feat(static-assets): introduce StaticAssetHostService and enhance static asset handling and i18n

- Added StaticAssetHostService to manage static asset host resolution with caching.
- Updated StaticAssetService and StaticDashboardService to utilize the new static asset host resolver.
- Enhanced StaticWebController to pass request host information for improved asset handling.
- Refactored static asset interfaces to support new functionality.
- Integrated CORS headers and cache policies for better asset management.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-18 17:32:39 +08:00
parent c94a601f83
commit efa5ade0da
32 changed files with 2675 additions and 636 deletions

View File

@@ -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<string, string | null>()
constructor(private readonly systemSettingService: SystemSettingService) {}
async getStaticAssetHost(requestHost?: string | null): Promise<string | null> {
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<string | null> {
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)
}
}

View File

@@ -31,6 +31,11 @@ export interface StaticAssetServiceOptions {
loggerName?: string
rewriteAssetReferences?: boolean
assetLinkRels?: Iterable<string>
staticAssetHostResolver?: (requestHost?: string | null) => Promise<string | null>
}
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<string>
private readonly staticAssetHostResolver?: (requestHost?: string | null) => Promise<string | null>
private staticRoot: string | null | undefined
private staticAssetHosts = new Map<string, string | null>()
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<Response | null> {
async handleRequest(
fullPath: string,
headOnly: boolean,
options?: StaticAssetRequestOptions,
): Promise<Response | null> {
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<void> {}
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<string | null> {
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<Response> {
private async createResponse(
file: ResolvedStaticAsset,
headOnly: boolean,
options?: StaticAssetRequestOptions,
): Promise<Response> {
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<Response> {
private async createHtmlResponse(
file: ResolvedStaticAsset,
headOnly: boolean,
options?: StaticAssetRequestOptions,
): Promise<Response> {
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<string> {
private async transformIndexHtml(
html: string,
file: ResolvedStaticAsset,
options?: StaticAssetRequestOptions,
): Promise<string> {
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<string | null> {
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 {

View File

@@ -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),
})
}

View File

@@ -101,7 +101,9 @@ export class StaticWebController {
private async serve(context: Context, service: StaticAssetService, headOnly: boolean): Promise<Response> {
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) {

View File

@@ -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 {}

View File

@@ -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),
})
}

View File

@@ -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<PhotoSyncResult>(finalResult)
@@ -409,7 +409,7 @@ export async function uploadPhotoAssets(
const event = camelCaseKeys<PhotoSyncProgressEvent>(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))
}

View File

@@ -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 (
<label className="flex w-full items-start gap-3 my-2 text-left text-text">
<Checkbox
@@ -18,8 +20,8 @@ export function DeleteFromStorageOption({ defaultChecked = false, disabled, onCh
}}
/>
<div className="text-sm leading-relaxed">
<p className="font-medium"></p>
<p className="text-xs text-text-tertiary"></p>
<p className="font-medium">{t('photos.library.delete.option.title')}</p>
<p className="text-xs text-text-tertiary">{t('photos.library.delete.option.description')}</p>
</div>
</label>
)

View File

@@ -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<string>([
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, unknown>) => string,
): { latitude: string; longitude: string; altitude?: string } | null => {
const latitudeValue = getExifValue<number | string>(exif, 'GPSLatitude')
const longitudeValue = getExifValue<number | string>(exif, 'GPSLongitude')
@@ -125,7 +231,11 @@ const convertGPSToDecimal = (
const altitudeRef = getExifValue<string>(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, unknown>) => 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<string>(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<string>(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<string>(exif, 'Make')
const model = getExifValue<string>(exif, 'Model')
@@ -172,77 +289,83 @@ const buildSections = (manifest: PhotoManifestItem): Section[] => {
})(),
},
{
label: '镜头',
label: t(exifKeys.rows.lens),
value: (() => {
const lensMake = getExifValue<string>(exif, 'LensMake')
const lensModel = getExifValue<string>(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<number | string>(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<number | string>(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<number>(exif, 'Rating')
return rating && rating > 0 ? `${'★'.repeat(rating)}` : null
})(),
},
{ label: '色彩空间', value: toReadableValue(getExifValue(exif, 'ColorSpace')) },
{ label: '时区', value: getExifValue<string>(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<string>(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<Record<string, unknown>>(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<PhotoExifDetailsModalProps> = ({ 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<PhotoExifDetailsModalProps> =
<DialogHeader>
<DialogTitle>{manifest.title || manifest.id}</DialogTitle>
<DialogDescription>
<p className="text-text-tertiary text-xs">{manifest.s3Key}</p>
<p className="text-text-tertiary text-xs">{t(exifKeys.headerFile, { value: manifest.s3Key })}</p>
</DialogDescription>
</DialogHeader>
@@ -301,7 +426,7 @@ export const PhotoExifDetailsModal: ModalComponent<PhotoExifDetailsModalProps> =
</ScrollArea>
) : (
<div className="border-fill-tertiary/50 bg-background/70 rounded-xl border px-4 py-8 text-center text-sm text-text-tertiary">
EXIF
{t(exifKeys.empty)}
</div>
)}
</div>

View File

@@ -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<string, I18nKeys>
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"
>
<DynamicIcon name="upload" className="h-3.5 w-3.5" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">{t(photoLibraryActionKeys.upload)}</span>
<span className="sm:hidden">{t(photoLibraryActionKeys.uploadShort)}</span>
</Button>
</div>
@@ -125,7 +143,7 @@ export function PhotoLibraryActionBar() {
'bg-accent/10 text-accent',
)}
>
{selectionCount}
{t(photoLibraryActionKeys.selectedCount, { count: selectionCount })}
</span>
<Button
type="button"
@@ -136,7 +154,7 @@ export function PhotoLibraryActionBar() {
className="flex items-center gap-1 text-text-secondary hover:text-text"
>
<DynamicIcon name="tags" className="h-3.5 w-3.5" />
{t(photoLibraryActionKeys.editTags)}
</Button>
<Button
type="button"
@@ -147,11 +165,11 @@ export function PhotoLibraryActionBar() {
className="flex items-center gap-1 text-rose-400 hover:text-rose-300"
>
<DynamicIcon name="trash-2" className="h-3.5 w-3.5" />
{t(photoLibraryActionKeys.delete)}
</Button>
<Button type="button" className="gap-1" variant="ghost" size="sm" onClick={clearSelection}>
<DynamicIcon name="x" className="h-3.5 w-3.5" />
{t(photoLibraryActionKeys.clear)}
</Button>
</div>
<Button
@@ -163,7 +181,7 @@ export function PhotoLibraryActionBar() {
className="flex items-center gap-1 text-text-secondary hover:text-text"
>
<DynamicIcon name={canSelectAll ? 'square' : 'check-square'} className="size-4" />
{hasAssets ? (canSelectAll ? '全选' : '已全选') : '全选'}
{selectAllLabel}
</Button>
</div>
</div>

View File

@@ -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: (
<DeleteFromStorageOption
onChange={(checked) => {
@@ -144,7 +166,7 @@ function PhotoGridItem({
<Thumbhash thumbHash={manifest.thumbHash} className="absolute inset-0" />
) : (
<div className="bg-background-secondary/80 text-text-tertiary flex h-48 w-full items-center justify-center">
{t(photoLibraryGridKeys.card.noPreview)}
</div>
)}
</div>
@@ -164,7 +186,7 @@ function PhotoGridItem({
)}
>
<DynamicIcon name={isSelected ? 'check' : 'square'} className="mr-1 h-3 w-3" />
<span>{isSelected ? '已选择' : '选择'}</span>
<span>{isSelected ? t(photoLibraryGridKeys.card.selected) : t(photoLibraryGridKeys.card.select)}</span>
</div>
</div>
@@ -209,14 +231,14 @@ function PhotoGridItem({
icon={<DynamicIcon name="tags" className="size-4" />}
onSelect={() => onEditTags(asset)}
>
{t('photos.library.card.edit-tags')}
</DropdownMenuItem>
<DropdownMenuItem
icon={<DynamicIcon name="info" className="size-4" />}
disabled={!manifest}
onSelect={handleViewExif}
>
EXIF
{t('photos.library.card.view-exif')}
</DropdownMenuItem>
<div className="h-[0.5px] bg-border my-1" />
<DropdownMenuItem
@@ -225,7 +247,7 @@ function PhotoGridItem({
onSelect={handleDelete}
className="text-red focus:text-red focus:bg-red/10"
>
{t('photos.library.card.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -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<PhotoLibrarySortBy>('uploadedAt')
@@ -289,8 +312,8 @@ export function PhotoLibraryGrid() {
} else if (!sortedAssets || sortedAssets.length === 0) {
content = (
<LinearBorderPanel className="bg-background-tertiary relative overflow-hidden p-4 sm:p-8 text-center">
<p className="text-text text-sm sm:text-base font-semibold"></p>
<p className="text-text-tertiary mt-2 text-xs sm:text-sm">使"上传图片"</p>
<p className="text-text text-sm sm:text-base font-semibold">{t('photos.library.empty.title')}</p>
<p className="text-text-tertiary mt-2 text-xs sm:text-sm">{t('photos.library.empty.description')}</p>
</LinearBorderPanel>
)
} else {
@@ -322,7 +345,7 @@ export function PhotoLibraryGrid() {
return (
<div className="space-y-3 relative">
<div className="flex flex-wrap items-center justify-end gap-2 text-xs absolute lg:translate-y-[-50px] -translate-y-10 -translate-x-2 lg:translate-x-0 lg:right-20">
<div className="flex flex-wrap items-center justify-end gap-2 text-xs absolute lg:translate-y-[-50px] -translate-y-10 -translate-x-2 lg:translate-x-0 lg:right-30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -332,7 +355,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"
>
<DynamicIcon name={currentSortBy.icon as any} className="size-4" />
<span className="font-medium">{currentSortBy.label}</span>
<span className="font-medium">{t(currentSortBy.labelKey)}</span>
<DynamicIcon name="chevron-down" className="h-3 w-3 text-text-tertiary" />
</Button>
</DropdownMenuTrigger>
@@ -344,7 +367,7 @@ export function PhotoLibraryGrid() {
icon={<DynamicIcon name={option.icon as any} className="size-4" />}
onSelect={() => setSortBy(option.value)}
>
{option.label}
{t(option.labelKey)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -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"
>
<DynamicIcon name={currentSortOrder.icon as any} className="size-4" />
<span className="font-medium">{currentSortOrder.label}</span>
<span className="font-medium">{t(currentSortOrder.labelKey)}</span>
<DynamicIcon name="chevron-down" className="h-3 w-3 text-text-tertiary" />
</Button>
</DropdownMenuTrigger>
@@ -371,7 +394,7 @@ export function PhotoLibraryGrid() {
icon={<DynamicIcon name={option.icon as any} className="size-4" />}
onSelect={() => setSortOrder(option.value)}
>
{option.label}
{t(option.labelKey)}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -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<PhotoTagEditorModalProps> = ({ 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<PhotoTagEditorModalProps> = ({
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<PhotoTagEditorModalProps> = ({
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<PhotoTagEditorModalProps> = ({
return (
<div className="space-y-4">
<DialogHeader>
<DialogTitle>{assetTitle}</DialogTitle>
<DialogTitle>{t(photoTagsKeys.modalTitle, { name: assetTitle })}</DialogTitle>
<DialogDescription>
{isMultiEdit
? '所有选中资源都会应用同样的标签。'
: '调整后将自动移动原图文件(及其 Live Photo 视频)到新的路径。'}
{isMultiEdit ? t(photoTagsKeys.modalDescriptionMultiple) : t(photoTagsKeys.modalDescriptionSingle)}
</DialogDescription>
</DialogHeader>
{nextPathPreview ? (
<div className="space-y-2 rounded-md border border-border/60 bg-background/60 p-3 text-xs text-text-tertiary">
<div className="flex items-center justify-between text-[11px] font-medium text-text">
<span>{isMultiEdit ? '示例存储路径(第一项)' : '新存储路径预览'}</span>
<span className="text-text-secondary"></span>
<span>{isMultiEdit ? t(photoTagsKeys.pathSample) : t(photoTagsKeys.pathPreview)}</span>
<span className="text-text-secondary">{t(photoTagsKeys.pathHint)}</span>
</div>
<p className="text-text rounded bg-background-secondary/60 px-2 py-1 font-mono text-xs">{nextPathPreview}</p>
</div>
@@ -117,7 +160,7 @@ export const PhotoTagEditorModal: ModalComponent<PhotoTagEditorModalProps> = ({
options={tagOptions}
value={tags}
onChange={setTags}
placeholder="输入后按 Enter 添加,或从常用标签中选择"
placeholder={t(photoTagsKeys.inputPlaceholder)}
disabled={isBusy}
/>
@@ -131,7 +174,7 @@ export const PhotoTagEditorModal: ModalComponent<PhotoTagEditorModalProps> = ({
onClick={dismiss}
className="text-text-secondary hover:text-text"
>
{t(photoTagsKeys.cancel)}
</Button>
<Button
type="button"
@@ -140,7 +183,7 @@ export const PhotoTagEditorModal: ModalComponent<PhotoTagEditorModalProps> = ({
disabled={!hasChanges || isBusy}
onClick={() => void handleSave()}
>
{isBusy ? '保存中…' : '保存'}
{isBusy ? t(photoTagsKeys.saving) : t(photoTagsKeys.save)}
</Button>
</DialogFooter>
</div>

View File

@@ -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)}
</Button>
<Button
type="button"
@@ -107,7 +136,7 @@ export function PhotoSyncActions() {
isLoading={isPending && pendingMode === 'apply'}
onClick={() => handleSync(false)}
>
{t(photoSyncActionKeys.buttonApply)}
</Button>
</div>
)

View File

@@ -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<string>
}
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<void>) => {
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({
<div className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-text text-base font-semibold"></h3>
<p className="text-text-tertiary mt-1 text-sm">
</p>
<h3 className="text-text text-base font-semibold">{t(photoSyncConflictsKeys.title)}</h3>
<p className="text-text-tertiary mt-1 text-sm">{t(photoSyncConflictsKeys.description)}</p>
</div>
<span className="text-text-tertiary text-xs">{sortedConflicts.length}</span>
<span className="text-text-tertiary text-xs">
{t(photoSyncConflictsKeys.total, { count: sortedConflicts.length })}
</span>
</div>
{isLoading ? (
@@ -271,11 +425,13 @@ export function PhotoSyncConflictsPanel({
onCheckedChange={(checked) => toggleAllSelection(Boolean(checked))}
/>
<span className="text-text-tertiary text-xs">
{hasSelection ? `已选 ${selectedIds.size}` : '未选择条目'}
{hasSelection
? t(photoSyncConflictsKeys.selection.selected, { count: selectedIds.size })
: t(photoSyncConflictsKeys.selection.none)}
</span>
{hasSelection ? (
<Button type="button" variant="ghost" size="xs" disabled={isProcessing} onClick={clearSelection}>
{t(photoSyncConflictsKeys.actions.clearSelection)}
</Button>
) : null}
</div>
@@ -289,7 +445,7 @@ export function PhotoSyncConflictsPanel({
disabled={isProcessing}
onClick={() => void handleAcceptSelected('prefer-storage')}
>
{t(photoSyncConflictsKeys.actions.selectedStorage)}
</Button>
<Button
type="button"
@@ -298,7 +454,7 @@ export function PhotoSyncConflictsPanel({
disabled={isProcessing}
onClick={() => void handleAcceptSelected('prefer-database')}
>
{t(photoSyncConflictsKeys.actions.selectedDatabase)}
</Button>
</>
) : (
@@ -310,7 +466,7 @@ export function PhotoSyncConflictsPanel({
disabled={isProcessing || sortedConflicts.length === 0}
onClick={() => void handleAcceptAll('prefer-storage')}
>
{t(photoSyncConflictsKeys.actions.allStorage)}
</Button>
<Button
type="button"
@@ -319,7 +475,7 @@ export function PhotoSyncConflictsPanel({
disabled={isProcessing || sortedConflicts.length === 0}
onClick={() => void handleAcceptAll('prefer-database')}
>
{t(photoSyncConflictsKeys.actions.allDatabase)}
</Button>
</>
)}
@@ -359,25 +515,31 @@ export function PhotoSyncConflictsPanel({
<span className="rounded-full bg-amber-500/10 px-2.5 py-1 text-xs font-semibold text-amber-400">
{typeLabel}
</span>
<code className="text-text-secondary text-xs">{conflict.photoId ?? '未绑定 Photo ID'}</code>
<code className="text-text-secondary text-xs">
{conflict.photoId ?? t(photoSyncConflictsKeys.info.photoIdFallback)}
</code>
{typeConfig ? (
<span className="text-text-tertiary text-xs">{t(typeConfig.descriptionKey)}</span>
) : null}
</div>
<div className="text-text-tertiary flex flex-wrap justify-end gap-2 text-xs">
<span>{formatDate(conflict.updatedAt)}</span>
<span>{formatDate(conflict.syncedAt)}</span>
<span>
{t(photoSyncConflictsKeys.info.lastUpdated, { time: formatDate(conflict.updatedAt) })}
</span>
<span>
{t(photoSyncConflictsKeys.info.firstDetected, { time: formatDate(conflict.syncedAt) })}
</span>
</div>
</div>
<div className="text-text-tertiary flex flex-wrap gap-3 text-xs">
<span>
Key
{t(photoSyncConflictsKeys.info.storageKey)}
<code className="text-text ml-1 font-mono text-[11px]">{conflict.storageKey}</code>
</span>
{payload?.incomingStorageKey ? (
<span>
Key
{t(photoSyncConflictsKeys.info.conflictKey)}
<code className="text-text ml-1 font-mono text-[11px]">{payload.incomingStorageKey}</code>
</span>
) : null}
@@ -400,11 +562,11 @@ export function PhotoSyncConflictsPanel({
</div>
<div className="text-text-tertiary grid gap-3 text-xs md:grid-cols-2">
<div>
<p className="text-text font-semibold"></p>
<p className="text-text font-semibold">{t(photoSyncConflictsKeys.metadata.database)}</p>
<MetadataSnapshot snapshot={payload?.recordSnapshot ?? null} />
</div>
<div>
<p className="text-text font-semibold"></p>
<p className="text-text font-semibold">{t(photoSyncConflictsKeys.metadata.storage)}</p>
<MetadataSnapshot snapshot={payload?.storageSnapshot ?? null} />
</div>
</div>
@@ -419,7 +581,7 @@ export function PhotoSyncConflictsPanel({
disabled={isResolving || isProcessing}
onClick={() => void handleResolve(conflict, 'prefer-storage')}
>
{t(photoSyncConflictsKeys.actions.preferStorage)}
</Button>
<Button
type="button"
@@ -428,7 +590,7 @@ export function PhotoSyncConflictsPanel({
disabled={isResolving || isProcessing}
onClick={() => void handleResolve(conflict, 'prefer-database')}
>
{t(photoSyncConflictsKeys.actions.preferDatabase)}
</Button>
<Button
type="button"
@@ -437,7 +599,9 @@ export function PhotoSyncConflictsPanel({
disabled={isProcessing}
onClick={() => toggleExpand(conflict.id)}
>
{expandedId === conflict.id ? '收起详情' : '查看详情'}
{expandedId === conflict.id
? t(photoSyncConflictsKeys.actions.hideDetails)
: t(photoSyncConflictsKeys.actions.viewDetails)}
</Button>
</div>
</div>
@@ -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 (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
<p className="text-text text-sm font-semibold"></p>
<p className="mt-1"></p>
<p className="text-text text-sm font-semibold">{t(photoSyncConflictsKeys.preview.databaseTitle)}</p>
<p className="mt-1">{t(photoSyncConflictsKeys.preview.databaseEmpty)}</p>
</div>
)
}
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 (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
@@ -483,28 +661,28 @@ function ConflictManifestPreview({
<img src={manifest.thumbnailUrl} alt={manifest.id} className="h-16 w-20 rounded-md object-cover" />
) : null}
<div className="space-y-1">
<p className="text-text text-sm font-semibold"></p>
<p className="text-text text-sm font-semibold">{t(photoSyncConflictsKeys.preview.databaseTitle)}</p>
<div className="flex items-center gap-2">
<span className="text-text">ID</span>
<span className="text-text">{t(photoSyncConflictsKeys.preview.idLabel)}</span>
<span className="truncate">{manifest.id}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-text"></span>
<span className="text-text">{t(photoSyncConflictsKeys.preview.dimensions)}</span>
<span>{dimensions}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-text"></span>
<span className="text-text">{t(photoSyncConflictsKeys.preview.size)}</span>
<span>{sizeMB}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-text"></span>
<span className="text-text">{t(photoSyncConflictsKeys.preview.updatedAt)}</span>
<span>{updatedAt}</span>
</div>
</div>
</div>
{onOpenOriginal ? (
<Button type="button" variant="ghost" size="xs" className="mt-3" disabled={disabled} onClick={onOpenOriginal}>
{t(photoSyncConflictsKeys.actions.viewOriginal)}
</Button>
) : null}
</div>
@@ -522,18 +700,19 @@ function ConflictStoragePreview({
disabled?: boolean
onOpenStorage?: () => void
}) {
const { t } = useTranslation()
return (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
<div className="flex items-center justify-between">
<p className="text-text text-sm font-semibold"></p>
<p className="text-text text-sm font-semibold">{t(photoSyncConflictsKeys.preview.storageTitle)}</p>
{onOpenStorage ? (
<Button type="button" variant="ghost" size="xs" disabled={disabled} onClick={onOpenStorage}>
{t(photoSyncConflictsKeys.actions.openStorage)}
</Button>
) : null}
</div>
<p className="mt-1 break-all">
Key
{t(photoSyncConflictsKeys.preview.storageKey)}
<span className="text-text font-mono text-[11px]">{storageKey}</span>
</p>
<MetadataSnapshot snapshot={snapshot ?? null} />

View File

@@ -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<PhotoSyncProgressStage, { label: string; description: string }> = {
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<PhotoSyncProgressStage, { labelKey: I18nKeys; descriptionKey: I18nKeys }> = {
'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<PhotoSyncProgressState['stages'][PhotoSyncProgressStage]['status'], string> = {
pending: '等待中',
running: '进行中',
completed: '已完成',
const STATUS_LABEL: Record<PhotoSyncProgressState['stages'][PhotoSyncProgressStage]['status'], I18nKeys> = {
pending: photoSyncProgressKeys.stageStatus.pending,
running: photoSyncProgressKeys.stageStatus.running,
completed: photoSyncProgressKeys.stageStatus.completed,
}
const LOG_LEVEL_CONFIG: Record<PhotoSyncLogLevel, { label: string; className: string }> = {
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<PhotoSyncLogLevel, { labelKey: I18nKeys; className: string }> = {
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
<BorderOverlay />
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-text text-sm font-semibold">{item.config.label}</p>
<p className="text-text-tertiary mt-1 text-xs">{item.config.description}</p>
<p className="text-text text-sm font-semibold">{t(item.config.labelKey)}</p>
<p className="text-text-tertiary mt-1 text-xs">{t(item.config.descriptionKey)}</p>
</div>
<span className="text-text-tertiary text-xs font-medium">{STATUS_LABEL[item.status]}</span>
<span className="text-text-tertiary text-xs font-medium">{t(STATUS_LABEL[item.status])}</span>
</div>
<div className="bg-fill/30 mt-3 h-1.5 rounded-full">
<m.div
@@ -157,7 +250,9 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
/>
</div>
<div className="text-text-tertiary mt-2 text-xs">
{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)}
</div>
</div>
))}
@@ -176,8 +271,10 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
<div className="border-border/20 bg-fill/10 mt-6 overflow-hidden rounded-lg border p-4">
<BorderOverlay />
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-text text-sm font-semibold"></p>
<span className="text-text-tertiary text-xs"> {recentLogs.length} </span>
<p className="text-text text-sm font-semibold">{t(photoSyncProgressKeys.logs.title)}</p>
<span className="text-text-tertiary text-xs">
{t(photoSyncProgressKeys.logs.recent, { count: recentLogs.length })}
</span>
</div>
<div className="mt-3 max-h-48 space-y-2 overflow-y-auto pr-1">
{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 (
<div
@@ -214,11 +319,11 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
>
<span className="text-text-tertiary tabular-nums">{formatLogTimestamp(log.timestamp)}</span>
<span className={`${levelConfig.className} rounded-full px-2 py-0.5 text-[11px] font-medium`}>
{levelConfig.label}
{t(levelConfig.labelKey)}
</span>
<span className="text-text">{log.message}</span>
{log.storageKey ? <code className="text-text-secondary">{log.storageKey}</code> : null}
{log.stage ? <span className="text-text-tertiary">{STAGE_CONFIG[log.stage].label}</span> : null}
{log.stage ? <span className="text-text-tertiary">{t(STAGE_CONFIG[log.stage].labelKey)}</span> : null}
{detailSegments.length > 0 ? (
<span className="text-text-tertiary">{detailSegments.join(' · ')}</span>
) : null}
@@ -232,16 +337,18 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
{lastAction ? (
<div className="border-border/20 bg-fill/10 mt-6 overflow-hidden rounded-lg border p-4">
<BorderOverlay />
<p className="text-text-tertiary text-xs tracking-wide uppercase"></p>
<p className="text-text-tertiary text-xs tracking-wide uppercase">{t(photoSyncProgressKeys.recent.title)}</p>
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm">
<span className="bg-accent/10 text-accent rounded-full px-2 py-0.5">
{formatActionLabel(lastAction.action)}
</span>
<code className="text-text-secondary text-xs">{lastAction.action.storageKey}</code>
<span className="text-text-tertiary text-xs">{STAGE_CONFIG[lastAction.stage].label}</span>
<span className="text-text-tertiary text-xs">{t(STAGE_CONFIG[lastAction.stage].labelKey)}</span>
</div>
<p className="text-text-tertiary mt-2 text-xs">
{lastAction.total > 0 ? `进度:${lastAction.index} / ${lastAction.total}` : '无需进一步处理'}
{lastAction.total > 0
? t(photoSyncProgressKeys.recent.progress, { processed: lastAction.index, total: lastAction.total })
: t(photoSyncProgressKeys.recent.noFurther)}
</p>
</div>
) : null}

View File

@@ -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<string, I18nKeys>
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}
</span>
<code className="text-text-secondary text-xs">{storageKey}</code>
{photoId ? <span className="text-text-tertiary text-xs">Photo ID{photoId}</span> : null}
{photoId ? (
<span className="text-text-tertiary text-xs">
{t(photoSyncResultKeys.info.photoId)} {photoId}
</span>
) : null}
</div>
<span className="text-text-tertiary inline-flex items-center gap-1 text-xs">
<span>{applied ? '已应用' : '未应用'}</span>
<span>{applied ? t(photoSyncResultKeys.actions.applied) : t(photoSyncResultKeys.actions.pending)}</span>
{resolutionLabel ? <span>· {resolutionLabel}</span> : null}
</span>
</div>
@@ -309,10 +414,14 @@ export function PhotoSyncResultPanel({
{conflictTypeLabel || conflictPayload?.incomingStorageKey ? (
<div className="text-text-tertiary text-xs">
{conflictTypeLabel ? <span>{conflictTypeLabel}</span> : null}
{conflictTypeLabel ? (
<span>
{t(photoSyncResultKeys.info.conflictType)} {conflictTypeLabel}
</span>
) : null}
{conflictPayload?.incomingStorageKey ? (
<span className="ml-2">
Key
{t(photoSyncResultKeys.info.storageKey)}
<code className="text-text ml-1 font-mono text-[11px]">{conflictPayload.incomingStorageKey}</code>
</span>
) : null}
@@ -321,9 +430,9 @@ export function PhotoSyncResultPanel({
{(beforeManifest || afterManifest) && (
<div className="grid gap-3 md:grid-cols-2">
<ManifestPreview title="数据库记录" manifest={beforeManifest} />
<ManifestPreview variant="database" manifest={beforeManifest} />
<ManifestPreview
title="存储对象"
variant="storage"
manifest={afterManifest}
onOpenOriginal={() => handleOpenOriginal(action)}
/>
@@ -334,13 +443,13 @@ export function PhotoSyncResultPanel({
<div className="text-text-tertiary grid gap-4 text-xs md:grid-cols-2">
{action.snapshots.before ? (
<div className="mt-4">
<p className="text-text font-semibold"></p>
<p className="text-text font-semibold">{t('photos.sync.metadata.database')}</p>
<MetadataSnapshot snapshot={action.snapshots.before} />
</div>
) : null}
{action.snapshots.after ? (
<div className="mt-4">
<p className="text-text font-semibold"></p>
<p className="text-text font-semibold">{t('photos.sync.metadata.storage')}</p>
<MetadataSnapshot snapshot={action.snapshots.after} />
</div>
) : null}
@@ -357,16 +466,20 @@ export function PhotoSyncResultPanel({
<BorderOverlay />
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-text text-base font-semibold"></h2>
<h2 className="text-text text-base font-semibold">{t(photoSyncResultKeys.history.heading)}</h2>
<p className="text-text-tertiary text-sm">
<span> {lastSyncRunMeta.completedLabel}</span>
<span>{t(photoSyncResultKeys.history.completedAt, { time: lastSyncRunMeta.completedLabel })}</span>
<span className="mx-1">·</span>
<span> {lastSyncRunMeta.durationLabel}</span>
<span>{t(photoSyncResultKeys.history.duration, { duration: lastSyncRunMeta.durationLabel })}</span>
<span className="mx-1">·</span>
<span>{lastSyncRun.dryRun ? '预览模式 · 未写入数据库' : '实时模式 · 已写入数据库'}</span>
<span>
{lastSyncRun.dryRun
? t(photoSyncResultKeys.history.modePreview)
: t(photoSyncResultKeys.history.modeLive)}
</span>
</p>
<p className="text-text-tertiary text-xs">
<span> {lastSyncRun.actionsCount} </span>
<span>{t(photoSyncResultKeys.history.operations, { count: lastSyncRun.actionsCount })}</span>
</p>
</div>
{summaryItems.length > 0 ? (
@@ -389,12 +502,12 @@ export function PhotoSyncResultPanel({
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-base font-semibold">
{isAwaitingStatus ? '正在加载同步状态' : '尚未执行同步'}
{isAwaitingStatus ? t(photoSyncResultKeys.status.loadingTitle) : t(photoSyncResultKeys.status.emptyTitle)}
</h2>
<p className="text-text-tertiary text-sm">
{isAwaitingStatus
? '正在查询最近一次同步记录,请稍候…'
: '请在系统设置中配置并激活存储提供商,然后使用右上角的按钮执行同步操作。预览模式不会写入数据,可用于安全检查。'}
? t(photoSyncResultKeys.status.loadingDescription)
: t(photoSyncResultKeys.status.emptyDescription)}
</p>
</div>
{showSkeleton ? (
@@ -419,20 +532,20 @@ export function PhotoSyncResultPanel({
<div className="space-y-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-text text-lg font-semibold"></h2>
<h2 className="text-text text-lg font-semibold">{t(photoSyncResultKeys.summary.heading)}</h2>
<p className="text-text-tertiary text-sm">
{lastWasDryRun === null
? '以下为最新同步结果。'
? t(photoSyncResultKeys.summary.descriptionLatest)
: lastWasDryRun
? '最近执行了预览模式,数据库未发生变更。'
: '最近一次同步结果已写入数据库。'}
? t(photoSyncResultKeys.summary.descriptionPreview)
: t(photoSyncResultKeys.summary.descriptionLive)}
</p>
</div>
<p className="text-text-tertiary text-xs">
<span>{filteredActions.length}</span>
<span>{t(photoSyncResultKeys.operations.count, { count: filteredActions.length })}</span>
{result && selectedActionType !== 'all' ? (
<span className="ml-1 inline-flex items-center gap-1">
<span>· </span>
<span>{t(photoSyncResultKeys.operations.filterLabel)}</span>
<span>{activeFilter?.label ?? ''}</span>
</span>
) : null}
@@ -456,9 +569,9 @@ export function PhotoSyncResultPanel({
<BorderOverlay />
<div className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-text text-base font-semibold"></h3>
<h3 className="text-text text-base font-semibold">{t(photoSyncResultKeys.table.title)}</h3>
<span className="text-text-tertiary text-xs">
{lastWasDryRun ? '预览模式 · 未应用变更' : '实时模式 · 结果已写入'}
{lastWasDryRun ? t(photoSyncResultKeys.table.modePreview) : t(photoSyncResultKeys.table.modeLive)}
</span>
</div>
@@ -483,7 +596,7 @@ export function PhotoSyncResultPanel({
{filteredActions.length === 0 ? (
<p className="text-text-tertiary text-sm mt-4">
{result ? '当前筛选下没有需要查看的操作。' : '同步完成,未检测到需要处理的对象。'}
{result ? t(photoSyncResultKeys.table.emptyFiltered) : t(photoSyncResultKeys.table.emptyNone)}
</p>
) : (
<div className="space-y-3">
@@ -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({
</span>
<code className="text-text-secondary text-xs">{action.storageKey}</code>
{action.photoId ? (
<span className="text-text-tertiary text-xs">Photo ID{action.photoId}</span>
<span className="text-text-tertiary text-xs">
{t(photoSyncResultKeys.info.photoId)} {action.photoId}
</span>
) : null}
</div>
<div className="text-text-tertiary flex flex-wrap items-center gap-2 text-xs">
<span>{action.applied ? '已应用' : '未应用'}</span>
<span>
{action.applied
? t(photoSyncResultKeys.actions.applied)
: t(photoSyncResultKeys.actions.pending)}
</span>
{resolutionLabel ? <span>· {resolutionLabel}</span> : null}
<Button
type="button"
@@ -536,7 +655,9 @@ export function PhotoSyncResultPanel({
variant="ghost"
onClick={() => handleToggleAction(actionKey)}
>
{isExpanded ? '收起详情' : '查看详情'}
{isExpanded
? t(photoSyncResultKeys.actions.collapse)
: t(photoSyncResultKeys.actions.expand)}
</Button>
</div>
</div>
@@ -545,10 +666,14 @@ export function PhotoSyncResultPanel({
{conflictTypeLabel || incomingKey ? (
<div className="text-text-tertiary text-xs">
{conflictTypeLabel ? <span>{conflictTypeLabel}</span> : null}
{conflictTypeLabel ? (
<span>
{t(photoSyncResultKeys.info.conflictType)} {conflictTypeLabel}
</span>
) : null}
{incomingKey ? (
<span className="ml-2">
Key
{t(photoSyncResultKeys.info.storageKey)}
<code className="text-text ml-1 font-mono text-[11px]">{incomingKey}</code>
</span>
) : 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 (
<div className="border-border/20 bg-background-secondary/60 text-text-tertiary rounded-md border p-3 text-xs">
<p className="text-text text-sm font-semibold">{title}</p>
<p className="mt-1"></p>
<p className="text-text text-sm font-semibold">{t(titleKey)}</p>
<p className="mt-1">{t(emptyLabel)}</p>
</div>
)
}
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 (
<div className="border-border/20 bg-background-secondary/60 rounded-md border p-3">
@@ -601,28 +741,28 @@ function ManifestPreview({
<img src={manifest.thumbnailUrl} alt={manifest.id} className="h-16 w-20 rounded-md object-cover" />
) : null}
<div className="text-text-tertiary space-y-1 text-xs">
<p className="text-text text-sm font-semibold">{title}</p>
<p className="text-text text-sm font-semibold">{t(titleKey)}</p>
<div className="flex items-center gap-2">
<span className="text-text">ID</span>
<span className="text-text">{t('photos.sync.conflicts.preview.common.id')}</span>
<span className="truncate">{manifest.id}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-text"></span>
<span className="text-text">{t('photos.sync.conflicts.preview.common.dimensions')}</span>
<span>{dimensions}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-text"></span>
<span className="text-text">{t('photos.sync.conflicts.preview.common.size')}</span>
<span>{sizeMB}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-text"></span>
<span className="text-text">{t('photos.sync.conflicts.preview.common.updated-at')}</span>
<span>{updatedAt}</span>
</div>
</div>
</div>
{onOpenOriginal ? (
<Button type="button" variant="ghost" size="xs" className="mt-3" onClick={onOpenOriginal}>
{t('photos.sync.conflicts.actions.view-original')}
</Button>
) : null}
</div>
@@ -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 (
<dl className="mt-2 space-y-1">
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right">
{snapshot.size !== null ? `${(snapshot.size / 1024 / 1024).toFixed(2)} MB` : '未知'}
</dd>
<dt>{t('photos.sync.metadata.size')}</dt>
<dd className="text-text text-right">{sizeLabel}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt>ETag</dt>
<dd className="text-text text-right font-mono text-[10px]">{snapshot.etag ?? '未知'}</dd>
<dt>{t('photos.sync.metadata.etag')}</dt>
<dd className="text-text text-right font-mono text-[10px]">{etagLabel}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right">{snapshot.lastModified ?? '未知'}</dd>
<dt>{t('photos.sync.metadata.updated-at')}</dt>
<dd className="text-text text-right">{updatedAtLabel}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right font-mono text-[10px]">{snapshot.metadataHash ?? '无'}</dd>
<dt>{t('photos.sync.metadata.hash')}</dt>
<dd className="text-text text-right font-mono text-[10px]">{hashLabel}</dd>
</div>
</dl>
)

View File

@@ -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<PhotoSyncResult | null>(null)
const [lastWasDryRun, setLastWasDryRun] = useState<boolean | null>(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],
})
}

View File

@@ -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 }:
<LinearBorderPanel className="bg-background-secondary/60 p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold text-text"></h3>
<p className="text-sm text-text-secondary"></p>
<h3 className="text-base font-semibold text-text">{t(photoUsageI18nKeys.summary.title)}</h3>
<p className="text-sm text-text-secondary">{t(photoUsageI18nKeys.summary.description)}</p>
</div>
<Button
type="button"
@@ -58,7 +110,7 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
disabled={isFetching || isLoading}
>
<i className={`i-lucide-rotate-cw size-4 ${isFetching ? 'animate-spin' : ''}`} aria-hidden />
{t(photoUsageI18nKeys.summary.refresh)}
</Button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
@@ -71,6 +123,7 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
description={item.description}
value={item.value}
tone={item.tone}
numberFormatter={numberFormatter}
/>
))}
</div>
@@ -79,10 +132,12 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
<LinearBorderPanel className="bg-background-secondary/60">
<div className="flex flex-col gap-2 border-b border-border/30 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold text-text"></h3>
<p className="text-sm text-text-secondary"></p>
<h3 className="text-base font-semibold text-text">{t(photoUsageI18nKeys.events.title)}</h3>
<p className="text-sm text-text-secondary">{t(photoUsageI18nKeys.events.description)}</p>
</div>
{events.length > 0 && <p className="text-xs text-text-tertiary"> {events.length} </p>}
{events.length > 0 && (
<p className="text-xs text-text-tertiary">{t(photoUsageI18nKeys.events.total, { count: events.length })}</p>
)}
</div>
{isLoading ? (
@@ -93,13 +148,19 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
</div>
) : isEmpty ? (
<div className="px-5 py-12 text-center">
<p className="text-lg font-medium text-text"></p>
<p className="mt-2 text-sm text-text-secondary"></p>
<p className="text-lg font-medium text-text">{t(photoUsageI18nKeys.events.emptyTitle)}</p>
<p className="mt-2 text-sm text-text-secondary">{t(photoUsageI18nKeys.events.emptyDescription)}</p>
</div>
) : (
<div className="divide-y divide-border/10">
{events.map((event) => (
<UsageEventRow key={event.id} event={event} />
<UsageEventRow
key={event.id}
event={event}
numberFormatter={numberFormatter}
dateTimeFormatter={dateTimeFormatter}
relativeTimeFormatter={relativeFormatter}
/>
))}
</div>
)}
@@ -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 (
<LinearBorderPanel className="bg-background-tertiary/80 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-text-tertiary">{label}</p>
<p className={`mt-2 text-2xl font-semibold ${toneClass}`}>{NUMBER_FORMATTER.format(value)}</p>
<p className={`mt-2 text-2xl font-semibold ${toneClass}`}>{numberFormatter.format(value)}</p>
<p className="mt-1 text-xs text-text-secondary">{description}</p>
</LinearBorderPanel>
)
@@ -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 (
<div className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:gap-6">
@@ -156,8 +232,8 @@ function UsageEventRow({ event }: UsageEventRowProps) {
<MetadataBadges metadata={event.metadata} />
</div>
<div className="flex flex-col items-start gap-1 text-right text-sm sm:min-w-[160px]">
<p className={`text-base font-semibold ${quantityClass}`}>{NUMBER_FORMATTER.format(event.quantity)}</p>
<p className="text-xs text-text-secondary">{event.unit === 'byte' ? '字节' : '次数'}</p>
<p className={`text-base font-semibold ${quantityClass}`}>{numberFormatter.format(event.quantity)}</p>
<p className="text-xs text-text-secondary">{t(photoUsageI18nKeys.events.unitLabel, { unit: unitLabel })}</p>
</div>
<div className="text-right text-sm text-text-secondary sm:min-w-[180px]">
<p>{dateLabel}</p>
@@ -168,17 +244,19 @@ function UsageEventRow({ event }: UsageEventRowProps) {
}
function MetadataBadges({ metadata }: { metadata: Record<string, unknown> | null }) {
const { t } = useTranslation()
if (!metadata) {
return <p className="mt-3 text-xs text-text-tertiary"></p>
return <p className="mt-3 text-xs text-text-tertiary">{t(photoUsageI18nKeys.events.metadataEmpty)}</p>
}
const entries = Object.entries(metadata).filter(([, value]) => value != null)
if (entries.length === 0) {
return <p className="mt-3 text-xs text-text-tertiary"></p>
return <p className="mt-3 text-xs text-text-tertiary">{t(photoUsageI18nKeys.events.metadataEmpty)}</p>
}
const visibleEntries = entries.slice(0, 4)
const remaining = entries.length - visibleEntries.length
const valueFallback = t(photoUsageI18nKeys.events.metadataValueUnknown)
return (
<div className="mt-3 flex flex-wrap gap-2">
@@ -187,10 +265,14 @@ function MetadataBadges({ metadata }: { metadata: Record<string, unknown> | 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)}
</span>
))}
{remaining > 0 && <span className="text-xs text-text-tertiary">+{remaining} </span>}
{remaining > 0 && (
<span className="text-xs text-text-tertiary">
{t(photoUsageI18nKeys.events.metadataMore, { count: remaining })}
</span>
)}
</div>
)
}
@@ -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')
}

View File

@@ -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<string>, rawValue: string | null): SchemaFormValue {
const { component } = field
@@ -67,6 +89,7 @@ function serializeValue(field: UiFieldNode<string>, 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)}
</Button>
</MainPageLayout.Actions>
)
@@ -209,7 +232,10 @@ export function SiteSettingsForm() {
<LinearBorderPanel className="p-6">
<div className="text-red flex items-center gap-3 text-sm">
<i className="i-mingcute-close-circle-fill text-lg" />
<span>{`无法加载站点设置:${error instanceof Error ? error.message : '未知错误'}`}</span>
<span>
{t(siteSettingsKeys.errors.loadPrefix)}{' '}
{error instanceof Error ? error.message : t(siteSettingsKeys.errors.unknown)}
</span>
</div>
</LinearBorderPanel>
</>
@@ -238,12 +264,12 @@ export function SiteSettingsForm() {
<div className="flex justify-end">
<div className="text-text-tertiary text-xs">
{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)}
</div>
</div>
</m.form>

View File

@@ -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)}
</Button>
</MainPageLayout.Actions>
)
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() {
<LinearBorderPanel className="p-6">
<div className="text-red flex items-center gap-3 text-sm">
<i className="i-mingcute-close-circle-fill text-lg" />
<span>{getRequestErrorMessage(error, '无法加载用户信息')}</span>
<span>{getRequestErrorMessage(error, t(siteUserKeys.loadingError))}</span>
</div>
</LinearBorderPanel>
</>
@@ -192,16 +252,21 @@ export function SiteUserProfileForm() {
<LinearBorderPanel className="bg-background-secondary">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-6">
<div>
<p className="text-text-tertiary text-xs font-semibold uppercase tracking-wider"></p>
<h2 className="text-text mt-1 text-xl font-semibold"></h2>
<p className="text-text-tertiary mt-1 text-sm">
RSS Feed
<p className="text-text-tertiary text-xs font-semibold uppercase tracking-wider">
{t(siteUserKeys.header.badge)}
</p>
<h2 className="text-text mt-1 text-xl font-semibold">{t(siteUserKeys.header.title)}</h2>
<p className="text-text-tertiary mt-1 text-sm">{t(siteUserKeys.header.description)}</p>
</div>
<div className="flex items-center gap-4">
<div className="relative size-16 sm:size-20 overflow-hidden rounded-full border border-white/5 shadow-inner">
{avatarPreview ? (
<img src={avatarPreview} alt="用户头像预览" className="h-full w-full object-cover" loading="lazy" />
<img
src={avatarPreview}
alt={t(siteUserKeys.preview.avatarAlt)}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="bg-accent/15 text-accent flex h-full w-full items-center justify-center text-2xl font-semibold">
{previewInitial}
@@ -209,10 +274,14 @@ export function SiteUserProfileForm() {
)}
</div>
<div className="space-y-1 text-sm">
<p className="text-text font-semibold">{formState.displayUsername || formState.name || '作者'}</p>
<p className="text-text font-semibold">
{formState.displayUsername || formState.name || t(siteUserKeys.preview.fallbackName)}
</p>
<p className="text-text-tertiary text-xs">{profile?.email}</p>
<p className="text-text-tertiary text-xs">
{formatTimestamp(profile?.updatedAt) || '尚未更新'}
{t(siteUserKeys.preview.lastUpdated, {
time: formatTimestamp(profile?.updatedAt) || t(siteUserKeys.preview.neverUpdated),
})}
</p>
</div>
</div>
@@ -228,49 +297,49 @@ export function SiteUserProfileForm() {
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="user-name"></Label>
<Label htmlFor="user-name">{t(siteUserKeys.form.name.label)}</Label>
<Input
id="user-name"
value={formState.name}
onInput={(event) => handleChange('name')(event.currentTarget.value)}
placeholder="例如Innei"
placeholder={t(siteUserKeys.form.name.placeholder)}
required
/>
<FormHelperText> RSS /</FormHelperText>
<FormHelperText>{t(siteUserKeys.form.name.helper)}</FormHelperText>
</div>
<div className="space-y-2">
<Label htmlFor="user-display"></Label>
<Label htmlFor="user-display">{t(siteUserKeys.form.display.label)}</Label>
<Input
id="user-display"
value={formState.displayUsername}
onInput={(event) => handleChange('displayUsername')(event.currentTarget.value)}
placeholder="可选例如innei.photo"
placeholder={t(siteUserKeys.form.display.placeholder)}
/>
<FormHelperText>使</FormHelperText>
<FormHelperText>{t(siteUserKeys.form.display.helper)}</FormHelperText>
</div>
<div className="space-y-2">
<Label htmlFor="user-username"></Label>
<Label htmlFor="user-username">{t(siteUserKeys.form.username.label)}</Label>
<Input
id="user-username"
value={formState.username}
onInput={(event) => handleChange('username')(event.currentTarget.value)}
placeholder="例如innei"
placeholder={t(siteUserKeys.form.username.placeholder)}
/>
<FormHelperText></FormHelperText>
<FormHelperText>{t(siteUserKeys.form.username.helper)}</FormHelperText>
</div>
<div className="space-y-2">
<Label htmlFor="user-avatar"></Label>
<Label htmlFor="user-avatar">{t(siteUserKeys.form.avatar.label)}</Label>
<Input
id="user-avatar"
type="url"
value={formState.avatar}
onInput={(event) => handleChange('avatar')(event.currentTarget.value)}
placeholder="https://cdn.example.com/avatar.png"
placeholder={t(siteUserKeys.form.avatar.placeholder)}
/>
<FormHelperText> http(s) // 开头的链接,留空则使用首字母。</FormHelperText>
<FormHelperText>{t(siteUserKeys.form.avatar.helper)}</FormHelperText>
</div>
</div>
</m.form>

View File

@@ -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<ProviderCardProps> = ({ 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<ProviderCardProps> = ({ 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<ProviderCardProps> = ({ provider, isActive, onEdit
<div className="absolute top-3 right-3">
<span className="bg-accent inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white uppercase">
<DynamicIcon name="check-circle" className="h-3 w-3" />
Active
{t(storageProvidersI18nKeys.card.active)}
</span>
</div>
)}
@@ -97,8 +100,10 @@ export const ProviderCard: FC<ProviderCardProps> = ({ provider, isActive, onEdit
{/* Provider Info */}
<div className="relative flex-1 space-y-1">
<h3 className="text-text text-sm font-semibold">{provider.name || '未命名存储'}</h3>
<p className="text-text-tertiary text-xs font-medium">{config.label}</p>
<h3 className="text-text text-sm font-semibold">
{provider.name || t(storageProvidersI18nKeys.card.untitled)}
</h3>
<p className="text-text-tertiary text-xs font-medium">{t(config.labelKey)}</p>
<p className="text-text-tertiary/70 truncate text-xs">{getPreviewInfo()}</p>
</div>
@@ -113,7 +118,7 @@ export const ProviderCard: FC<ProviderCardProps> = ({ provider, isActive, onEdit
onClick={onToggleActive}
>
<DynamicIcon name="x-circle" className="mr-1 h-3.5 w-3.5" />
<span>Make Inactive</span>
<span>{t(storageProvidersI18nKeys.card.makeInactive)}</span>
</Button>
) : (
<Button
@@ -124,12 +129,12 @@ export const ProviderCard: FC<ProviderCardProps> = ({ provider, isActive, onEdit
onClick={onToggleActive}
>
<DynamicIcon name="check" className="h-3.5 w-3.5" />
<span>Make Active</span>
<span>{t(storageProvidersI18nKeys.card.makeActive)}</span>
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={onEdit}>
<DynamicIcon name="pencil" className="mr-1 h-3.5 w-3.5" />
<span>Edit</span>
<span>{t(storageProvidersI18nKeys.card.edit)}</span>
</Button>
</div>
</div>

View File

@@ -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<StorageProvider | null>(provider)
const [isDirty, setIsDirty] = useState(false)
@@ -106,12 +112,14 @@ export function ProviderEditModal({
</div>
<div className="flex-1 space-y-1">
<h2 className="text-text text-xl font-semibold">
{isNewProvider ? 'Add Storage Provider' : 'Edit Provider'}
{t(isNewProvider ? storageProvidersI18nKeys.modal.createTitle : storageProvidersI18nKeys.modal.editTitle)}
</h2>
<p className="text-text-tertiary text-sm">
{isNewProvider
? 'Configure a new storage provider for your photos'
: 'Update provider configuration and credentials'}
{t(
isNewProvider
? storageProvidersI18nKeys.modal.createDescription
: storageProvidersI18nKeys.modal.editDescription,
)}
</p>
</div>
</div>
@@ -129,32 +137,32 @@ export function ProviderEditModal({
>
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-text text-sm font-semibold">Basic Information</h3>
<h3 className="text-text text-sm font-semibold">{t(storageProvidersI18nKeys.modal.sections.basic)}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="provider-name">Display Name</Label>
<Label htmlFor="provider-name">{t(storageProvidersI18nKeys.modal.fields.nameLabel)}</Label>
<Input
id="provider-name"
value={formData.name}
onInput={(e) => handleNameChange(e.currentTarget.value)}
placeholder="e.g., Production S3"
placeholder={t(storageProvidersI18nKeys.modal.fields.namePlaceholder)}
className="bg-background/60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider-type">Provider Type</Label>
<Label htmlFor="provider-type">{t(storageProvidersI18nKeys.modal.fields.typeLabel)}</Label>
<Select
value={formData.type}
onValueChange={(value) => handleTypeChange(value as StorageProviderType)}
>
<SelectTrigger id="provider-type">
<SelectValue placeholder="Select provider type" />
<SelectValue placeholder={t(storageProvidersI18nKeys.modal.fields.typePlaceholder)} />
</SelectTrigger>
<SelectContent>
{STORAGE_PROVIDER_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -166,10 +174,13 @@ export function ProviderEditModal({
{/* Configuration Fields */}
{selectedFields.length > 0 && (
<div className="space-y-4">
<h3 className="text-text text-sm font-semibold">Connection Configuration</h3>
<h3 className="text-text text-sm font-semibold">
{t(storageProvidersI18nKeys.modal.sections.connection)}
</h3>
<div className="space-y-4">
{selectedFields.map((field) => {
const value = formData.config[field.key] || ''
const placeholder = field.placeholderKey ? t(field.placeholderKey) : undefined
return (
<div
key={field.key}
@@ -177,9 +188,11 @@ export function ProviderEditModal({
>
<div className="space-y-1">
<Label htmlFor={`field-${field.key}`} className="font-semibold">
{field.label}
{t(field.labelKey)}
</Label>
{field.description && <p className="text-text-tertiary text-xs">{field.description}</p>}
{field.descriptionKey ? (
<p className="text-text-tertiary text-xs">{t(field.descriptionKey)}</p>
) : null}
</div>
{field.multiline ? (
@@ -187,7 +200,7 @@ export function ProviderEditModal({
id={`field-${field.key}`}
value={value}
onInput={(e) => handleConfigChange(field.key, e.currentTarget.value)}
placeholder={field.placeholder}
placeholder={placeholder}
rows={3}
className="bg-background/60"
/>
@@ -197,13 +210,13 @@ export function ProviderEditModal({
type={field.sensitive ? 'password' : 'text'}
value={value}
onInput={(e) => handleConfigChange(field.key, e.currentTarget.value)}
placeholder={field.placeholder}
placeholder={placeholder}
className="bg-background/60"
autoComplete="off"
/>
)}
{field.helper && <FormHelperText>{field.helper}</FormHelperText>}
{field.helperKey ? <FormHelperText>{t(field.helperKey)}</FormHelperText> : null}
</div>
)
})}
@@ -227,11 +240,11 @@ export function ProviderEditModal({
size="sm"
className="text-text-secondary hover:text-text"
>
Cancel
{t(storageProvidersI18nKeys.actions.cancel)}
</Button>
<Button type="button" onClick={handleSave} variant="primary" size="sm">
<DynamicIcon name="plus" className="mr-2 h-3.5 w-3.5" />
<span>Create Provider</span>
<span>{t(storageProvidersI18nKeys.actions.create)}</span>
</Button>
</div>
) : (
@@ -239,7 +252,7 @@ export function ProviderEditModal({
<div className="flex items-center justify-end gap-3">
<Button type="button" onClick={handleSave} disabled={!isDirty} variant="primary" size="sm">
<DynamicIcon name="save" className="mr-2 h-3.5 w-3.5" />
<span>Save Changes</span>
<span>{t(storageProvidersI18nKeys.actions.save)}</span>
</Button>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Spring } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { m } from 'motion/react'
import { startTransition, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { useSetPhotoSyncAutoRun } from '~/atoms/photo-sync'
@@ -10,6 +11,7 @@ import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
import { useBlock } from '~/hooks/useBlock'
import { storageProvidersI18nKeys } from '../constants'
import { useStorageProvidersQuery, useUpdateStorageProvidersMutation } from '../hooks'
import type { StorageProvider } from '../types'
import { createEmptyProvider, reorderProvidersByActive } from '../utils'
@@ -22,6 +24,7 @@ export function StorageProvidersManager() {
const { setHeaderActionState } = useMainPageLayout()
const navigate = useNavigate()
const setPhotoSyncAutoRun = useSetPhotoSyncAutoRun()
const { t } = useTranslation()
const [providers, setProviders] = useState<StorageProvider[]>([])
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
@@ -31,10 +34,10 @@ export function StorageProvidersManager() {
useBlock({
when: isDirty,
title: '离开前请保存设置',
description: '当前存储提供商设置尚未保存,离开页面会丢失这些更改,确定要继续吗?',
confirmText: '继续离开',
cancelText: '留在此页',
title: t(storageProvidersI18nKeys.blocker.title),
description: t(storageProvidersI18nKeys.blocker.description),
confirmText: t(storageProvidersI18nKeys.blocker.confirm),
cancelText: t(storageProvidersI18nKeys.blocker.cancel),
})
useEffect(() => {
@@ -125,10 +128,10 @@ export function StorageProvidersManager() {
initialProviderStateRef.current = true
hasShownSyncPromptRef.current = true
Prompt.prompt({
title: '配置完成,立即同步照片?',
description: '存储提供商配置已经保存,是否前往「数据同步」页面立即开始扫描存储中的照片并写入数据库?',
onConfirmText: '开始同步',
onCancelText: '稍后再说',
title: t(storageProvidersI18nKeys.prompt.title),
description: t(storageProvidersI18nKeys.prompt.description),
onConfirmText: t(storageProvidersI18nKeys.prompt.confirm),
onCancelText: t(storageProvidersI18nKeys.prompt.cancel),
onConfirm: () => {
setPhotoSyncAutoRun('apply')
navigate('/photos/sync')
@@ -158,18 +161,18 @@ export function StorageProvidersManager() {
const headerActionPortal = (
<MainPageLayout.Actions>
<Button type="button" onClick={handleAddProvider} size="sm" variant="secondary">
{t(storageProvidersI18nKeys.actions.add)}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={disableSave}
isLoading={updateMutation.isPending}
loadingText="保存中…"
loadingText={t(storageProvidersI18nKeys.actions.saving)}
variant="primary"
size="sm"
>
{t(storageProvidersI18nKeys.actions.save)}
</Button>
</MainPageLayout.Actions>
)
@@ -198,8 +201,8 @@ export function StorageProvidersManager() {
{headerActionPortal}
<div className="bg-background-tertiary text-red flex items-center justify-center gap-3 rounded p-8 text-sm">
<span>
<span>{error instanceof Error ? error.message : '未知错误'}</span>
{t(storageProvidersI18nKeys.errors.load)}
<span>{error instanceof Error ? error.message : t('common.unknown-error')}</span>
</span>
</div>
</>
@@ -246,13 +249,11 @@ export function StorageProvidersManager() {
>
<div className="bg-background-tertiary border-fill-tertiary flex flex-col items-center justify-center gap-3 rounded-lg border p-8 text-center">
<div className="space-y-1">
<p className="text-text-secondary text-sm"></p>
<p className="text-text-tertiary text-xs">
</p>
<p className="text-text-secondary text-sm">{t(storageProvidersI18nKeys.empty.title)}</p>
<p className="text-text-tertiary text-xs">{t(storageProvidersI18nKeys.empty.description)}</p>
</div>
<Button type="button" size="sm" variant="primary" onClick={handleAddProvider}>
{t(storageProvidersI18nKeys.empty.action)}
</Button>
</div>
</m.div>
@@ -268,15 +269,7 @@ export function StorageProvidersManager() {
className="mt-4 text-center"
>
<p className="text-text-tertiary text-xs">
<span>
{updateMutation.isError && updateMutation.error
? `保存失败:${updateMutation.error instanceof Error ? updateMutation.error.message : '未知错误'}`
: updateMutation.isSuccess && !isDirty
? '✓ 配置已保存并同步'
: isDirty
? `有未保存的更改 • ${providers.length} 个提供商`
: `${providers.length} 个存储提供商 • ${orderedProviders.find((p) => p.id === activeProviderId)?.name || 'N/A'} 当前激活`}
</span>
<span>{getStatusMessage()}</span>
</p>
</m.div>
)}
@@ -297,15 +290,15 @@ export function StorageProvidersManager() {
</div>
<div className="flex-1 space-y-1.5 sm:space-y-2">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="text-text text-sm font-semibold sm:text-base"></span>
<span className="text-text text-sm font-semibold sm:text-base">
{t(storageProvidersI18nKeys.security.title)}
</span>
</div>
<p className="text-text-secondary text-xs sm:text-sm leading-relaxed">
访使{' '}
<span className="font-mono font-semibold text-accent">AES-256-GCM</span>{' '}
{t(storageProvidersI18nKeys.security.description, { algorithm: 'AES-256-GCM' })}
</p>
<p className="text-text-tertiary text-[11px] sm:text-xs">
AES-256-GCM
{t(storageProvidersI18nKeys.security.helper, { algorithm: 'AES-256-GCM' })}
</p>
</div>
</div>
@@ -313,4 +306,23 @@ export function StorageProvidersManager() {
</m.div>
</>
)
function getStatusMessage() {
if (updateMutation.isError && updateMutation.error) {
const reason = updateMutation.error instanceof Error ? updateMutation.error.message : t('common.unknown-error')
return t(storageProvidersI18nKeys.status.error, { reason })
}
if (updateMutation.isSuccess && !isDirty) {
return t(storageProvidersI18nKeys.status.saved)
}
if (isDirty) {
return t(storageProvidersI18nKeys.status.dirty, { total: providers.length })
}
const activeName =
orderedProviders.find((p) => p.id === activeProviderId)?.name || t(storageProvidersI18nKeys.card.untitled)
return t(storageProvidersI18nKeys.status.summary, {
total: providers.length,
active: activeName,
})
}
}

View File

@@ -9,12 +9,150 @@ export const STORAGE_PROVIDER_TYPES: readonly StorageProviderType[] = ['s3', 'gi
export const STORAGE_PROVIDER_TYPE_OPTIONS: ReadonlyArray<{
value: StorageProviderType
label: string
labelKey: I18nKeys
}> = [
{ value: 's3', label: 'S3 / 兼容对象存储' },
{ value: 'github', label: 'GitHub 仓库' },
{ value: 's3', labelKey: 'storage.providers.types.s3' },
{ value: 'github', labelKey: 'storage.providers.types.github' },
]
export const storageProvidersI18nKeys = {
blocker: {
title: 'storage.providers.blocker.title',
description: 'storage.providers.blocker.description',
confirm: 'storage.providers.blocker.confirm',
cancel: 'storage.providers.blocker.cancel',
},
actions: {
add: 'storage.providers.actions.add',
save: 'storage.providers.actions.save',
saving: 'storage.providers.actions.saving',
cancel: 'storage.providers.actions.cancel',
create: 'storage.providers.actions.create',
},
prompt: {
title: 'storage.providers.prompt.sync.title',
description: 'storage.providers.prompt.sync.description',
confirm: 'storage.providers.prompt.sync.confirm',
cancel: 'storage.providers.prompt.sync.cancel',
},
status: {
error: 'storage.providers.status.error',
saved: 'storage.providers.status.saved',
dirty: 'storage.providers.status.dirty',
summary: 'storage.providers.status.summary',
},
empty: {
title: 'storage.providers.empty.title',
description: 'storage.providers.empty.description',
action: 'storage.providers.empty.action',
},
errors: {
load: 'storage.providers.error.load',
},
security: {
title: 'storage.providers.security.title',
description: 'storage.providers.security.description',
helper: 'storage.providers.security.helper',
},
modal: {
createTitle: 'storage.providers.modal.create.title',
editTitle: 'storage.providers.modal.edit.title',
createDescription: 'storage.providers.modal.create.description',
editDescription: 'storage.providers.modal.edit.description',
sections: {
basic: 'storage.providers.modal.sections.basic',
connection: 'storage.providers.modal.sections.connection',
},
fields: {
nameLabel: 'storage.providers.modal.fields.name.label',
namePlaceholder: 'storage.providers.modal.fields.name.placeholder',
typeLabel: 'storage.providers.modal.fields.type.label',
typePlaceholder: 'storage.providers.modal.fields.type.placeholder',
},
},
card: {
active: 'storage.providers.card.active',
makeActive: 'storage.providers.card.make-active',
makeInactive: 'storage.providers.card.make-inactive',
edit: 'storage.providers.card.edit',
notConfigured: 'storage.providers.card.preview.not-configured',
fallback: 'storage.providers.card.preview.fallback',
untitled: 'storage.providers.card.untitled',
},
types: {
s3: 'storage.providers.types.s3',
github: 'storage.providers.types.github',
local: 'storage.providers.types.local',
minio: 'storage.providers.types.minio',
eagle: 'storage.providers.types.eagle',
},
} as const satisfies {
blocker: {
title: I18nKeys
description: I18nKeys
confirm: I18nKeys
cancel: I18nKeys
}
actions: {
add: I18nKeys
save: I18nKeys
saving: I18nKeys
cancel: I18nKeys
create: I18nKeys
}
prompt: {
title: I18nKeys
description: I18nKeys
confirm: I18nKeys
cancel: I18nKeys
}
status: {
error: I18nKeys
saved: I18nKeys
dirty: I18nKeys
summary: I18nKeys
}
empty: {
title: I18nKeys
description: I18nKeys
action: I18nKeys
}
errors: {
load: I18nKeys
}
security: {
title: I18nKeys
description: I18nKeys
helper: I18nKeys
}
modal: {
createTitle: I18nKeys
editTitle: I18nKeys
createDescription: I18nKeys
editDescription: I18nKeys
sections: {
basic: I18nKeys
connection: I18nKeys
}
fields: {
nameLabel: I18nKeys
namePlaceholder: I18nKeys
typeLabel: I18nKeys
typePlaceholder: I18nKeys
}
}
card: {
active: I18nKeys
makeActive: I18nKeys
makeInactive: I18nKeys
edit: I18nKeys
notConfigured: I18nKeys
fallback: I18nKeys
untitled: I18nKeys
}
types: Record<'s3' | 'github' | 'local' | 'minio' | 'eagle', I18nKeys>
}
export const STORAGE_PROVIDER_FIELD_DEFINITIONS: Record<
StorageProviderType,
readonly StorageProviderFieldDefinition[]
@@ -22,100 +160,100 @@ export const STORAGE_PROVIDER_FIELD_DEFINITIONS: Record<
s3: [
{
key: 'bucket',
label: 'Bucket 名称',
placeholder: 'afilmory-photos',
description: 'S3 存储桶名称,用于读取图片文件。',
labelKey: 'storage.providers.fields.s3.bucket.label',
placeholderKey: 'storage.providers.fields.s3.bucket.placeholder',
descriptionKey: 'storage.providers.fields.s3.bucket.description',
},
{
key: 'region',
label: '区域 (Region)',
placeholder: 'ap-southeast-1',
description: 'S3 区域代码,例如 ap-southeast-1。',
labelKey: 'storage.providers.fields.s3.region.label',
placeholderKey: 'storage.providers.fields.s3.region.placeholder',
descriptionKey: 'storage.providers.fields.s3.region.description',
},
{
key: 'endpoint',
label: '自定义 Endpoint',
placeholder: 'https://s3.example.com',
description: '可选S3 兼容服务的自定义 Endpoint 地址。',
helper: '对于 AWS 官方 S3 可留空MinIO 等第三方服务需要填写。',
labelKey: 'storage.providers.fields.s3.endpoint.label',
placeholderKey: 'storage.providers.fields.s3.endpoint.placeholder',
descriptionKey: 'storage.providers.fields.s3.endpoint.description',
helperKey: 'storage.providers.fields.s3.endpoint.helper',
},
{
key: 'accessKeyId',
label: 'Access Key ID',
placeholder: 'AKIAxxxxxxxxxxxx',
labelKey: 'storage.providers.fields.s3.access-key.label',
placeholderKey: 'storage.providers.fields.s3.access-key.placeholder',
},
{
key: 'secretAccessKey',
label: 'Secret Access Key',
placeholder: '************',
labelKey: 'storage.providers.fields.s3.secret-key.label',
placeholderKey: 'storage.providers.fields.s3.secret-key.placeholder',
sensitive: true,
},
{
key: 'prefix',
label: '文件前缀',
placeholder: 'photos/',
description: '可选,仅访问指定前缀下的文件。',
labelKey: 'storage.providers.fields.s3.prefix.label',
placeholderKey: 'storage.providers.fields.s3.prefix.placeholder',
descriptionKey: 'storage.providers.fields.s3.prefix.description',
},
{
key: 'customDomain',
label: '自定义访问域名',
placeholder: 'https://cdn.example.com',
description: '设置公开访问照片时使用的自定义域名。',
labelKey: 'storage.providers.fields.s3.custom-domain.label',
placeholderKey: 'storage.providers.fields.s3.custom-domain.placeholder',
descriptionKey: 'storage.providers.fields.s3.custom-domain.description',
},
{
key: 'excludeRegex',
label: '排除规则 (正则)',
placeholder: '\\.(tmp|bak)$',
description: '可选,排除不需要的文件。',
labelKey: 'storage.providers.fields.s3.exclude-regex.label',
placeholderKey: 'storage.providers.fields.s3.exclude-regex.placeholder',
descriptionKey: 'storage.providers.fields.s3.exclude-regex.description',
multiline: true,
helper: '正则表达式需符合 JavaScript 语法。',
helperKey: 'storage.providers.fields.s3.exclude-regex.helper',
},
{
key: 'maxFileLimit',
label: '最大文件数量',
placeholder: '1000',
description: '可选,为扫描过程设置最大文件数量限制。',
labelKey: 'storage.providers.fields.s3.max-files.label',
placeholderKey: 'storage.providers.fields.s3.max-files.placeholder',
descriptionKey: 'storage.providers.fields.s3.max-files.description',
},
],
github: [
{
key: 'owner',
label: '仓库 Owner',
placeholder: 'afilmory',
description: 'GitHub 仓库的拥有者(用户或组织名称)。',
labelKey: 'storage.providers.fields.github.owner.label',
placeholderKey: 'storage.providers.fields.github.owner.placeholder',
descriptionKey: 'storage.providers.fields.github.owner.description',
},
{
key: 'repo',
label: '仓库名称',
placeholder: 'photo-assets',
description: '存储照片的仓库名称。',
labelKey: 'storage.providers.fields.github.repo.label',
placeholderKey: 'storage.providers.fields.github.repo.placeholder',
descriptionKey: 'storage.providers.fields.github.repo.description',
},
{
key: 'branch',
label: '分支',
placeholder: 'main',
description: '可选,指定需要同步的分支。',
helper: '默认 master/main如需其它分支请填写完整名称。',
labelKey: 'storage.providers.fields.github.branch.label',
placeholderKey: 'storage.providers.fields.github.branch.placeholder',
descriptionKey: 'storage.providers.fields.github.branch.description',
helperKey: 'storage.providers.fields.github.branch.helper',
},
{
key: 'token',
label: '访问令牌',
placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx',
description: '用于访问私有仓库的 GitHub Personal Access Token。',
labelKey: 'storage.providers.fields.github.token.label',
placeholderKey: 'storage.providers.fields.github.token.placeholder',
descriptionKey: 'storage.providers.fields.github.token.description',
sensitive: true,
},
{
key: 'path',
label: '仓库路径',
placeholder: 'public/photos',
description: '可选,仅同步仓库中的特定路径。',
labelKey: 'storage.providers.fields.github.path.label',
placeholderKey: 'storage.providers.fields.github.path.placeholder',
descriptionKey: 'storage.providers.fields.github.path.description',
},
{
key: 'useRawUrl',
label: '使用 Raw URL',
placeholder: 'true / false',
description: '是否使用 raw.githubusercontent.com 生成公开访问链接。',
helper: '使用自定义域名则可填写 false。',
labelKey: 'storage.providers.fields.github.use-raw.label',
placeholderKey: 'storage.providers.fields.github.use-raw.placeholder',
descriptionKey: 'storage.providers.fields.github.use-raw.description',
helperKey: 'storage.providers.fields.github.use-raw.helper',
},
],
}

View File

@@ -21,10 +21,10 @@ export interface StorageSettingEntry {
export interface StorageProviderFieldDefinition {
key: string
label: string
placeholder?: string
description?: string
helper?: string
labelKey: I18nKeys
placeholderKey?: I18nKeys
descriptionKey?: I18nKeys
helperKey?: I18nKeys
multiline?: boolean
sensitive?: boolean
}

View File

@@ -1,4 +1,6 @@
import { STORAGE_PROVIDER_FIELD_DEFINITIONS, STORAGE_PROVIDER_TYPES } from './constants'
import { getI18n } from '~/i18n'
import { STORAGE_PROVIDER_FIELD_DEFINITIONS, STORAGE_PROVIDER_TYPES, storageProvidersI18nKeys } from './constants'
import type { StorageProvider, StorageProviderType } from './types'
function generateId() {
@@ -25,6 +27,8 @@ function coerceProvider(input: unknown): StorageProvider | null {
return null
}
const i18n = getI18n()
const record = input as Record<string, unknown>
const type = isStorageProviderType(record.type) ? record.type : 's3'
const configInput =
@@ -34,7 +38,10 @@ function coerceProvider(input: unknown): StorageProvider | null {
const provider: StorageProvider = {
id: typeof record.id === 'string' && record.id.trim().length > 0 ? record.id.trim() : generateId(),
name: typeof record.name === 'string' && record.name.trim().length > 0 ? record.name.trim() : '未命名存储',
name:
typeof record.name === 'string' && record.name.trim().length > 0
? record.name.trim()
: i18n.t(storageProvidersI18nKeys.card.untitled),
type,
config: normaliseConfigForType(type, configInput),
}
@@ -92,9 +99,10 @@ export function getDefaultConfigForType(type: StorageProviderType): Record<strin
export function createEmptyProvider(type: StorageProviderType): StorageProvider {
const timestamp = new Date().toISOString()
const i18n = getI18n()
return {
id: '',
name: '未命名存储',
name: i18n.t(storageProvidersI18nKeys.card.untitled),
type,
config: getDefaultConfigForType(type),
createdAt: timestamp,
@@ -118,9 +126,15 @@ export function reorderProvidersByActive(
return [...providers]
}
const i18n = getI18n()
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
const collator = new Intl.Collator(locale)
return [...providers].sort((a, b) => {
if (a.id === activeId) return -1
if (b.id === activeId) return 1
return a.name.localeCompare(b.name, 'zh-cn')
const nameA = a.name || i18n.t(storageProvidersI18nKeys.card.untitled)
const nameB = b.name || i18n.t(storageProvidersI18nKeys.card.untitled)
return collator.compare(nameA, nameB)
})
}

View File

@@ -198,7 +198,7 @@ export function SuperAdminSettingsForm({ visibleSectionIds }: SuperAdminSettings
const { registrationsRemaining, totalUsers } = stats
const remainingLabel = (() => {
if (registrationsRemaining === null || registrationsRemaining === undefined) {
return '无限制'
return t('superadmin.settings.stats.unlimited')
}
if (typeof registrationsRemaining === 'number' && Number.isFinite(registrationsRemaining)) {

View File

@@ -1,9 +1,11 @@
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { buildHomeUrl, buildRegistrationUrl, getCurrentHostname } from './tenant-utils'
export const TenantMissingStandalone = () => {
const { t } = useTranslation()
const hostname = useMemo(() => getCurrentHostname(), [])
const registrationUrl = useMemo(() => buildRegistrationUrl(), [])
const homeUrl = useMemo(() => buildHomeUrl(), [])
@@ -20,17 +22,21 @@ export const TenantMissingStandalone = () => {
<div className="relative p-10 sm:p-12">
<div>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">404</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl"></h1>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">
{t('welcome.tenant-missing.code')}
</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">
{t('welcome.tenant-missing.title')}
</h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">
访访
使 Afilmory
{t('welcome.tenant-missing.description')}
</p>
{hostname && (
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
<p className="text-text-secondary">
<span className="text-text font-medium">{hostname}</span>
{t('welcome.tenant-missing.request')}
<span className="text-text font-medium">{hostname}</span>
</p>
</div>
)}
@@ -41,10 +47,10 @@ export const TenantMissingStandalone = () => {
className="glassmorphic-btn flex-1"
onClick={() => (window.location.href = registrationUrl)}
>
{t('welcome.tenant-missing.register')}
</Button>
<Button variant="ghost" className="flex-1" onClick={() => (window.location.href = homeUrl)}>
{t('welcome.tenant-missing.home')}
</Button>
</div>
</div>

View File

@@ -1,9 +1,11 @@
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { buildHomeUrl, buildRegistrationUrl, getCurrentHostname } from './tenant-utils'
export const TenantRestrictedStandalone = () => {
const { t } = useTranslation()
const hostname = useMemo(() => getCurrentHostname(), [])
const registrationUrl = useMemo(() => buildRegistrationUrl(), [])
const homeUrl = useMemo(() => buildHomeUrl(), [])
@@ -20,17 +22,21 @@ export const TenantRestrictedStandalone = () => {
<div className="relative p-10 sm:p-12">
<div>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">403</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl"></h1>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">
{t('welcome.tenant-restricted.code')}
</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">
{t('welcome.tenant-restricted.title')}
</h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">
访访
Afilmory使
{t('welcome.tenant-restricted.description')}
</p>
{hostname && (
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
<p className="text-text-secondary">
<span className="text-text font-medium">{hostname}</span>
{t('welcome.tenant-restricted.request')}
<span className="text-text font-medium">{hostname}</span>
</p>
</div>
)}
@@ -41,10 +47,10 @@ export const TenantRestrictedStandalone = () => {
className="glassmorphic-btn flex-1"
onClick={() => (window.location.href = registrationUrl)}
>
{t('welcome.tenant-restricted.register')}
</Button>
<Button variant="ghost" className="flex-1" onClick={() => (window.location.href = homeUrl)}>
{t('welcome.tenant-restricted.home')}
</Button>
</div>
</div>

View File

@@ -7,7 +7,6 @@ import { toast } from 'sonner'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { getI18n } from '~/i18n'
import type { SessionResponse } from '~/modules/auth/api/session'
import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session'
import { authClient } from '~/modules/auth/auth-client'
@@ -168,14 +167,23 @@ function PlanCard({
tenantSlug: string | null
creemCustomerId: string | null
}) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const [checkoutLoading, setCheckoutLoading] = useState(false)
const [portalLoading, setPortalLoading] = useState(false)
const productId = plan.payment?.creemProductId ?? null
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
const quotaNumberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale])
const canCheckout = Boolean(!isCurrent && tenantId && productId)
const showPortalButton = isCurrent && plan.planId !== 'free' && Boolean(productId && creemCustomerId)
const formatQuotaValue = (value: number | null, unitKey: I18nKeys | null) => {
if (value === null || value === undefined) {
return t(planI18nKeys.quotaUnlimited)
}
const numeral = quotaNumberFormatter.format(value)
return unitKey ? t(unitKey, { value: numeral }) : numeral
}
const handleCheckout = async () => {
if (!canCheckout || !tenantId || !productId) {
@@ -249,7 +257,7 @@ function PlanCard({
isCurrent && 'translate-y-6',
)}
>
{formatPrice(plan.pricing.monthlyPrice, plan.pricing.currency)}
{formatPrice(plan.pricing.monthlyPrice, plan.pricing.currency, locale)}
</p>
)}
</div>
@@ -260,7 +268,7 @@ function PlanCard({
{Object.entries(plan.quotas).map(([key, value]) => (
<li key={key} className="flex items-center justify-between text-sm">
<span className="text-text-tertiary">{t(QUOTA_LABEL_KEYS[key] ?? key)}</span>
<span className="text-text font-medium">{renderQuotaValue(value, QUOTA_UNIT_KEYS[key] ?? null)}</span>
<span className="text-text font-medium">{formatQuotaValue(value, QUOTA_UNIT_KEYS[key] ?? null)}</span>
</li>
))}
</ul>
@@ -321,22 +329,9 @@ function CurrentBadge({ planId }: { planId: string }) {
return <span className="bg-accent/10 text-accent rounded-full px-2 py-0.5 text-xs font-semibold">{t(labelKey)}</span>
}
function renderQuotaValue(value: number | null, unitKey: I18nKeys | null): string {
const i18n = getI18n()
if (value === null || value === undefined) {
return i18n.t(planI18nKeys.quotaUnlimited)
}
const locale = i18n.language ?? 'en'
const numeral = value.toLocaleString(locale)
if (!unitKey) {
return numeral
}
return i18n.t(unitKey, { value: numeral })
}
function formatPrice(value: number, currency: string | null | undefined): string {
function formatPrice(value: number, currency: string | null | undefined, locale: string): string {
const normalizedCurrency = currency?.toUpperCase() ?? ''
const formatted = value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
const formatted = value.toLocaleString(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
return normalizedCurrency ? `${normalizedCurrency} ${formatted}` : formatted
}

View File

@@ -182,16 +182,266 @@
"photos.conflict.metadata.label": "Metadata mismatch",
"photos.conflict.missing.description": "Record exists in the database but the storage object is no longer accessible.",
"photos.conflict.missing.label": "Missing in storage",
"photos.library.actions.all-selected": "All selected",
"photos.library.actions.clear-selection": "Clear selection",
"photos.library.actions.delete": "Delete",
"photos.library.actions.edit-tags": "Edit tags",
"photos.library.actions.select-all": "Select all",
"photos.library.actions.selected-count": "{{count}} selected",
"photos.library.actions.upload": "Upload files",
"photos.library.actions.upload-short": "Upload",
"photos.library.card.delete": "Delete asset",
"photos.library.card.device-unknown": "Unknown device",
"photos.library.card.edit-tags": "Edit tags",
"photos.library.card.no-preview": "Preview unavailable",
"photos.library.card.select": "Select",
"photos.library.card.selected": "Selected",
"photos.library.card.size-unknown": "Size unknown",
"photos.library.card.view-exif": "View EXIF",
"photos.library.delete.cancel": "Cancel",
"photos.library.delete.confirm": "Delete",
"photos.library.delete.description": "This action cannot be undone. Continue deleting “{{name}}”? Enable the option below to also remove the remote storage file.",
"photos.library.delete.option.description": "When checked, the remote originals and thumbnails will be removed as well.",
"photos.library.delete.option.title": "Also delete storage files",
"photos.library.delete.title": "Delete this asset?",
"photos.library.empty.description": "Use the upload button to add new photos to your library.",
"photos.library.empty.title": "No photos yet",
"photos.library.exif.empty": "No EXIF data is available for this asset.",
"photos.library.exif.file": "File: {{value}}",
"photos.library.exif.rows.altitude": "Altitude",
"photos.library.exif.rows.altitude-below": "{{value}} m (below sea level)",
"photos.library.exif.rows.altitude-value": "{{value}} m",
"photos.library.exif.rows.aperture": "Aperture",
"photos.library.exif.rows.aspect-ratio": "Aspect ratio",
"photos.library.exif.rows.author": "Author",
"photos.library.exif.rows.brightness": "Brightness value",
"photos.library.exif.rows.captured-at": "Captured at",
"photos.library.exif.rows.color-space": "Color space",
"photos.library.exif.rows.copyright": "Copyright",
"photos.library.exif.rows.device": "Camera",
"photos.library.exif.rows.eq-focal-length": "35mm equivalent focal length",
"photos.library.exif.rows.exposure-compensation": "Exposure compensation",
"photos.library.exif.rows.exposure-mode": "Exposure mode",
"photos.library.exif.rows.exposure-program": "Exposure program",
"photos.library.exif.rows.file-format": "Format",
"photos.library.exif.rows.file-size": "File size",
"photos.library.exif.rows.flash": "Flash",
"photos.library.exif.rows.focal-length": "Focal length",
"photos.library.exif.rows.iso": "ISO",
"photos.library.exif.rows.latitude": "Latitude",
"photos.library.exif.rows.lens": "Lens",
"photos.library.exif.rows.light-source": "Light source",
"photos.library.exif.rows.longitude": "Longitude",
"photos.library.exif.rows.megapixels": "Megapixels",
"photos.library.exif.rows.metering-mode": "Metering mode",
"photos.library.exif.rows.photo-id": "Photo ID",
"photos.library.exif.rows.rating": "Rating",
"photos.library.exif.rows.resolution": "Resolution",
"photos.library.exif.rows.scale-factor": "35mm scale factor",
"photos.library.exif.rows.scene-type": "Scene type",
"photos.library.exif.rows.sensor": "Sensor",
"photos.library.exif.rows.shutter": "Shutter speed",
"photos.library.exif.rows.software": "Software",
"photos.library.exif.rows.time-offset": "Time offset",
"photos.library.exif.rows.timezone": "Time zone",
"photos.library.exif.rows.timezone-source": "Time zone source",
"photos.library.exif.rows.title": "Title",
"photos.library.exif.rows.white-balance": "White balance",
"photos.library.exif.sections.basic": "Basic Info",
"photos.library.exif.sections.capture": "Capture Settings",
"photos.library.exif.sections.fuji": "Fujifilm Recipe",
"photos.library.exif.sections.location": "Location",
"photos.library.exif.sections.metadata": "Metadata",
"photos.library.sort.by-captured": "Sort by capture time",
"photos.library.sort.by-uploaded": "Sort by uploaded time",
"photos.library.sort.order-asc": "Oldest first",
"photos.library.sort.order-desc": "Newest first",
"photos.library.tags.modal.asset-count": "{{count}} assets",
"photos.library.tags.modal.cancel": "Cancel",
"photos.library.tags.modal.description.multiple": "All selected assets will receive the same tags. Tags also determine the remote storage path.",
"photos.library.tags.modal.description.single": "Tags determine the remote storage path. Moving tags will relocate original files and Live Photo videos accordingly.",
"photos.library.tags.modal.input": "Type a tag and press Enter, or select from suggested tags",
"photos.library.tags.modal.no-selection": "No asset selected",
"photos.library.tags.modal.path.hint": "Based on tag order",
"photos.library.tags.modal.path.preview": "New storage path preview",
"photos.library.tags.modal.path.sample": "Sample path (first item)",
"photos.library.tags.modal.save": "Save",
"photos.library.tags.modal.saving": "Saving…",
"photos.library.tags.modal.title": "Edit tags for “{{name}}”",
"photos.library.tags.toast.error": "Failed to update tags",
"photos.library.tags.toast.error-description": "Please try again later.",
"photos.library.tags.toast.multi-success": "Updated tags for {{count}} assets",
"photos.library.tags.toast.single-success": "Tags updated",
"photos.library.tags.toast.success-description": "Storage paths have been updated for the new tag structure.",
"photos.page.description": "Sync and manage photo assets on the server.",
"photos.page.title": "Photo Library",
"photos.sync.actions.button.apply": "Sync photos",
"photos.sync.actions.button.preview": "Preview sync",
"photos.sync.actions.toast.apply-success": "Photo sync completed",
"photos.sync.actions.toast.error-description": "Photo sync failed. Please try again later.",
"photos.sync.actions.toast.error-title": "Sync failed",
"photos.sync.actions.toast.preview-success": "Preview sync finished",
"photos.sync.actions.toast.success-description": "Inserted {{inserted}} · Updated {{updated}} · Conflicts {{conflicts}} · Errors {{errors}}",
"photos.sync.conflicts.actions.all-database": "Set all to database",
"photos.sync.conflicts.actions.all-storage": "Set all to storage",
"photos.sync.conflicts.actions.clear-selection": "Clear selection",
"photos.sync.conflicts.actions.hide-details": "Hide details",
"photos.sync.conflicts.actions.open-storage": "Open",
"photos.sync.conflicts.actions.prefer-database": "Keep database",
"photos.sync.conflicts.actions.prefer-storage": "Keep storage",
"photos.sync.conflicts.actions.selected-database": "Apply database to selected",
"photos.sync.conflicts.actions.selected-storage": "Apply storage to selected",
"photos.sync.conflicts.actions.view-details": "View details",
"photos.sync.conflicts.actions.view-original": "View original",
"photos.sync.conflicts.description": "These conflicts need manual confirmation. Select multiple entries to resolve faster.",
"photos.sync.conflicts.info.conflict-key": "Conflict key:",
"photos.sync.conflicts.info.first-detected": "Detected: {{time}}",
"photos.sync.conflicts.info.last-updated": "Last updated: {{time}}",
"photos.sync.conflicts.info.photo-id-fallback": "Photo ID missing",
"photos.sync.conflicts.info.storage-key": "Storage key:",
"photos.sync.conflicts.preview.common.dimensions": "Dimensions:",
"photos.sync.conflicts.preview.common.id": "ID:",
"photos.sync.conflicts.preview.common.size": "Size:",
"photos.sync.conflicts.preview.common.updated-at": "Updated:",
"photos.sync.conflicts.preview.database.empty": "No database record available",
"photos.sync.conflicts.preview.database.title": "Database record",
"photos.sync.conflicts.preview.storage.key": "Key:",
"photos.sync.conflicts.preview.storage.title": "Storage object",
"photos.sync.conflicts.prompts.bulk": "Resolve {{scope}} and {{strategy}}?",
"photos.sync.conflicts.prompts.cancel": "Cancel",
"photos.sync.conflicts.prompts.confirm": "Confirm",
"photos.sync.conflicts.prompts.scope-all": "all pending conflicts",
"photos.sync.conflicts.prompts.scope-selected": "the {{count}} selected conflicts",
"photos.sync.conflicts.prompts.single": "Resolve conflict {{identifier}} and {{strategy}}?",
"photos.sync.conflicts.prompts.title": "Confirm action",
"photos.sync.conflicts.selection.clear": "Clear selection",
"photos.sync.conflicts.selection.none": "No items selected",
"photos.sync.conflicts.selection.selected": "{{count}} selected",
"photos.sync.conflicts.strategy.database": "keep database version",
"photos.sync.conflicts.strategy.storage": "keep storage version",
"photos.sync.conflicts.title": "Pending conflicts",
"photos.sync.conflicts.toast.no-original": "No original asset preview is available for this record.",
"photos.sync.conflicts.toast.none": "No conflicts to resolve right now.",
"photos.sync.conflicts.toast.open-storage-failed": "Unable to open storage object",
"photos.sync.conflicts.toast.select-required": "Select conflicts first.",
"photos.sync.conflicts.total": "Total: {{count}}",
"photos.sync.metadata.database": "Metadata · database",
"photos.sync.metadata.etag": "ETag",
"photos.sync.metadata.hash": "Metadata hash",
"photos.sync.metadata.none": "None",
"photos.sync.metadata.size": "Size",
"photos.sync.metadata.storage": "Metadata · storage",
"photos.sync.metadata.unknown": "Unknown",
"photos.sync.metadata.updated-at": "Updated",
"photos.sync.progress.heading.error": "Sync failed",
"photos.sync.progress.heading.preview": "Running preview sync",
"photos.sync.progress.heading.running": "Running sync",
"photos.sync.progress.logs.detail.error": "Error {{value}}",
"photos.sync.progress.logs.detail.live-photo": "Includes Live Photo",
"photos.sync.progress.logs.detail.live-photo-absent": "No Live Photo",
"photos.sync.progress.logs.detail.manifest": "Includes previous manifest",
"photos.sync.progress.logs.detail.manifest-absent": "No previous manifest",
"photos.sync.progress.logs.detail.result": "Result {{value}}",
"photos.sync.progress.logs.level.error": "Error",
"photos.sync.progress.logs.level.info": "Info",
"photos.sync.progress.logs.level.success": "Success",
"photos.sync.progress.logs.level.warn": "Warning",
"photos.sync.progress.logs.recent": "Latest {{count}} entries",
"photos.sync.progress.logs.title": "Sync logs",
"photos.sync.progress.recent.no-further": "No further actions required",
"photos.sync.progress.recent.progress": "Progress: {{processed}} / {{total}}",
"photos.sync.progress.recent.title": "Recent action",
"photos.sync.progress.stage-status.completed": "Completed",
"photos.sync.progress.stage-status.pending": "Pending",
"photos.sync.progress.stage-status.running": "Running",
"photos.sync.progress.stages.conflicts.description": "Detect differences between storage metadata and database records.",
"photos.sync.progress.stages.conflicts.label": "Verify metadata",
"photos.sync.progress.stages.missing.description": "Sync new objects from storage into the database.",
"photos.sync.progress.stages.missing.label": "Import new photos",
"photos.sync.progress.stages.orphan.description": "Mark database entries whose storage objects are missing.",
"photos.sync.progress.stages.orphan.label": "Identify orphan records",
"photos.sync.progress.stages.progress": "{{processed}} / {{total}}",
"photos.sync.progress.stages.reconciliation.description": "Update record statuses to match the latest metadata.",
"photos.sync.progress.stages.reconciliation.label": "Reconcile statuses",
"photos.sync.progress.status.error": "Stopped",
"photos.sync.progress.status.running": "In progress",
"photos.sync.progress.subtitle.error": "An error occurred during sync. Review the log for details and retry.",
"photos.sync.progress.subtitle.preview": "Simulating sync operations. Preview mode does not write to the database.",
"photos.sync.progress.subtitle.running": "Reconciling storage with the database. Keep this page open to view live progress.",
"photos.sync.result.actions.applied": "Applied",
"photos.sync.result.actions.collapse": "Hide details",
"photos.sync.result.actions.expand": "View details",
"photos.sync.result.actions.pending": "Not applied",
"photos.sync.result.alerts.open-original-failed": "Unable to open original photo",
"photos.sync.result.duration.less-than-second": "Less than 1 second",
"photos.sync.result.duration.minutes": "{{count}} min",
"photos.sync.result.duration.seconds": "{{count}} sec",
"photos.sync.result.filters.all": "All",
"photos.sync.result.history.completed-at": "Completed {{time}}",
"photos.sync.result.history.duration": "Duration {{duration}}",
"photos.sync.result.history.heading": "Last sync completed",
"photos.sync.result.history.mode.live": "Live mode · Changes applied",
"photos.sync.result.history.mode.preview": "Preview mode · No database writes",
"photos.sync.result.history.operations": "Actions: {{count}}",
"photos.sync.result.info.conflict-type": "Conflict type:",
"photos.sync.result.info.photo-id": "Photo ID:",
"photos.sync.result.info.storage-key": "Storage key:",
"photos.sync.result.manifest.empty": "No manifest data",
"photos.sync.result.operations.count": "Operations: {{count}}",
"photos.sync.result.operations.filter-label": "· Filter:",
"photos.sync.result.status.empty.description": "Configure and activate a storage provider in Settings, then use the sync controls to start processing. Preview runs are safe and do not mutate data.",
"photos.sync.result.status.empty.title": "No sync runs yet",
"photos.sync.result.status.loading.description": "Fetching the latest sync run. Please wait…",
"photos.sync.result.status.loading.title": "Loading sync status",
"photos.sync.result.summary.description.latest": "Latest sync results are shown below.",
"photos.sync.result.summary.description.live": "The last sync ran in live mode and wrote changes to the database.",
"photos.sync.result.summary.description.preview": "Preview mode ran most recently; no database changes were applied.",
"photos.sync.result.summary.heading": "Sync summary",
"photos.sync.result.summary.labels.completed": "Synced",
"photos.sync.result.summary.labels.conflicts": "Conflicts",
"photos.sync.result.summary.labels.database-records": "Database records",
"photos.sync.result.summary.labels.deleted": "Records deleted",
"photos.sync.result.summary.labels.errors": "Errors",
"photos.sync.result.summary.labels.inserted": "Photos inserted",
"photos.sync.result.summary.labels.pending": "Pending",
"photos.sync.result.summary.labels.skipped": "Skipped",
"photos.sync.result.summary.labels.storage-objects": "Storage objects",
"photos.sync.result.summary.labels.updated": "Records updated",
"photos.sync.result.table.empty.filtered": "No actions match the current filter.",
"photos.sync.result.table.empty.none": "Sync finished with no actions to review.",
"photos.sync.result.table.mode.live": "Live mode · Results saved",
"photos.sync.result.table.mode.preview": "Preview mode · No changes applied",
"photos.sync.result.table.title": "Sync action details",
"photos.sync.toasts.conflict-batch-error": "Some conflicts failed to resolve",
"photos.sync.toasts.conflict-batch-success": "{{strategy}} resolved {{count}} conflict(s).",
"photos.sync.toasts.conflict-database": "Database record retained; storage difference ignored.",
"photos.sync.toasts.conflict-error": "Failed to resolve conflict",
"photos.sync.toasts.conflict-error-desc": "Unable to resolve conflict right now. Please try again later.",
"photos.sync.toasts.conflict-resolved": "Conflict resolved",
"photos.sync.toasts.conflict-select": "Select at least one conflict first.",
"photos.sync.toasts.conflict-storage": "Storage version applied to the database.",
"photos.tabs.library": "Library",
"photos.tabs.storage": "Storage",
"photos.tabs.sync": "Sync",
"photos.tabs.usage": "Usage",
"photos.usage.events.description": "Latest billing-relevant actions such as uploads, deletions, and sync jobs.",
"photos.usage.events.empty.description": "Upload photos or run sync to see billing events here.",
"photos.usage.events.empty.title": "No usage yet",
"photos.usage.events.metadata.empty": "—",
"photos.usage.events.metadata.more": "+{{count}} more",
"photos.usage.events.metadata.value-unknown": "N/A",
"photos.usage.events.title": "Recent usage events",
"photos.usage.events.total": "{{count}} event(s)",
"photos.usage.events.unit.byte": "Bytes",
"photos.usage.events.unit.count": "Actions",
"photos.usage.events.unit.label": "Unit: {{unit}}",
"photos.usage.photo-created.description": "Photos added through upload or sync.",
"photos.usage.photo-created.label": "Photo created",
"photos.usage.photo-deleted.description": "Photos removed from the library or storage.",
"photos.usage.photo-deleted.label": "Photo deleted",
"photos.usage.summary.description": "Cumulative totals grouped by event type.",
"photos.usage.summary.refresh": "Refresh",
"photos.usage.summary.title": "Usage overview",
"photos.usage.sync-completed.description": "Summary event when a data sync run finishes.",
"photos.usage.sync-completed.label": "Sync completed",
"plan.badge.current": "Current Plan",
@@ -234,6 +484,136 @@
"settings.site.title": "Site Settings",
"settings.user.description": "Maintain the public author profile, avatar, and aliases.",
"settings.user.title": "User Profile",
"site.settings.banner.dirty": "{{count}} setting(s) pending save",
"site.settings.banner.fail": "Save failed:",
"site.settings.banner.success": "Settings synced successfully",
"site.settings.banner.synced": "All settings are up to date",
"site.settings.button.save": "Save changes",
"site.settings.button.saving": "Saving…",
"site.settings.error.load-prefix": "Unable to load site settings:",
"site.settings.error.unknown": "Unknown error",
"site.user.blocker.cancel": "Stay on this page",
"site.user.blocker.confirm": "Leave anyway",
"site.user.blocker.description": "You have unsaved edits. Leaving now will discard them. Continue?",
"site.user.blocker.title": "Save changes before leaving",
"site.user.button.save": "Save changes",
"site.user.button.saving": "Saving…",
"site.user.error.loading": "Unable to load user profile",
"site.user.form.avatar.helper": "Supports http(s) URLs or links starting with //. Leave blank to fallback to initials.",
"site.user.form.avatar.label": "Avatar URL",
"site.user.form.avatar.placeholder": "https://cdn.example.com/avatar.png",
"site.user.form.display.helper": "Shown next to the avatar. Leave blank to reuse the author name.",
"site.user.form.display.label": "Display name",
"site.user.form.display.placeholder": "Optional, e.g., innei.photo",
"site.user.form.name.helper": "Displayed publicly and in RSS author/editor fields.",
"site.user.form.name.label": "Author name",
"site.user.form.name.placeholder": "e.g., Innei",
"site.user.form.username.helper": "Used internally to identify the author. Not shown on the site.",
"site.user.form.username.label": "Username (optional)",
"site.user.form.username.placeholder": "e.g., innei",
"site.user.header.badge": "Author profile",
"site.user.header.description": "Used on the site header, RSS feed, and social cards. Keep it aligned with your personal brand.",
"site.user.header.title": "Public author identity",
"site.user.preview.avatar-alt": "Author avatar preview",
"site.user.preview.fallback": "Author",
"site.user.preview.last-updated": "Last updated: {{time}}",
"site.user.preview.never-updated": "Not updated yet",
"site.user.toast.error": "Failed to save profile",
"site.user.toast.error-description": "Please review your input and try again.",
"site.user.toast.success": "Profile updated",
"storage.providers.actions.add": "Add provider",
"storage.providers.actions.cancel": "Cancel",
"storage.providers.actions.create": "Create provider",
"storage.providers.actions.save": "Save changes",
"storage.providers.actions.saving": "Saving…",
"storage.providers.blocker.cancel": "Stay on this page",
"storage.providers.blocker.confirm": "Leave anyway",
"storage.providers.blocker.description": "You have unsaved storage provider changes. Leaving this page will discard them. Continue?",
"storage.providers.blocker.title": "Save storage changes first",
"storage.providers.card.active": "Active",
"storage.providers.card.edit": "Edit",
"storage.providers.card.make-active": "Make active",
"storage.providers.card.make-inactive": "Make inactive",
"storage.providers.card.preview.fallback": "Storage provider",
"storage.providers.card.preview.not-configured": "Not configured",
"storage.providers.card.untitled": "Untitled provider",
"storage.providers.empty.action": "Add provider",
"storage.providers.empty.description": "Add a storage provider so the system can sync and manage remote photos.",
"storage.providers.empty.title": "No storage provider configured",
"storage.providers.error.load": "Unable to load storage settings",
"storage.providers.fields.github.branch.description": "Optional branch to sync.",
"storage.providers.fields.github.branch.helper": "Defaults to master/main. Provide the full branch name if it differs.",
"storage.providers.fields.github.branch.label": "Branch",
"storage.providers.fields.github.branch.placeholder": "main",
"storage.providers.fields.github.owner.description": "GitHub user or organization name.",
"storage.providers.fields.github.owner.label": "Repository owner",
"storage.providers.fields.github.owner.placeholder": "afilmory",
"storage.providers.fields.github.path.description": "Optional path within the repository to limit syncing.",
"storage.providers.fields.github.path.label": "Repository path",
"storage.providers.fields.github.path.placeholder": "public/photos",
"storage.providers.fields.github.repo.description": "Repository that stores your photos.",
"storage.providers.fields.github.repo.label": "Repository name",
"storage.providers.fields.github.repo.placeholder": "photo-assets",
"storage.providers.fields.github.token.description": "Personal Access Token for private repositories.",
"storage.providers.fields.github.token.label": "Access token",
"storage.providers.fields.github.token.placeholder": "ghp_xxxxxxxxxxxxxxxxxxxx",
"storage.providers.fields.github.use-raw.description": "Use raw.githubusercontent.com when generating public URLs.",
"storage.providers.fields.github.use-raw.helper": "Set to false if you serve files via a custom domain.",
"storage.providers.fields.github.use-raw.label": "Use raw URL",
"storage.providers.fields.github.use-raw.placeholder": "true / false",
"storage.providers.fields.s3.access-key.label": "Access Key ID",
"storage.providers.fields.s3.access-key.placeholder": "AKIAxxxxxxxxxxxx",
"storage.providers.fields.s3.bucket.description": "Name of the S3 bucket that stores your photos.",
"storage.providers.fields.s3.bucket.label": "Bucket name",
"storage.providers.fields.s3.bucket.placeholder": "afilmory-photos",
"storage.providers.fields.s3.custom-domain.description": "Domain used when generating public photo URLs.",
"storage.providers.fields.s3.custom-domain.label": "Custom public domain",
"storage.providers.fields.s3.custom-domain.placeholder": "https://cdn.example.com",
"storage.providers.fields.s3.endpoint.description": "Optional endpoint for S3-compatible services.",
"storage.providers.fields.s3.endpoint.helper": "Leave empty for AWS S3. Required for MinIO or other vendors.",
"storage.providers.fields.s3.endpoint.label": "Custom endpoint",
"storage.providers.fields.s3.endpoint.placeholder": "https://s3.example.com",
"storage.providers.fields.s3.exclude-regex.description": "Optional. Skip files that match this regular expression.",
"storage.providers.fields.s3.exclude-regex.helper": "Use JavaScript-compatible regular expressions.",
"storage.providers.fields.s3.exclude-regex.label": "Exclude pattern (regex)",
"storage.providers.fields.s3.exclude-regex.placeholder": "\\\\.(tmp|bak)$",
"storage.providers.fields.s3.max-files.description": "Optional limit for how many files to scan per run.",
"storage.providers.fields.s3.max-files.label": "Max files",
"storage.providers.fields.s3.max-files.placeholder": "1000",
"storage.providers.fields.s3.prefix.description": "Optional. Limit scanning to objects under this prefix.",
"storage.providers.fields.s3.prefix.label": "Path prefix",
"storage.providers.fields.s3.prefix.placeholder": "photos/",
"storage.providers.fields.s3.region.description": "S3 region code, e.g. ap-southeast-1.",
"storage.providers.fields.s3.region.label": "Region",
"storage.providers.fields.s3.region.placeholder": "ap-southeast-1",
"storage.providers.fields.s3.secret-key.label": "Secret Access Key",
"storage.providers.fields.s3.secret-key.placeholder": "************",
"storage.providers.modal.create.description": "Configure a new storage backend for your photos.",
"storage.providers.modal.create.title": "Add storage provider",
"storage.providers.modal.edit.description": "Update provider credentials and options.",
"storage.providers.modal.edit.title": "Edit provider",
"storage.providers.modal.fields.name.label": "Display name",
"storage.providers.modal.fields.name.placeholder": "e.g., Production S3",
"storage.providers.modal.fields.type.label": "Provider type",
"storage.providers.modal.fields.type.placeholder": "Select a provider",
"storage.providers.modal.sections.basic": "Basic information",
"storage.providers.modal.sections.connection": "Connection configuration",
"storage.providers.prompt.sync.cancel": "Maybe later",
"storage.providers.prompt.sync.confirm": "Start syncing",
"storage.providers.prompt.sync.description": "Storage provider configuration is saved. Go to Data Sync now to scan storage and update the database?",
"storage.providers.prompt.sync.title": "Sync photos now?",
"storage.providers.security.description": "Sensitive credentials (keys, tokens, etc.) are encrypted with {{algorithm}} to protect your data.",
"storage.providers.security.helper": "{{algorithm}} provides authenticated encryption to keep data confidential and tamper-proof.",
"storage.providers.security.title": "Storage security",
"storage.providers.status.dirty": "{{total}} provider(s) pending save",
"storage.providers.status.error": "Save failed: {{reason}}",
"storage.providers.status.saved": "✓ Storage configuration saved",
"storage.providers.status.summary": "{{total}} storage provider(s) • {{active}} active",
"storage.providers.types.eagle": "Eagle library",
"storage.providers.types.github": "GitHub repository",
"storage.providers.types.local": "Local storage",
"storage.providers.types.minio": "MinIO",
"storage.providers.types.s3": "AWS S3 / compatible object storage",
"superadmin.brand": "Afilmory · System Settings",
"superadmin.builder-debug.actions.cancel": "Cancel Debug",
"superadmin.builder-debug.actions.start": "Run Debug",
@@ -317,6 +697,7 @@
"superadmin.settings.message.unknown-error": "Unknown error",
"superadmin.settings.stats.remaining": "Remaining registration slots",
"superadmin.settings.stats.total-users": "Total users",
"superadmin.settings.stats.unlimited": "Unlimited",
"superadmin.settings.title": "System Settings",
"superadmin.tenants.button.ban": "Ban",
"superadmin.tenants.button.processing": "Working…",
@@ -341,5 +722,17 @@
"superadmin.tenants.toast.ban-success": "Tenant {{name}} has been banned.",
"superadmin.tenants.toast.plan-error": "Failed to update subscription plan.",
"superadmin.tenants.toast.plan-success": "{{name}} switched to the {{planId}} plan.",
"superadmin.tenants.toast.unban-success": "Tenant {{name}} is no longer banned."
"superadmin.tenants.toast.unban-success": "Tenant {{name}} is no longer banned.",
"welcome.tenant-missing.code": "404",
"welcome.tenant-missing.description": "We could not find a space at this address. It may have been removed or the link is incorrect. Double-check the URL, or register your own space to continue using Afilmory.",
"welcome.tenant-missing.home": "Back to home",
"welcome.tenant-missing.register": "Register now",
"welcome.tenant-missing.request": "Requested host:",
"welcome.tenant-missing.title": "Space Not Found",
"welcome.tenant-restricted.code": "403",
"welcome.tenant-restricted.description": "This hostname is reserved by the system and cannot serve a dashboard or public site. To keep exploring Afilmory, use another hostname or register your own dedicated space.",
"welcome.tenant-restricted.home": "Back to home",
"welcome.tenant-restricted.register": "Create a new space",
"welcome.tenant-restricted.request": "Requested host:",
"welcome.tenant-restricted.title": "Space Reserved"
}

View File

@@ -70,7 +70,7 @@
"common.unknown-error": "未知错误",
"dashboard.overview.activity.empty": "暂无最近活动,上传照片后即可看到这里的动态。",
"dashboard.overview.activity.error": "无法获取活动数据,请稍后再试。",
"dashboard.overview.activity.id-label": "ID:",
"dashboard.overview.activity.id-label": "ID",
"dashboard.overview.activity.no-preview": "暂无预览",
"dashboard.overview.activity.size-unknown": "大小未知",
"dashboard.overview.activity.subtitle": "展示最近 {{count}} 次上传和同步记录",
@@ -78,7 +78,7 @@
"dashboard.overview.activity.taken-at": "拍摄时间 {{time}}",
"dashboard.overview.activity.uploaded-at": "上传于 {{time}}",
"dashboard.overview.page.description": "掌握图库运行状态与最近同步活动",
"dashboard.overview.page.title": "Dashboard",
"dashboard.overview.page.title": "仪表盘",
"dashboard.overview.section.activity.title": "最近活动",
"dashboard.overview.stats.month.helper.equal": "与上月持平",
"dashboard.overview.stats.month.helper.first": "首次出现上传记录",
@@ -182,18 +182,268 @@
"photos.conflict.metadata.label": "元数据不一致",
"photos.conflict.missing.description": "数据库存在记录,但对应的存储对象已无法访问。",
"photos.conflict.missing.label": "存储缺失",
"photos.library.actions.all-selected": "已全选",
"photos.library.actions.clear-selection": "清除选择",
"photos.library.actions.delete": "删除",
"photos.library.actions.edit-tags": "编辑标签",
"photos.library.actions.select-all": "全选",
"photos.library.actions.selected-count": "已选 {{count}} 项",
"photos.library.actions.upload": "上传文件",
"photos.library.actions.upload-short": "上传",
"photos.library.card.delete": "删除素材",
"photos.library.card.device-unknown": "未知设备",
"photos.library.card.edit-tags": "编辑标签",
"photos.library.card.no-preview": "暂无预览",
"photos.library.card.select": "选择",
"photos.library.card.selected": "已选择",
"photos.library.card.size-unknown": "大小未知",
"photos.library.card.view-exif": "查看 EXIF",
"photos.library.delete.cancel": "取消",
"photos.library.delete.confirm": "删除",
"photos.library.delete.description": "此操作无法撤销。要继续删除「{{name}}」吗?勾选下方选项可同时删除远程存储文件。",
"photos.library.delete.option.description": "勾选后会删除远程原件与缩略图。",
"photos.library.delete.option.title": "同时删除存储文件",
"photos.library.delete.title": "确定要删除该素材?",
"photos.library.empty.description": "使用上传按钮将照片添加到图库。",
"photos.library.empty.title": "还没有照片",
"photos.library.exif.empty": "该素材没有可用的 EXIF 数据。",
"photos.library.exif.file": "文件:{{value}}",
"photos.library.exif.rows.altitude": "海拔",
"photos.library.exif.rows.altitude-below": "{{value}} 米(低于海平面)",
"photos.library.exif.rows.altitude-value": "{{value}} 米",
"photos.library.exif.rows.aperture": "光圈",
"photos.library.exif.rows.aspect-ratio": "纵横比",
"photos.library.exif.rows.author": "作者",
"photos.library.exif.rows.brightness": "亮度值",
"photos.library.exif.rows.captured-at": "拍摄时间",
"photos.library.exif.rows.color-space": "色彩空间",
"photos.library.exif.rows.copyright": "版权",
"photos.library.exif.rows.device": "相机",
"photos.library.exif.rows.eq-focal-length": "等效 35mm 焦距",
"photos.library.exif.rows.exposure-compensation": "曝光补偿",
"photos.library.exif.rows.exposure-mode": "曝光模式",
"photos.library.exif.rows.exposure-program": "曝光程序",
"photos.library.exif.rows.file-format": "格式",
"photos.library.exif.rows.file-size": "文件大小",
"photos.library.exif.rows.flash": "闪光灯",
"photos.library.exif.rows.focal-length": "焦距",
"photos.library.exif.rows.iso": "ISO 感光度",
"photos.library.exif.rows.latitude": "纬度",
"photos.library.exif.rows.lens": "镜头",
"photos.library.exif.rows.light-source": "光源",
"photos.library.exif.rows.longitude": "经度",
"photos.library.exif.rows.megapixels": "像素数",
"photos.library.exif.rows.metering-mode": "测光模式",
"photos.library.exif.rows.photo-id": "照片 ID",
"photos.library.exif.rows.rating": "评级",
"photos.library.exif.rows.resolution": "分辨率",
"photos.library.exif.rows.scale-factor": "35mm 换算系数",
"photos.library.exif.rows.scene-type": "场景类型",
"photos.library.exif.rows.sensor": "传感器",
"photos.library.exif.rows.shutter": "快门速度",
"photos.library.exif.rows.software": "软件",
"photos.library.exif.rows.time-offset": "时差",
"photos.library.exif.rows.timezone": "时区",
"photos.library.exif.rows.timezone-source": "时区来源",
"photos.library.exif.rows.title": "标题",
"photos.library.exif.rows.white-balance": "白平衡",
"photos.library.exif.sections.basic": "基础信息",
"photos.library.exif.sections.capture": "拍摄参数",
"photos.library.exif.sections.fuji": "富士配方",
"photos.library.exif.sections.location": "位置信息",
"photos.library.exif.sections.metadata": "元数据",
"photos.library.sort.by-captured": "按拍摄时间排序",
"photos.library.sort.by-uploaded": "按上传时间排序",
"photos.library.sort.order-asc": "最早在前",
"photos.library.sort.order-desc": "最新在前",
"photos.library.tags.modal.asset-count": "{{count}} 个素材",
"photos.library.tags.modal.cancel": "取消",
"photos.library.tags.modal.description.multiple": "所有选中素材将使用相同标签。标签也会影响远程存储路径。",
"photos.library.tags.modal.description.single": "标签决定远程存储路径,调整标签会同时移动原始文件与 Live Photo 视频。",
"photos.library.tags.modal.input": "输入标签并按 Enter或从推荐标签中选择",
"photos.library.tags.modal.no-selection": "未选中任何素材",
"photos.library.tags.modal.path.hint": "根据标签顺序生成",
"photos.library.tags.modal.path.preview": "新的存储路径预览",
"photos.library.tags.modal.path.sample": "示例路径(首个条目)",
"photos.library.tags.modal.save": "保存",
"photos.library.tags.modal.saving": "保存中…",
"photos.library.tags.modal.title": "为「{{name}}」编辑标签",
"photos.library.tags.toast.error": "无法更新标签",
"photos.library.tags.toast.error-description": "请稍后再试。",
"photos.library.tags.toast.multi-success": "已更新 {{count}} 个素材的标签",
"photos.library.tags.toast.single-success": "标签已更新",
"photos.library.tags.toast.success-description": "已根据新标签更新存储路径。",
"photos.page.description": "在此同步和管理服务器中的照片资产。",
"photos.page.title": "照片库",
"photos.sync.actions.button.apply": "同步照片",
"photos.sync.actions.button.preview": "预览同步",
"photos.sync.actions.toast.apply-success": "照片同步完成",
"photos.sync.actions.toast.error-description": "照片同步失败,请稍后再试。",
"photos.sync.actions.toast.error-title": "同步失败",
"photos.sync.actions.toast.preview-success": "预览同步完成",
"photos.sync.actions.toast.success-description": "新增 {{inserted}} · 更新 {{updated}} · 冲突 {{conflicts}} · 错误 {{errors}}",
"photos.sync.conflicts.actions.all-database": "全部采用数据库版本",
"photos.sync.conflicts.actions.all-storage": "全部采用存储版本",
"photos.sync.conflicts.actions.clear-selection": "清除选择",
"photos.sync.conflicts.actions.hide-details": "收起详情",
"photos.sync.conflicts.actions.open-storage": "打开",
"photos.sync.conflicts.actions.prefer-database": "保留数据库",
"photos.sync.conflicts.actions.prefer-storage": "保留存储",
"photos.sync.conflicts.actions.selected-database": "将数据库版本应用到所选项",
"photos.sync.conflicts.actions.selected-storage": "将存储版本应用到所选项",
"photos.sync.conflicts.actions.view-details": "查看详情",
"photos.sync.conflicts.actions.view-original": "查看原图",
"photos.sync.conflicts.description": "这些冲突需要人工确认,可多选条目加速处理。",
"photos.sync.conflicts.info.conflict-key": "冲突 Key",
"photos.sync.conflicts.info.first-detected": "发现时间:{{time}}",
"photos.sync.conflicts.info.last-updated": "最近更新:{{time}}",
"photos.sync.conflicts.info.photo-id-fallback": "缺少 Photo ID",
"photos.sync.conflicts.info.storage-key": "存储 Key",
"photos.sync.conflicts.preview.common.dimensions": "尺寸:",
"photos.sync.conflicts.preview.common.id": "ID",
"photos.sync.conflicts.preview.common.size": "大小:",
"photos.sync.conflicts.preview.common.updated-at": "更新时间:",
"photos.sync.conflicts.preview.database.empty": "无可用数据库记录",
"photos.sync.conflicts.preview.database.title": "数据库记录",
"photos.sync.conflicts.preview.storage.key": "Key",
"photos.sync.conflicts.preview.storage.title": "存储对象",
"photos.sync.conflicts.prompts.bulk": "是否处理 {{scope}} 并 {{strategy}}",
"photos.sync.conflicts.prompts.cancel": "取消",
"photos.sync.conflicts.prompts.confirm": "确认",
"photos.sync.conflicts.prompts.scope-all": "全部待处理冲突",
"photos.sync.conflicts.prompts.scope-selected": "所选 {{count}} 个冲突",
"photos.sync.conflicts.prompts.single": "是否处理冲突 {{identifier}} 并 {{strategy}}",
"photos.sync.conflicts.prompts.title": "确认操作",
"photos.sync.conflicts.selection.clear": "清除选择",
"photos.sync.conflicts.selection.none": "未选择任何项",
"photos.sync.conflicts.selection.selected": "已选 {{count}} 项",
"photos.sync.conflicts.strategy.database": "保留数据库版本",
"photos.sync.conflicts.strategy.storage": "保留存储版本",
"photos.sync.conflicts.title": "待处理冲突",
"photos.sync.conflicts.toast.no-original": "此记录没有原始资源预览。",
"photos.sync.conflicts.toast.none": "当前没有需要处理的冲突。",
"photos.sync.conflicts.toast.open-storage-failed": "无法打开存储对象",
"photos.sync.conflicts.toast.select-required": "请先选择需要处理的冲突。",
"photos.sync.conflicts.total": "总计:{{count}}",
"photos.sync.metadata.database": "元数据 · 数据库",
"photos.sync.metadata.etag": "ETag实体标签",
"photos.sync.metadata.hash": "元数据哈希",
"photos.sync.metadata.none": "无",
"photos.sync.metadata.size": "大小",
"photos.sync.metadata.storage": "元数据 · 存储",
"photos.sync.metadata.unknown": "未知",
"photos.sync.metadata.updated-at": "更新时间",
"photos.sync.progress.heading.error": "同步失败",
"photos.sync.progress.heading.preview": "正在运行预览同步",
"photos.sync.progress.heading.running": "正在运行同步",
"photos.sync.progress.logs.detail.error": "错误 {{value}}",
"photos.sync.progress.logs.detail.live-photo": "包含实况照片",
"photos.sync.progress.logs.detail.live-photo-absent": "无实况照片",
"photos.sync.progress.logs.detail.manifest": "包含上一份清单",
"photos.sync.progress.logs.detail.manifest-absent": "无上一份清单",
"photos.sync.progress.logs.detail.result": "结果 {{value}}",
"photos.sync.progress.logs.level.error": "错误",
"photos.sync.progress.logs.level.info": "信息",
"photos.sync.progress.logs.level.success": "成功",
"photos.sync.progress.logs.level.warn": "警告",
"photos.sync.progress.logs.recent": "最新 {{count}} 条",
"photos.sync.progress.logs.title": "同步日志",
"photos.sync.progress.recent.no-further": "无需进一步操作",
"photos.sync.progress.recent.progress": "进度:{{processed}} / {{total}}",
"photos.sync.progress.recent.title": "最近操作",
"photos.sync.progress.stage-status.completed": "已完成",
"photos.sync.progress.stage-status.pending": "待开始",
"photos.sync.progress.stage-status.running": "进行中",
"photos.sync.progress.stages.conflicts.description": "检测存储元数据与数据库记录的差异。",
"photos.sync.progress.stages.conflicts.label": "校验元数据",
"photos.sync.progress.stages.missing.description": "将存储中的新对象同步到数据库。",
"photos.sync.progress.stages.missing.label": "导入新照片",
"photos.sync.progress.stages.orphan.description": "标记数据库中对应存储对象缺失的记录。",
"photos.sync.progress.stages.orphan.label": "识别孤立记录",
"photos.sync.progress.stages.progress": "{{processed}} / {{total}} 项",
"photos.sync.progress.stages.reconciliation.description": "更新记录状态以匹配最新元数据。",
"photos.sync.progress.stages.reconciliation.label": "状态对账",
"photos.sync.progress.status.error": "已停止",
"photos.sync.progress.status.running": "进行中",
"photos.sync.progress.subtitle.error": "同步过程中发生错误,请查看日志后重试。",
"photos.sync.progress.subtitle.preview": "正在模拟同步操作,预览模式不会写入数据库。",
"photos.sync.progress.subtitle.running": "正在对比存储与数据库,请保持此页面开启以查看实时进度。",
"photos.sync.result.actions.applied": "已应用",
"photos.sync.result.actions.collapse": "收起详情",
"photos.sync.result.actions.expand": "查看详情",
"photos.sync.result.actions.pending": "未应用",
"photos.sync.result.alerts.open-original-failed": "无法打开原始照片",
"photos.sync.result.duration.less-than-second": "少于 1 秒",
"photos.sync.result.duration.minutes": "{{count}} 分钟",
"photos.sync.result.duration.seconds": "{{count}} 秒",
"photos.sync.result.filters.all": "全部",
"photos.sync.result.history.completed-at": "完成时间 {{time}}",
"photos.sync.result.history.duration": "耗时 {{duration}}",
"photos.sync.result.history.heading": "最近完成的同步",
"photos.sync.result.history.mode.live": "实时模式 · 已应用更改",
"photos.sync.result.history.mode.preview": "预览模式 · 未写入数据库",
"photos.sync.result.history.operations": "操作:{{count}}",
"photos.sync.result.info.conflict-type": "冲突类型:",
"photos.sync.result.info.photo-id": "Photo ID",
"photos.sync.result.info.storage-key": "存储 Key",
"photos.sync.result.manifest.empty": "暂无清单数据",
"photos.sync.result.operations.count": "操作:{{count}}",
"photos.sync.result.operations.filter-label": "· 筛选:",
"photos.sync.result.status.empty.description": "请在设置中配置并启用存储提供方,然后使用同步控制开始处理。预览模式安全且不会修改数据。",
"photos.sync.result.status.empty.title": "尚无同步记录",
"photos.sync.result.status.loading.description": "正在获取最新同步任务,请稍候…",
"photos.sync.result.status.loading.title": "正在加载同步状态",
"photos.sync.result.summary.description.latest": "下方展示最近一次同步结果。",
"photos.sync.result.summary.description.live": "最近一次同步以实时模式执行,并写入了数据库。",
"photos.sync.result.summary.description.preview": "最近运行的是预览模式,未对数据库写入任何更改。",
"photos.sync.result.summary.heading": "同步概览",
"photos.sync.result.summary.labels.completed": "已同步",
"photos.sync.result.summary.labels.conflicts": "冲突",
"photos.sync.result.summary.labels.database-records": "数据库记录",
"photos.sync.result.summary.labels.deleted": "已删除",
"photos.sync.result.summary.labels.errors": "错误",
"photos.sync.result.summary.labels.inserted": "新增照片",
"photos.sync.result.summary.labels.pending": "待处理",
"photos.sync.result.summary.labels.skipped": "已跳过",
"photos.sync.result.summary.labels.storage-objects": "存储对象",
"photos.sync.result.summary.labels.updated": "已更新",
"photos.sync.result.table.empty.filtered": "没有符合当前筛选条件的操作。",
"photos.sync.result.table.empty.none": "同步完成,但没有可供查看的操作。",
"photos.sync.result.table.mode.live": "实时模式 · 结果已保存",
"photos.sync.result.table.mode.preview": "预览模式 · 未应用更改",
"photos.sync.result.table.title": "同步操作详情",
"photos.sync.toasts.conflict-batch-error": "部分冲突处理失败",
"photos.sync.toasts.conflict-batch-success": "{{strategy}} 已处理 {{count}} 个冲突。",
"photos.sync.toasts.conflict-database": "已保留数据库记录,忽略存储差异。",
"photos.sync.toasts.conflict-error": "冲突处理失败",
"photos.sync.toasts.conflict-error-desc": "当前无法解决冲突,请稍后再试。",
"photos.sync.toasts.conflict-resolved": "冲突已解决",
"photos.sync.toasts.conflict-select": "请先选择至少一个冲突项。",
"photos.sync.toasts.conflict-storage": "已将存储版本写入数据库。",
"photos.tabs.library": "图库管理",
"photos.tabs.storage": "素材存储",
"photos.tabs.sync": "存储同步",
"photos.tabs.usage": "用量记录",
"photos.usage.photo-created.description": "通过上传或同步新增的照片资产。",
"photos.usage.events.description": "与计费相关的最新操作,如上传、删除与同步。",
"photos.usage.events.empty.description": "上传照片或运行同步后会在此显示计费事件。",
"photos.usage.events.empty.title": "暂无使用数据",
"photos.usage.events.metadata.empty": "— / 无",
"photos.usage.events.metadata.more": "+{{count}} 项",
"photos.usage.events.metadata.value-unknown": "未知",
"photos.usage.events.title": "最近的使用事件",
"photos.usage.events.total": "{{count}} 条事件",
"photos.usage.events.unit.byte": "字节",
"photos.usage.events.unit.count": "次数",
"photos.usage.events.unit.label": "单位:{{unit}}",
"photos.usage.photo-created.description": "通过上传或同步新增的照片。",
"photos.usage.photo-created.label": "新增照片",
"photos.usage.photo-deleted.description": "从图库或存储中除的照片资产。",
"photos.usage.photo-deleted.description": "从图库或存储中除的照片。",
"photos.usage.photo-deleted.label": "删除照片",
"photos.usage.sync-completed.description": "一次数据同步执行完成时记录的汇总事件。",
"photos.usage.sync-completed.label": "同步运行",
"photos.usage.summary.description": "按事件类型汇总的累计统计。",
"photos.usage.summary.refresh": "刷新",
"photos.usage.summary.title": "使用概览",
"photos.usage.sync-completed.description": "数据同步完成时产生的汇总事件。",
"photos.usage.sync-completed.label": "同步完成",
"plan.badge.current": "当前方案",
"plan.badge.internal": "内部方案",
"plan.checkout.coming-soon": "敬请期待",
@@ -209,7 +459,7 @@
"plan.quotas.label.maxSyncObjectSizeMb": "同步素材大小",
"plan.quotas.label.maxUploadSizeMb": "单次上传大小",
"plan.quotas.label.monthlyAssetProcessLimit": "每月可新增照片",
"plan.quotas.unit.megabytes": "{{value}} MB",
"plan.quotas.unit.megabytes": "{{value}} MB(兆字节)",
"plan.quotas.unit.photos": "{{value}} 张",
"plan.quotas.unlimited": "无限制",
"plan.toast.checkout-failure": "无法创建订阅结算会话,请稍后再试。",
@@ -234,68 +484,198 @@
"settings.site.title": "站点设置",
"settings.user.description": "维护展示在前台的作者资料、头像与别名。",
"settings.user.title": "用户信息",
"site.settings.banner.dirty": "{{count}} 项设置待保存",
"site.settings.banner.fail": "保存失败:",
"site.settings.banner.success": "设置已成功同步",
"site.settings.banner.synced": "所有设置均为最新状态",
"site.settings.button.save": "保存更改",
"site.settings.button.saving": "保存中…",
"site.settings.error.load-prefix": "无法加载站点设置:",
"site.settings.error.unknown": "未知错误",
"site.user.blocker.cancel": "留在此页",
"site.user.blocker.confirm": "仍然离开",
"site.user.blocker.description": "您有未保存的更改,离开将会丢失,确定继续?",
"site.user.blocker.title": "离开前请先保存",
"site.user.button.save": "保存更改",
"site.user.button.saving": "保存中…",
"site.user.error.loading": "无法加载用户资料",
"site.user.form.avatar.helper": "支持 http(s) 或 // 开头的链接,留空则回退为首字母头像。",
"site.user.form.avatar.label": "头像 URL",
"site.user.form.avatar.placeholder": "https://cdn.example.com/avatar.png示例",
"site.user.form.display.helper": "显示在头像旁。留空则使用作者姓名。",
"site.user.form.display.label": "展示名称",
"site.user.form.display.placeholder": "可选,例如 innei.photo",
"site.user.form.name.helper": "会公开展示,并写入 RSS 的作者/编辑字段。",
"site.user.form.name.label": "作者姓名",
"site.user.form.name.placeholder": "例如 Innei",
"site.user.form.username.helper": "内部用于识别作者,不会公开显示。",
"site.user.form.username.label": "用户名(可选)",
"site.user.form.username.placeholder": "例如 innei",
"site.user.header.badge": "作者资料",
"site.user.header.description": "用于站点头部、RSS 与社交卡片,请保持与个人品牌一致。",
"site.user.header.title": "公开作者身份",
"site.user.preview.avatar-alt": "作者头像预览",
"site.user.preview.fallback": "作者",
"site.user.preview.last-updated": "最近更新:{{time}}",
"site.user.preview.never-updated": "尚未更新",
"site.user.toast.error": "保存资料失败",
"site.user.toast.error-description": "请检查输入后重试。",
"site.user.toast.success": "资料已更新",
"storage.providers.actions.add": "添加存储源",
"storage.providers.actions.cancel": "取消",
"storage.providers.actions.create": "创建存储源",
"storage.providers.actions.save": "保存更改",
"storage.providers.actions.saving": "保存中…",
"storage.providers.blocker.cancel": "留在此页",
"storage.providers.blocker.confirm": "仍然离开",
"storage.providers.blocker.description": "当前存储配置尚未保存,离开页面会丢失这些修改,确定继续?",
"storage.providers.blocker.title": "请先保存存储配置",
"storage.providers.card.active": "已启用",
"storage.providers.card.edit": "编辑",
"storage.providers.card.make-active": "设为启用",
"storage.providers.card.make-inactive": "设为停用",
"storage.providers.card.preview.fallback": "存储提供方",
"storage.providers.card.preview.not-configured": "未配置",
"storage.providers.card.untitled": "未命名存储",
"storage.providers.empty.action": "添加存储源",
"storage.providers.empty.description": "添加一个存储提供方,系统才能同步并管理远程照片。",
"storage.providers.empty.title": "尚未配置存储提供方",
"storage.providers.error.load": "无法加载存储设置",
"storage.providers.fields.github.branch.description": "可选,要同步的分支。",
"storage.providers.fields.github.branch.helper": "默认 master/main如有不同请填写完整分支名。",
"storage.providers.fields.github.branch.label": "分支",
"storage.providers.fields.github.branch.placeholder": "main示例",
"storage.providers.fields.github.owner.description": "GitHub 用户或组织名称。",
"storage.providers.fields.github.owner.label": "仓库拥有者",
"storage.providers.fields.github.owner.placeholder": "afilmory示例",
"storage.providers.fields.github.path.description": "可选,限制同步的仓库路径。",
"storage.providers.fields.github.path.label": "仓库路径",
"storage.providers.fields.github.path.placeholder": "public/photos可选",
"storage.providers.fields.github.repo.description": "存放照片的仓库。",
"storage.providers.fields.github.repo.label": "仓库名称",
"storage.providers.fields.github.repo.placeholder": "photo-assets示例",
"storage.providers.fields.github.token.description": "用于私有仓库的 Personal Access Token。",
"storage.providers.fields.github.token.label": "访问令牌",
"storage.providers.fields.github.token.placeholder": "ghp_xxxxxxxxxxxxxxxxxxxx示例",
"storage.providers.fields.github.use-raw.description": "生成公开链接时使用 raw.githubusercontent.com。",
"storage.providers.fields.github.use-raw.helper": "如果通过自定义域名分发文件,请设为 false。",
"storage.providers.fields.github.use-raw.label": "使用 raw 链接",
"storage.providers.fields.github.use-raw.placeholder": "true / false布尔",
"storage.providers.fields.s3.access-key.label": "Access Key ID公钥",
"storage.providers.fields.s3.access-key.placeholder": "AKIAxxxxxxxxxxxx示例",
"storage.providers.fields.s3.bucket.description": "存放照片的 S3 Bucket 名称。",
"storage.providers.fields.s3.bucket.label": "Bucket 名称",
"storage.providers.fields.s3.bucket.placeholder": "afilmory-photos示例",
"storage.providers.fields.s3.custom-domain.description": "用于生成公开照片链接的域名。",
"storage.providers.fields.s3.custom-domain.label": "自定义公开域名",
"storage.providers.fields.s3.custom-domain.placeholder": "https://cdn.example.com示例",
"storage.providers.fields.s3.endpoint.description": "S3 兼容服务的自定义 Endpoint。",
"storage.providers.fields.s3.endpoint.helper": "使用 AWS S3 时可留空MinIO 或其他厂商需要填写。",
"storage.providers.fields.s3.endpoint.label": "自定义 Endpoint",
"storage.providers.fields.s3.endpoint.placeholder": "https://s3.example.com示例",
"storage.providers.fields.s3.exclude-regex.description": "可选,跳过匹配该正则表达式的文件。",
"storage.providers.fields.s3.exclude-regex.helper": "使用 JavaScript 兼容的正则表达式。",
"storage.providers.fields.s3.exclude-regex.label": "排除规则(正则)",
"storage.providers.fields.s3.exclude-regex.placeholder": "\\.(tmp|bak)$",
"storage.providers.fields.s3.max-files.description": "可选,每次扫描的最大文件数。",
"storage.providers.fields.s3.max-files.label": "最大文件数",
"storage.providers.fields.s3.max-files.placeholder": "1000可选",
"storage.providers.fields.s3.prefix.description": "可选,仅扫描该前缀下的对象。",
"storage.providers.fields.s3.prefix.label": "路径前缀",
"storage.providers.fields.s3.prefix.placeholder": "photos/(前缀)",
"storage.providers.fields.s3.region.description": "S3 区域代码,例如 ap-southeast-1。",
"storage.providers.fields.s3.region.label": "区域",
"storage.providers.fields.s3.region.placeholder": "ap-southeast-1示例",
"storage.providers.fields.s3.secret-key.label": "Secret Access Key私钥",
"storage.providers.fields.s3.secret-key.placeholder": "************(示例)",
"storage.providers.modal.create.description": "为照片配置新的存储后端。",
"storage.providers.modal.create.title": "添加存储提供方",
"storage.providers.modal.edit.description": "更新该提供方的凭证和选项。",
"storage.providers.modal.edit.title": "编辑存储提供方",
"storage.providers.modal.fields.name.label": "显示名称",
"storage.providers.modal.fields.name.placeholder": "例如 Production S3",
"storage.providers.modal.fields.type.label": "提供类型",
"storage.providers.modal.fields.type.placeholder": "请选择提供方",
"storage.providers.modal.sections.basic": "基础信息",
"storage.providers.modal.sections.connection": "连接配置",
"storage.providers.prompt.sync.cancel": "稍后再说",
"storage.providers.prompt.sync.confirm": "开始同步",
"storage.providers.prompt.sync.description": "存储提供方配置已保存。是否立即前往数据同步,扫描存储并更新数据库?",
"storage.providers.prompt.sync.title": "立即同步照片?",
"storage.providers.security.description": "密钥、令牌等敏感凭据会使用 {{algorithm}} 加密以保护数据。",
"storage.providers.security.helper": "{{algorithm}} 提供认证加密,确保数据保密且不可篡改。",
"storage.providers.security.title": "存储安全",
"storage.providers.status.dirty": "{{total}} 个存储源待保存",
"storage.providers.status.error": "保存失败:{{reason}}",
"storage.providers.status.saved": "✓ 存储配置已保存",
"storage.providers.status.summary": "{{total}} 个存储提供方 • {{active}} 个启用",
"storage.providers.types.eagle": "Eagle 素材库",
"storage.providers.types.github": "GitHub 仓库",
"storage.providers.types.local": "本地存储",
"storage.providers.types.minio": "MinIO 存储",
"storage.providers.types.s3": "AWS S3 / 兼容对象存储",
"superadmin.brand": "Afilmory · 系统管理",
"superadmin.builder-debug.actions.cancel": "取消调试",
"superadmin.builder-debug.actions.start": "启动调试",
"superadmin.builder-debug.api.missing-result": "调试过程中未收到最终结果,连接已终止。",
"superadmin.builder-debug.actions.start": "开始调试",
"superadmin.builder-debug.api.missing-result": "在连接结束前未收到最终调试结果。",
"superadmin.builder-debug.api.request-failed": "调试请求失败:{{status}} {{statusText}}",
"superadmin.builder-debug.description": "该工具用于单张图片的 Builder 管线验收。调试过程中不会写入数据库,所有上传与生成的文件会在任务完成后立刻清理。",
"superadmin.builder-debug.description": "在不触碰持久化数据的情况下验证单张图片的构建管线。",
"superadmin.builder-debug.input.clear": "清除",
"superadmin.builder-debug.input.file-meta": "{{size}} · {{type}}",
"superadmin.builder-debug.input.max": "仅支持张图片,大 25 MB",
"superadmin.builder-debug.input.placeholder": "点击或拖图片到此区域",
"superadmin.builder-debug.input.subtitle": "选择一张原始图片,系统将模拟 Builder 处理链路。",
"superadmin.builder-debug.input.file-meta": "{{size}} · {{type}}(源文件)",
"superadmin.builder-debug.input.max": "仅支持 1 张图片,大小不超过 25 MB",
"superadmin.builder-debug.input.placeholder": "点击或拖图片",
"superadmin.builder-debug.input.subtitle": "选择一张源图片来模拟 Builder 管线。",
"superadmin.builder-debug.input.title": "调试输入",
"superadmin.builder-debug.log.level.error": "ERROR",
"superadmin.builder-debug.log.level.info": "INFO",
"superadmin.builder-debug.log.level.success": "SUCCESS",
"superadmin.builder-debug.log.level.warn": "WARN",
"superadmin.builder-debug.log.level.error": "错误",
"superadmin.builder-debug.log.level.info": "信息",
"superadmin.builder-debug.log.level.success": "成功",
"superadmin.builder-debug.log.level.warn": "警告",
"superadmin.builder-debug.log.message.complete": "构建完成 · 结果 {{resultType}}",
"superadmin.builder-debug.log.message.start": "上传 {{filename}}准备行 Builder",
"superadmin.builder-debug.log.status.complete": "COMPLETE",
"superadmin.builder-debug.log.status.error": "ERROR",
"superadmin.builder-debug.log.status.start": "START",
"superadmin.builder-debug.logs.empty": "无日志",
"superadmin.builder-debug.logs.initializing": "正在初始化调试环境...",
"superadmin.builder-debug.log.message.start": "正在上传 {{filename}} · 准备行 Builder",
"superadmin.builder-debug.log.status.complete": "完成",
"superadmin.builder-debug.log.status.error": "错误",
"superadmin.builder-debug.log.status.start": "开始",
"superadmin.builder-debug.logs.empty": "无日志",
"superadmin.builder-debug.logs.initializing": "正在准备调试环境",
"superadmin.builder-debug.logs.source": "来源Builder + Data Sync Relay",
"superadmin.builder-debug.logs.subtitle": "最新 {{count}} 条消息",
"superadmin.builder-debug.logs.title": "实时日志",
"superadmin.builder-debug.notes.keep-page-open": "行期间请保持页面开启。调试依赖与 Data Sync 相同的 builder 配置,并实时返回日志。",
"superadmin.builder-debug.output.after-run": "运行调试后,这里会显示 manifest 内容与概要。",
"superadmin.builder-debug.output.copy": "复制 manifest",
"superadmin.builder-debug.output.no-manifest": "当前任务未生成 manifest 数据。",
"superadmin.builder-debug.output.subtitle": "展示 Builder 返回的 manifest 摘要",
"superadmin.builder-debug.notes.keep-page-open": "任务运行期间请保持页面开启。日志来自与数据同步相同的 Builder 配置。",
"superadmin.builder-debug.output.after-run": "运行调试任务后会在此显示清单详情。",
"superadmin.builder-debug.output.copy": "复制清单",
"superadmin.builder-debug.output.no-manifest": "此次运行未产生清单数据。",
"superadmin.builder-debug.output.subtitle": "查看 Builder 返回的清单摘要",
"superadmin.builder-debug.output.title": "调试输出",
"superadmin.builder-debug.recent.file": "文件",
"superadmin.builder-debug.recent.size": "大小",
"superadmin.builder-debug.recent.storage-key": "Storage Key",
"superadmin.builder-debug.recent.title": "最近一次任务",
"superadmin.builder-debug.safety.items.no-db": "不写入照片资产数据库记录",
"superadmin.builder-debug.safety.items.no-storage": "不在存储中保留任何调试产物",
"superadmin.builder-debug.safety.items.realtime": "所有日志实时输出,供排查使用",
"superadmin.builder-debug.recent.storage-key": "存储 Key",
"superadmin.builder-debug.recent.title": "最近任务",
"superadmin.builder-debug.safety.items.no-db": "不会向数据库写入照片记录",
"superadmin.builder-debug.safety.items.no-storage": "不在存储中保留调试产物",
"superadmin.builder-debug.safety.items.realtime": "日志实时输出,方便排查",
"superadmin.builder-debug.safety.title": "⚠️ 调试以安全模式运行:",
"superadmin.builder-debug.status.error": "失败",
"superadmin.builder-debug.status.idle": "就绪",
"superadmin.builder-debug.status.running": "调试中",
"superadmin.builder-debug.status.running": "运行中",
"superadmin.builder-debug.status.success": "已完成",
"superadmin.builder-debug.summary.cleaned": "产物清理",
"superadmin.builder-debug.summary.cleaned": "产物清理",
"superadmin.builder-debug.summary.cleaned-no": "否",
"superadmin.builder-debug.summary.cleaned-yes": "是",
"superadmin.builder-debug.summary.result-type": "结果类型",
"superadmin.builder-debug.summary.storage-key": "Storage Key",
"superadmin.builder-debug.summary.storage-key": "存储 Key",
"superadmin.builder-debug.summary.thumbnail": "缩略图 URL",
"superadmin.builder-debug.summary.thumbnail-missing": "未生成",
"superadmin.builder-debug.title": "Builder 调试工具",
"superadmin.builder-debug.title": "Builder 调试控制台",
"superadmin.builder-debug.toast.cancelled": "调试已取消",
"superadmin.builder-debug.toast.copy-failure.description": "请手动复制内容",
"superadmin.builder-debug.toast.copy-failure.description": "请手动复制内容",
"superadmin.builder-debug.toast.copy-failure.title": "复制失败",
"superadmin.builder-debug.toast.copy-success": "已复制 manifest 数据",
"superadmin.builder-debug.toast.copy-success": "清单已复制到剪贴板",
"superadmin.builder-debug.toast.failure-fallback": "调试失败,请检查后重试。",
"superadmin.builder-debug.toast.failure.title": "调试失败",
"superadmin.builder-debug.toast.manual-cancelled-log": "手动取消调试任务",
"superadmin.builder-debug.toast.manual-cancelled-message": "调试已被手动取消。",
"superadmin.builder-debug.toast.pick-file": "请选择需要调试的图片文件",
"superadmin.builder-debug.toast.success.description": "Builder 管线行成功,产物已清理。",
"superadmin.builder-debug.toast.manual-cancelled-log": "用户已停止调试运行",
"superadmin.builder-debug.toast.manual-cancelled-message": "调试会话已被手动取消。",
"superadmin.builder-debug.toast.pick-file": "在运行调试前请选择一张图片。",
"superadmin.builder-debug.toast.success.description": "Builder 管线行成功已清理产物。",
"superadmin.builder-debug.toast.success.title": "调试完成",
"superadmin.builder.title": "构建器设置",
"superadmin.nav.builder": "构建器",
@@ -317,6 +697,7 @@
"superadmin.settings.message.unknown-error": "未知错误",
"superadmin.settings.stats.remaining": "剩余可注册名额",
"superadmin.settings.stats.total-users": "当前用户总数",
"superadmin.settings.stats.unlimited": "不限",
"superadmin.settings.title": "系统设置",
"superadmin.tenants.button.ban": "封禁",
"superadmin.tenants.button.processing": "处理中…",
@@ -341,5 +722,17 @@
"superadmin.tenants.toast.ban-success": "已封禁租户 {{name}}",
"superadmin.tenants.toast.plan-error": "更新订阅失败",
"superadmin.tenants.toast.plan-success": "已将 {{name}} 切换到 {{planId}} 计划",
"superadmin.tenants.toast.unban-success": "已解除封禁 {{name}}"
"superadmin.tenants.toast.unban-success": "已解除封禁 {{name}}",
"welcome.tenant-missing.code": "404",
"welcome.tenant-missing.description": "未能在该地址找到空间,可能已被删除或链接有误。请检查 URL或注册自己的空间继续使用 Afilmory。",
"welcome.tenant-missing.home": "返回首页",
"welcome.tenant-missing.register": "立即注册",
"welcome.tenant-missing.request": "请求的主机:",
"welcome.tenant-missing.title": "未找到空间",
"welcome.tenant-restricted.code": "403",
"welcome.tenant-restricted.description": "该主机名被系统保留,无法用于仪表盘或公开站点。如需继续体验 Afilmory请使用其他主机名或注册专属空间。",
"welcome.tenant-restricted.home": "返回首页",
"welcome.tenant-restricted.register": "创建新空间",
"welcome.tenant-restricted.request": "请求的主机:",
"welcome.tenant-restricted.title": "空间已被保留"
}