mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-25 07:15:36 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "空间已被保留"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user