mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(i18n): enhance dashboard with internationalization support
- Integrated `useTranslation` from `react-i18next` across various components for localization. - Updated navigation, settings, and photo management components to utilize translation keys for labels and descriptions. - Refactored error messages and user prompts to support multiple languages. - Improved user experience by ensuring all relevant text is translatable, enhancing accessibility for non-English users. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavLink, useNavigate } from 'react-router'
|
||||
|
||||
import { useAuthUserValue } from '~/atoms/auth'
|
||||
@@ -7,17 +8,18 @@ import { useTenantPlanQuery } from '~/modules/billing'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
const navigationTabs = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Photos', path: '/photos' },
|
||||
{ label: 'Analytics', path: '/analytics' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
] as const
|
||||
{ labelKey: 'nav.overview', path: '/' },
|
||||
{ labelKey: 'nav.photos', path: '/photos' },
|
||||
{ labelKey: 'nav.analytics', path: '/analytics' },
|
||||
{ labelKey: 'nav.settings', path: '/settings' },
|
||||
] as const satisfies readonly { labelKey: I18nKeys; path: string }[]
|
||||
|
||||
export function Header() {
|
||||
const user = useAuthUserValue()
|
||||
const planQuery = useTenantPlanQuery({ enabled: Boolean(user) })
|
||||
const planLabel = planQuery.data?.plan?.name ?? planQuery.data?.plan?.planId ?? null
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<header className="bg-background relative shrink-0 border-b border-fill-tertiary/50">
|
||||
@@ -39,7 +41,7 @@ export function Header() {
|
||||
isActive ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:text-text',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
@@ -49,7 +51,12 @@ export function Header() {
|
||||
{/* Right side - User Menu */}
|
||||
{user && (
|
||||
<div className="border-fill-tertiary/50 ml-2 sm:ml-auto flex items-center gap-3 border-l pl-2 sm:pl-4">
|
||||
<PlanBadge label={planLabel} isLoading={planQuery.isLoading} onClick={() => navigate('/plan')} />
|
||||
<PlanBadge
|
||||
label={planLabel}
|
||||
isLoading={planQuery.isLoading}
|
||||
onClick={() => navigate('/plan')}
|
||||
labelKey="header.plan.badge"
|
||||
/>
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
)}
|
||||
@@ -58,7 +65,18 @@ export function Header() {
|
||||
)
|
||||
}
|
||||
|
||||
function PlanBadge({ label, isLoading, onClick }: { label: string | null; isLoading: boolean; onClick: () => void }) {
|
||||
function PlanBadge({
|
||||
label,
|
||||
isLoading,
|
||||
onClick,
|
||||
labelKey,
|
||||
}: {
|
||||
label: string | null
|
||||
isLoading: boolean
|
||||
onClick: () => void
|
||||
labelKey: I18nKeys
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (isLoading && !label) {
|
||||
return <div className="bg-fill/30 h-6 w-24 animate-pulse rounded-lg border border-fill-tertiary/30" />
|
||||
}
|
||||
@@ -73,7 +91,9 @@ function PlanBadge({ label, isLoading, onClick }: { label: string | null; isLoad
|
||||
onClick={onClick}
|
||||
className="bg-fill/30 text-text-secondary hover:bg-fill/50 flex items-center gap-1.5 rounded border border-fill-tertiary/30 px-2.5 py-1 text-xs font-medium transition sm:text-[13px]"
|
||||
>
|
||||
<span className="text-text-tertiary text-[11px] sm:text-xs font-medium uppercase tracking-wide">Plan</span>
|
||||
<span className="text-text-tertiary text-[11px] sm:text-xs font-medium uppercase tracking-wide">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-text-tertiary/40" aria-hidden="true" />
|
||||
<span className="text-text font-semibold capitalize">{label}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import type { MouseEventHandler, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
type PageTabItem = {
|
||||
id: string
|
||||
label: ReactNode
|
||||
label?: ReactNode
|
||||
labelKey?: I18nKeys
|
||||
to?: string
|
||||
end?: boolean
|
||||
onSelect?: () => void
|
||||
@@ -19,6 +21,9 @@ export interface PageTabsProps {
|
||||
}
|
||||
|
||||
export function PageTabs({ items, activeId, onSelect, className }: PageTabsProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolveLabel = (item: PageTabItem): ReactNode => (item.labelKey ? t(item.labelKey) : (item.label ?? item.id))
|
||||
|
||||
const renderTabContent = (selected: boolean, label: ReactNode) => (
|
||||
<span
|
||||
className={clsxm(
|
||||
@@ -38,7 +43,7 @@ export function PageTabs({ items, activeId, onSelect, className }: PageTabsProps
|
||||
<NavLink key={item.id} to={item.to} end={item.end}>
|
||||
{({ isActive }) => {
|
||||
const selected = isActive || activeId === item.id
|
||||
return renderTabContent(selected, item.label)
|
||||
return renderTabContent(selected, resolveLabel(item))
|
||||
}}
|
||||
</NavLink>
|
||||
)
|
||||
@@ -61,7 +66,7 @@ export function PageTabs({ items, activeId, onSelect, className }: PageTabsProps
|
||||
disabled={item.disabled}
|
||||
className="focus-visible:outline-none shape-squircle"
|
||||
>
|
||||
{renderTabContent(selected, item.label)}
|
||||
{renderTabContent(selected, resolveLabel(item))}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
6
be/apps/dashboard/src/global.d.ts
vendored
6
be/apps/dashboard/src/global.d.ts
vendored
@@ -1,5 +1,7 @@
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
|
||||
import type { useTranslation } from 'react-i18next'
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { t } = useTranslation('dashboard')
|
||||
declare global {
|
||||
export type Nullable<T> = T | null | undefined
|
||||
|
||||
@@ -14,7 +16,7 @@ declare global {
|
||||
} & {}
|
||||
|
||||
const APP_NAME: string
|
||||
|
||||
export type I18nKeys = OmitStringType<Parameters<typeof t>[0]>
|
||||
/**
|
||||
* This function is a macro, will replace in the build stage.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Modal, Prompt } from '@afilmory/ui'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBeforeUnload, useBlocker } from 'react-router'
|
||||
|
||||
type UseBlockOptions = {
|
||||
@@ -12,21 +13,29 @@ type UseBlockOptions = {
|
||||
beforeUnloadMessage?: string
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE = '您有尚未保存的变更'
|
||||
const DEFAULT_DESCRIPTION = '离开当前页面会丢失未保存的更改,确定要继续吗?'
|
||||
const DEFAULT_CONFIRM_TEXT = '继续离开'
|
||||
const DEFAULT_CANCEL_TEXT = '留在此页'
|
||||
const DEFAULT_BEFORE_UNLOAD_MESSAGE = '您有未保存的更改,确定要离开吗?'
|
||||
const blockerI18nKeys = {
|
||||
title: 'blocker.unsaved.title',
|
||||
description: 'blocker.unsaved.description',
|
||||
confirm: 'blocker.unsaved.confirm',
|
||||
cancel: 'blocker.unsaved.cancel',
|
||||
beforeUnload: 'blocker.unsaved.before-unload',
|
||||
} as const
|
||||
|
||||
export function useBlock({
|
||||
when,
|
||||
title = DEFAULT_TITLE,
|
||||
description = DEFAULT_DESCRIPTION,
|
||||
confirmText = DEFAULT_CONFIRM_TEXT,
|
||||
cancelText = DEFAULT_CANCEL_TEXT,
|
||||
title,
|
||||
description,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = 'danger',
|
||||
beforeUnloadMessage = DEFAULT_BEFORE_UNLOAD_MESSAGE,
|
||||
beforeUnloadMessage,
|
||||
}: UseBlockOptions) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedTitle = title ?? t(blockerI18nKeys.title)
|
||||
const resolvedDescription = description ?? t(blockerI18nKeys.description)
|
||||
const resolvedConfirmText = confirmText ?? t(blockerI18nKeys.confirm)
|
||||
const resolvedCancelText = cancelText ?? t(blockerI18nKeys.cancel)
|
||||
const resolvedBeforeUnload = beforeUnloadMessage ?? t(blockerI18nKeys.beforeUnload)
|
||||
const promptIdRef = useRef<string | null>(null)
|
||||
const isPromptOpenRef = useRef(false)
|
||||
|
||||
@@ -57,10 +66,10 @@ export function useBlock({
|
||||
|
||||
isPromptOpenRef.current = true
|
||||
promptIdRef.current = Prompt.prompt({
|
||||
title,
|
||||
description,
|
||||
onConfirmText: confirmText,
|
||||
onCancelText: cancelText,
|
||||
title: resolvedTitle,
|
||||
description: resolvedDescription,
|
||||
onConfirmText: resolvedConfirmText,
|
||||
onCancelText: resolvedCancelText,
|
||||
variant,
|
||||
onConfirm: async () => {
|
||||
closePrompt()
|
||||
@@ -71,7 +80,7 @@ export function useBlock({
|
||||
blocker.reset?.()
|
||||
},
|
||||
})
|
||||
}, [blocker, cancelText, closePrompt, confirmText, description, title, variant])
|
||||
}, [blocker, closePrompt, resolvedCancelText, resolvedConfirmText, resolvedDescription, resolvedTitle, variant])
|
||||
|
||||
useEffect(() => {
|
||||
if (!when) {
|
||||
@@ -98,6 +107,6 @@ export function useBlock({
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.returnValue = beforeUnloadMessage
|
||||
event.returnValue = resolvedBeforeUnload
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FetchError } from 'ofetch'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
import { useSetAccessDenied } from '~/atoms/access-denied'
|
||||
@@ -35,6 +36,7 @@ type UseRoutePermissionArgs = {
|
||||
export function useRoutePermission({ session, isLoading }: UseRoutePermissionArgs) {
|
||||
const location = useLocation()
|
||||
const setAccessDenied = useSetAccessDenied()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
@@ -77,7 +79,7 @@ export function useRoutePermission({ session, isLoading }: UseRoutePermissionArg
|
||||
const reason =
|
||||
(error.data as { message?: string } | undefined)?.message ??
|
||||
error.response?._data?.message ??
|
||||
'您没有权限访问该页面'
|
||||
t('access-denied.default-reason')
|
||||
setAccessDenied({
|
||||
active: true,
|
||||
status: 403,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FetchError } from 'ofetch'
|
||||
|
||||
import { getI18n } from '~/i18n'
|
||||
|
||||
type FetchErrorWithPayload = FetchError<unknown> & {
|
||||
response?: {
|
||||
_data?: unknown
|
||||
@@ -49,7 +51,7 @@ function toMessage(value: unknown): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function getRequestErrorMessage(error: unknown, fallback = '请求失败,请稍后重试。'): string {
|
||||
export function getRequestErrorMessage(error: unknown, fallback?: string): string {
|
||||
if (error instanceof FetchError) {
|
||||
const payload = (error as FetchErrorWithPayload).data ?? (error as FetchErrorWithPayload).response?._data
|
||||
const payloadMessage = toMessage(payload)
|
||||
@@ -68,5 +70,5 @@ export function getRequestErrorMessage(error: unknown, fallback = '请求失败
|
||||
return genericMessage
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fallback ?? getI18n().t('errors.request.generic')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { clsxm, Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
@@ -21,6 +22,54 @@ const percentFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
const monthLabelFormatter = new Intl.DateTimeFormat('zh-CN', { month: 'short' })
|
||||
const fullMonthFormatter = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' })
|
||||
|
||||
const analyticsKeys = {
|
||||
pageTitle: 'analytics.page.title',
|
||||
pageDescription: 'analytics.page.description',
|
||||
sections: {
|
||||
upload: {
|
||||
title: 'analytics.sections.upload.title',
|
||||
description: 'analytics.sections.upload.description',
|
||||
error: 'analytics.sections.upload.error',
|
||||
empty: 'analytics.sections.upload.empty',
|
||||
total: 'analytics.sections.upload.total',
|
||||
best: 'analytics.sections.upload.best',
|
||||
current: 'analytics.sections.upload.current',
|
||||
growthEqual: 'analytics.sections.upload.growth-equal',
|
||||
firstRecord: 'analytics.sections.upload.first-record',
|
||||
compareEqual: 'analytics.sections.upload.compare-equal',
|
||||
tooltip: 'analytics.sections.upload.tooltip',
|
||||
},
|
||||
storage: {
|
||||
title: 'analytics.sections.storage.title',
|
||||
description: 'analytics.sections.storage.description',
|
||||
error: 'analytics.sections.storage.error',
|
||||
empty: 'analytics.sections.storage.empty',
|
||||
total: 'analytics.sections.storage.total',
|
||||
photos: 'analytics.sections.storage.photos',
|
||||
current: 'analytics.sections.storage.current',
|
||||
deltaEqual: 'analytics.sections.storage.delta.equal',
|
||||
deltaCompare: 'analytics.sections.storage.delta.compare',
|
||||
deltaFirst: 'analytics.sections.storage.delta.first',
|
||||
providerMeta: 'analytics.sections.storage.provider-meta',
|
||||
},
|
||||
tags: {
|
||||
title: 'analytics.sections.tags.title',
|
||||
description: 'analytics.sections.tags.description',
|
||||
error: 'analytics.sections.tags.error',
|
||||
empty: 'analytics.sections.tags.empty',
|
||||
},
|
||||
devices: {
|
||||
title: 'analytics.sections.devices.title',
|
||||
description: 'analytics.sections.devices.description',
|
||||
error: 'analytics.sections.devices.error',
|
||||
empty: 'analytics.sections.devices.empty',
|
||||
},
|
||||
},
|
||||
units: {
|
||||
photos: 'analytics.units.photos',
|
||||
},
|
||||
} as const
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B'
|
||||
@@ -104,6 +153,7 @@ function RankedListSkeleton() {
|
||||
}
|
||||
|
||||
function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
|
||||
const { t } = useTranslation()
|
||||
const maxUploads = data.reduce((max, point) => Math.max(max, point.uploads), 0)
|
||||
|
||||
return (
|
||||
@@ -122,7 +172,10 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
|
||||
className="group flex min-w-[24px] sm:min-w-[32px] flex-1 flex-col items-center gap-0.5 sm:gap-1"
|
||||
title={`${fullLabel} · ${plainNumberFormatter.format(point.uploads)} 张`}
|
||||
title={t(analyticsKeys.sections.upload.tooltip, {
|
||||
month: fullLabel,
|
||||
value: plainNumberFormatter.format(point.uploads),
|
||||
})}
|
||||
>
|
||||
<div className="relative flex h-32 sm:h-40 w-full items-end">
|
||||
<div
|
||||
@@ -143,8 +196,13 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
|
||||
}
|
||||
|
||||
function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUsage[]; totalBytes: number }) {
|
||||
const { t } = useTranslation()
|
||||
if (providers.length === 0) {
|
||||
return <div className="text-text-tertiary mt-4 sm:mt-5 text-xs sm:text-sm">暂无存储使用数据。</div>
|
||||
return (
|
||||
<div className="text-text-tertiary mt-4 sm:mt-5 text-xs sm:text-sm">
|
||||
{t(analyticsKeys.sections.storage.empty)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -165,7 +223,10 @@ function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUs
|
||||
<span className="text-text-secondary text-right">
|
||||
{formatBytes(provider.bytes)}
|
||||
<span className="text-text-tertiary ml-1 sm:ml-2 text-[11px] sm:text-xs">
|
||||
{percent}% · {provider.photoCount} 张
|
||||
{t(analyticsKeys.sections.storage.providerMeta, {
|
||||
percent,
|
||||
photoCount: plainNumberFormatter.format(provider.photoCount),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -182,9 +243,16 @@ function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUs
|
||||
)
|
||||
}
|
||||
|
||||
function RankedList({ items, emptyText }: { items: Array<{ label: string; value: number }>; emptyText: string }) {
|
||||
function RankedList({
|
||||
items,
|
||||
emptyTextKey,
|
||||
}: {
|
||||
items: Array<{ label: string; value: number }>
|
||||
emptyTextKey: I18nKeys
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (items.length === 0) {
|
||||
return <div className="text-text-tertiary mt-3 sm:mt-4 text-xs sm:text-sm">{emptyText}</div>
|
||||
return <div className="text-text-tertiary mt-3 sm:mt-4 text-xs sm:text-sm">{t(emptyTextKey)}</div>
|
||||
}
|
||||
|
||||
const maxValue = items.reduce((max, item) => Math.max(max, item.value), 0)
|
||||
@@ -246,6 +314,7 @@ function SectionPanel({
|
||||
}
|
||||
|
||||
export function DashboardAnalytics() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading, isError } = useDashboardAnalyticsQuery()
|
||||
|
||||
const uploadTrendStats = useMemo(() => {
|
||||
@@ -287,36 +356,43 @@ export function DashboardAnalytics() {
|
||||
}))
|
||||
|
||||
return (
|
||||
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
|
||||
<MainPageLayout title={t(analyticsKeys.pageTitle)} description={t(analyticsKeys.pageDescription)}>
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 md:grid-cols-2">
|
||||
<SectionPanel title="Upload Trends" description="近 12 个月的上传趋势">
|
||||
<SectionPanel
|
||||
title={t(analyticsKeys.sections.upload.title)}
|
||||
description={t(analyticsKeys.sections.upload.description)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<TrendSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-6 text-sm">无法加载上传趋势,请稍后再试。</div>
|
||||
<div className="text-red mt-6 text-sm">{t(analyticsKeys.sections.upload.error)}</div>
|
||||
) : data?.uploadTrends?.length ? (
|
||||
<>
|
||||
{uploadTrendStats ? (
|
||||
<div className="mt-4 sm:mt-5 grid gap-2 sm:gap-3 text-xs sm:text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">累计上传</span>
|
||||
<span className="text-text-secondary">{t(analyticsKeys.sections.upload.total)}</span>
|
||||
<span className="text-text font-semibold">
|
||||
{compactNumberFormatter.format(uploadTrendStats.totalUploads)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">表现最佳</span>
|
||||
<span className="text-text-secondary">{t(analyticsKeys.sections.upload.best)}</span>
|
||||
<span className="text-text font-semibold text-right">
|
||||
<span className="block sm:inline">{formatFullMonth(uploadTrendStats.bestMonth.month)}</span>
|
||||
<span className="text-text-tertiary ml-0 sm:ml-2 text-[11px] sm:text-[13px]">
|
||||
{plainNumberFormatter.format(uploadTrendStats.bestMonth.uploads)} 张
|
||||
{t(analyticsKeys.units.photos, {
|
||||
value: plainNumberFormatter.format(uploadTrendStats.bestMonth.uploads),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">本月上传</span>
|
||||
<span className="text-text-secondary">{t(analyticsKeys.sections.upload.current)}</span>
|
||||
<span className="text-text font-semibold">
|
||||
{plainNumberFormatter.format(uploadTrendStats.currentMonth.uploads)} 张
|
||||
{t(analyticsKeys.units.photos, {
|
||||
value: plainNumberFormatter.format(uploadTrendStats.currentMonth.uploads),
|
||||
})}
|
||||
{uploadTrendStats.growth !== null ? (
|
||||
<span
|
||||
className={clsxm(
|
||||
@@ -325,12 +401,14 @@ export function DashboardAnalytics() {
|
||||
)}
|
||||
>
|
||||
{uploadTrendStats.growth === 0
|
||||
? '与上月持平'
|
||||
? t(analyticsKeys.sections.upload.growthEqual)
|
||||
: `${uploadTrendStats.growth >= 0 ? '+' : ''}${percentFormatter.format(uploadTrendStats.growth)}`}
|
||||
</span>
|
||||
) : uploadTrendStats.previousMonth ? (
|
||||
<span className="text-text-tertiary ml-1 sm:ml-2 text-[11px] sm:text-[13px]">
|
||||
{uploadTrendStats.delta > 0 ? '首次出现上传记录' : '与上月持平'}
|
||||
{uploadTrendStats.delta > 0
|
||||
? t(analyticsKeys.sections.upload.firstRecord)
|
||||
: t(analyticsKeys.sections.upload.compareEqual)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -341,44 +419,52 @@ export function DashboardAnalytics() {
|
||||
<UploadTrendsChart data={data.uploadTrends} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-text-tertiary mt-4 sm:mt-6 text-xs sm:text-sm">暂无上传记录。</div>
|
||||
<div className="text-text-tertiary mt-4 sm:mt-6 text-xs sm:text-sm">
|
||||
{t(analyticsKeys.sections.upload.empty)}
|
||||
</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="Storage Usage" description="按存储提供方统计的容量占比">
|
||||
<SectionPanel
|
||||
title={t(analyticsKeys.sections.storage.title)}
|
||||
description={t(analyticsKeys.sections.storage.description)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ProvidersSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-5 text-sm">无法加载存储数据,请稍后再试。</div>
|
||||
<div className="text-red mt-5 text-sm">{t(analyticsKeys.sections.storage.error)}</div>
|
||||
) : storageUsage ? (
|
||||
(() => {
|
||||
const monthDeltaBytes = storageUsage.currentMonthBytes - storageUsage.previousMonthBytes
|
||||
let monthDeltaDescription = '与上月持平'
|
||||
let monthDeltaDescription = t(analyticsKeys.sections.storage.deltaEqual)
|
||||
|
||||
if (storageUsage.previousMonthBytes > 0) {
|
||||
if (monthDeltaBytes !== 0) {
|
||||
const prefix = monthDeltaBytes > 0 ? '+' : '-'
|
||||
monthDeltaDescription = `${prefix}${formatBytes(Math.abs(monthDeltaBytes))} 对比上月`
|
||||
const deltaValue = `${prefix}${formatBytes(Math.abs(monthDeltaBytes))}`
|
||||
monthDeltaDescription = t(analyticsKeys.sections.storage.deltaCompare, { delta: deltaValue })
|
||||
}
|
||||
} else if (storageUsage.currentMonthBytes > 0) {
|
||||
monthDeltaDescription = '首次记录'
|
||||
monthDeltaDescription = t(analyticsKeys.sections.storage.deltaFirst)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 sm:mt-5 grid gap-2 sm:gap-3 text-xs sm:text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">总占用</span>
|
||||
<span className="text-text-secondary">{t(analyticsKeys.sections.storage.total)}</span>
|
||||
<span className="text-text font-semibold text-right">{formatBytes(storageUsage.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">照片数量</span>
|
||||
<span className="text-text-secondary">{t(analyticsKeys.sections.storage.photos)}</span>
|
||||
<span className="text-text font-semibold">
|
||||
{plainNumberFormatter.format(storageUsage.totalPhotos)} 张
|
||||
{t(analyticsKeys.units.photos, {
|
||||
value: plainNumberFormatter.format(storageUsage.totalPhotos),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">本月新增</span>
|
||||
<span className="text-text-secondary">{t(analyticsKeys.sections.storage.current)}</span>
|
||||
<span className="text-text font-semibold text-right">
|
||||
<span className="block sm:inline">{formatBytes(storageUsage.currentMonthBytes)}</span>
|
||||
<span className="text-text-tertiary ml-0 sm:ml-2 text-[11px] sm:text-[13px]">
|
||||
@@ -393,27 +479,33 @@ export function DashboardAnalytics() {
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<div className="text-text-tertiary mt-5 text-sm">暂无存储使用数据。</div>
|
||||
<div className="text-text-tertiary mt-5 text-sm">{t(analyticsKeys.sections.storage.empty)}</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="Popular Tags" description="最近上传中最常使用的标签">
|
||||
<SectionPanel
|
||||
title={t(analyticsKeys.sections.tags.title)}
|
||||
description={t(analyticsKeys.sections.tags.description)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RankedListSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-4 text-sm">无法加载标签数据,请稍后再试。</div>
|
||||
<div className="text-red mt-4 text-sm">{t(analyticsKeys.sections.tags.error)}</div>
|
||||
) : (
|
||||
<RankedList items={popularTagItems ?? []} emptyText="暂无标签统计数据。" />
|
||||
<RankedList items={popularTagItems ?? []} emptyTextKey={analyticsKeys.sections.tags.empty} />
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="Top Devices" description="根据 EXIF 信息统计的热门拍摄设备">
|
||||
<SectionPanel
|
||||
title={t(analyticsKeys.sections.devices.title)}
|
||||
description={t(analyticsKeys.sections.devices.description)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RankedListSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-4 text-sm">无法加载设备数据,请稍后再试。</div>
|
||||
<div className="text-red mt-4 text-sm">{t(analyticsKeys.sections.devices.error)}</div>
|
||||
) : (
|
||||
<RankedList items={deviceItems ?? []} emptyText="暂无设备统计数据。" />
|
||||
<RankedList items={deviceItems ?? []} emptyTextKey={analyticsKeys.sections.devices.empty} />
|
||||
)}
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@afilmory/ui'
|
||||
import { cx } from '@afilmory/utils'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
@@ -19,6 +20,7 @@ export function SocialConnectionSettings() {
|
||||
const accountsQuery = useSocialAccounts()
|
||||
const linkMutation = useLinkSocialAccountMutation()
|
||||
const unlinkMutation = useUnlinkSocialAccountMutation()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const providers = providersQuery.data?.providers ?? []
|
||||
const accountsByProvider = useMemo(() => {
|
||||
@@ -33,10 +35,10 @@ export function SocialConnectionSettings() {
|
||||
const hasError = providersQuery.isError || accountsQuery.isError
|
||||
const errorMessage = useMemo(() => {
|
||||
if (providersQuery.isError && providersQuery.error) {
|
||||
return getRequestErrorMessage(providersQuery.error, '无法加载可用的 OAuth Provider')
|
||||
return getRequestErrorMessage(providersQuery.error, t('auth.social.error.providers'))
|
||||
}
|
||||
if (accountsQuery.isError && accountsQuery.error) {
|
||||
return getRequestErrorMessage(accountsQuery.error, '无法查询绑定状态')
|
||||
return getRequestErrorMessage(accountsQuery.error, t('auth.social.error.accounts'))
|
||||
}
|
||||
return null
|
||||
}, [accountsQuery.error, accountsQuery.isError, providersQuery.error, providersQuery.isError])
|
||||
@@ -64,8 +66,8 @@ export function SocialConnectionSettings() {
|
||||
window.open(result.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`无法开启 ${providerName} 绑定`, {
|
||||
description: getRequestErrorMessage(error, '请稍后再试'),
|
||||
toast.error(t('auth.social.toast.connect-failure', { provider: providerName }), {
|
||||
description: getRequestErrorMessage(error, t('common.retry-later')),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -76,10 +78,10 @@ export function SocialConnectionSettings() {
|
||||
async (providerId: string, providerName: string, accountId?: string) => {
|
||||
try {
|
||||
await unlinkMutation.mutateAsync({ providerId, accountId })
|
||||
toast.success(`已解除与 ${providerName} 的绑定`)
|
||||
toast.success(t('auth.social.toast.disconnect-success', { provider: providerName }))
|
||||
} catch (error) {
|
||||
toast.error('解绑失败', {
|
||||
description: getRequestErrorMessage(error, '请稍后再试'),
|
||||
toast.error(t('auth.social.toast.disconnect-failure'), {
|
||||
description: getRequestErrorMessage(error, t('common.retry-later')),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -116,10 +118,8 @@ export function SocialConnectionSettings() {
|
||||
return (
|
||||
<LinearBorderPanel className="p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-2 sm:gap-3">
|
||||
<p className="text-sm sm:text-base font-semibold">未配置可用的 OAuth Provider</p>
|
||||
<p className="text-text-tertiary text-xs sm:text-sm">
|
||||
超级管理员尚未在系统设置中启用任何第三方登录方式,当前租户无法执行 OAuth 绑定。
|
||||
</p>
|
||||
<p className="text-sm sm:text-base font-semibold">{t('auth.social.empty.title')}</p>
|
||||
<p className="text-text-tertiary text-xs sm:text-sm">{t('auth.social.empty.description')}</p>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
@@ -129,11 +129,11 @@ export function SocialConnectionSettings() {
|
||||
<LinearBorderPanel className="p-4 sm:p-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<p className="text-text-tertiary text-xs sm:text-sm font-semibold tracking-wide uppercase">登录方式</p>
|
||||
<h2 className="mt-1 text-xl sm:text-2xl font-semibold">OAuth 账号绑定</h2>
|
||||
<p className="text-text-tertiary mt-1.5 sm:mt-2 text-xs sm:text-sm">
|
||||
绑定后即可使用对应平台的账号快速登录后台,并同步基础资料。解除绑定不会删除原有后台账号。
|
||||
<p className="text-text-tertiary text-xs sm:text-sm font-semibold tracking-wide uppercase">
|
||||
{t('auth.social.section.label')}
|
||||
</p>
|
||||
<h2 className="mt-1 text-xl sm:text-2xl font-semibold">{t('auth.social.section.title')}</h2>
|
||||
<p className="text-text-tertiary mt-1.5 sm:mt-2 text-xs sm:text-sm">{t('auth.social.section.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
@@ -156,11 +156,13 @@ export function SocialConnectionSettings() {
|
||||
<p className="text-sm sm:text-base leading-tight font-semibold">{provider.name}</p>
|
||||
{linkedAccount ? (
|
||||
<p className="text-text-tertiary mt-0.5 sm:mt-1 text-[11px] sm:text-xs">
|
||||
已绑定 · {formatTimestamp(linkedAccount.createdAt)}
|
||||
{t('auth.social.provider.connected', {
|
||||
time: formatTimestamp(linkedAccount.createdAt, i18n.language),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-text-tertiary mt-0.5 sm:mt-1 text-[11px] sm:text-xs">
|
||||
尚未绑定,点击下方按钮完成授权。
|
||||
{t('auth.social.provider.unconnected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,14 +176,16 @@ export function SocialConnectionSettings() {
|
||||
size="sm"
|
||||
disabled={isUnlinking || isLastLinkedProvider}
|
||||
isLoading={isUnlinking}
|
||||
loadingText="解绑中…"
|
||||
loadingText={t('auth.social.provider.disconnecting')}
|
||||
onClick={() => handleDisconnect(provider.id, provider.name, linkedAccount.accountId)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
解除绑定
|
||||
{t('auth.social.provider.disconnect')}
|
||||
</Button>
|
||||
{isLastLinkedProvider ? (
|
||||
<p className="text-text-tertiary text-[11px] sm:text-xs">需要至少保留一个已绑定的登录方式。</p>
|
||||
<p className="text-text-tertiary text-[11px] sm:text-xs">
|
||||
{t('auth.social.provider.last-warning')}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
@@ -191,11 +195,11 @@ export function SocialConnectionSettings() {
|
||||
size="sm"
|
||||
disabled={isLinking}
|
||||
isLoading={isLinking}
|
||||
loadingText="跳转中…"
|
||||
loadingText={t('auth.social.provider.connecting')}
|
||||
onClick={() => handleConnect(provider.id, provider.name)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
绑定 {provider.name}
|
||||
{t('auth.social.provider.connect', { provider: provider.name })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -208,13 +212,13 @@ export function SocialConnectionSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
function formatTimestamp(value: string, locale: string | undefined): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
return new Intl.DateTimeFormat(locale ?? undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
|
||||
@@ -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'
|
||||
@@ -198,6 +199,7 @@ export function BuilderSettingsForm() {
|
||||
const { data, isLoading, isError, error } = useBuilderSettingsUiSchemaQuery()
|
||||
const updateMutation = useUpdateBuilderSettingsMutation()
|
||||
const { setHeaderActionState } = useMainPageLayout()
|
||||
const { t } = useTranslation()
|
||||
const formId = useId()
|
||||
|
||||
const [formState, setFormState] = useState<BuilderSettingsFormState>({} as BuilderSettingsFormState)
|
||||
@@ -240,19 +242,22 @@ export function BuilderSettingsForm() {
|
||||
|
||||
const mutationMessage = useMemo(() => {
|
||||
if (updateMutation.isError) {
|
||||
const reason = updateMutation.error instanceof Error ? updateMutation.error.message : '保存失败'
|
||||
return `保存失败:${reason}`
|
||||
const reason =
|
||||
updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: t('builder-settings.message.unknown-error')
|
||||
return t('builder-settings.message.error', { reason })
|
||||
}
|
||||
if (updateMutation.isPending) {
|
||||
return '正在保存构建器设置…'
|
||||
return t('builder-settings.message.saving')
|
||||
}
|
||||
if (!dirty && updateMutation.isSuccess) {
|
||||
return '保存成功,配置已更新'
|
||||
return t('builder-settings.message.saved')
|
||||
}
|
||||
if (dirty) {
|
||||
return '您有尚未保存的更改'
|
||||
return t('builder-settings.message.dirty')
|
||||
}
|
||||
return '所有设置已同步'
|
||||
return t('builder-settings.message.idle')
|
||||
}, [dirty, updateMutation.error, updateMutation.isError, updateMutation.isPending, updateMutation.isSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -277,11 +282,11 @@ export function BuilderSettingsForm() {
|
||||
form={formId}
|
||||
disabled={!dirty}
|
||||
isLoading={updateMutation.isPending}
|
||||
loadingText="保存中…"
|
||||
loadingText={t('builder-settings.button.loading')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
保存修改
|
||||
{t('builder-settings.button.save')}
|
||||
</Button>
|
||||
</MainPageLayout.Actions>
|
||||
)
|
||||
@@ -311,7 +316,11 @@ export function BuilderSettingsForm() {
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="text-red flex items-center gap-2 text-sm">
|
||||
<i className="i-mingcute-close-circle-fill text-lg" />
|
||||
<span>{`无法加载构建器设置:${error instanceof Error ? error.message : '未知错误'}`}</span>
|
||||
<span>
|
||||
{t('builder-settings.error.loading', {
|
||||
reason: error instanceof Error ? error.message : t('builder-settings.message.unknown-error'),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LinearDivider } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { m } from 'motion/react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
@@ -8,29 +11,64 @@ import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { useDashboardOverviewQuery } from '../hooks'
|
||||
import type { DashboardRecentActivityItem } from '../types'
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const plainNumberFormatter = new Intl.NumberFormat('zh-CN')
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' })
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
function formatCompactNumber(value: number) {
|
||||
if (!Number.isFinite(value)) return '--'
|
||||
if (value === 0) return '0'
|
||||
return compactNumberFormatter.format(value)
|
||||
const overviewI18nKeys = {
|
||||
pageTitle: 'dashboard.overview.page.title',
|
||||
pageDescription: 'dashboard.overview.page.description',
|
||||
timeUnknown: 'dashboard.overview.time.unknown',
|
||||
activityEmpty: 'dashboard.overview.activity.empty',
|
||||
activityNoPreview: 'dashboard.overview.activity.no-preview',
|
||||
activityUploadedAt: 'dashboard.overview.activity.uploaded-at',
|
||||
activityTakenAt: 'dashboard.overview.activity.taken-at',
|
||||
activitySizeUnknown: 'dashboard.overview.activity.size-unknown',
|
||||
activityIdLabel: 'dashboard.overview.activity.id-label',
|
||||
activitySubtitleWithCount: 'dashboard.overview.activity.subtitle',
|
||||
activitySubtitleEmpty: 'dashboard.overview.activity.subtitle-empty',
|
||||
activityError: 'dashboard.overview.activity.error',
|
||||
stats: {
|
||||
totalLabel: 'dashboard.overview.stats.total.label',
|
||||
totalHelper: 'dashboard.overview.stats.total.helper',
|
||||
storageLabel: 'dashboard.overview.stats.storage.label',
|
||||
storageHelperWithPhotos: 'dashboard.overview.stats.storage.helper.with-photos',
|
||||
storageHelperEmpty: 'dashboard.overview.stats.storage.helper.empty',
|
||||
monthLabel: 'dashboard.overview.stats.month.label',
|
||||
monthEqual: 'dashboard.overview.stats.month.helper.equal',
|
||||
monthFirst: 'dashboard.overview.stats.month.helper.first',
|
||||
monthMore: 'dashboard.overview.stats.month.helper.more',
|
||||
monthLess: 'dashboard.overview.stats.month.helper.less',
|
||||
syncLabel: 'dashboard.overview.stats.sync.label',
|
||||
syncHelper: 'dashboard.overview.stats.sync.helper',
|
||||
syncHelperEmpty: 'dashboard.overview.stats.sync.helper-empty',
|
||||
},
|
||||
sectionActivityTitle: 'dashboard.overview.section.activity.title',
|
||||
} as const satisfies {
|
||||
pageTitle: I18nKeys
|
||||
pageDescription: I18nKeys
|
||||
timeUnknown: I18nKeys
|
||||
activityEmpty: I18nKeys
|
||||
activityNoPreview: I18nKeys
|
||||
activityUploadedAt: I18nKeys
|
||||
activityTakenAt: I18nKeys
|
||||
activitySizeUnknown: I18nKeys
|
||||
activityIdLabel: I18nKeys
|
||||
activitySubtitleWithCount: I18nKeys
|
||||
activitySubtitleEmpty: I18nKeys
|
||||
activityError: I18nKeys
|
||||
stats: {
|
||||
totalLabel: I18nKeys
|
||||
totalHelper: I18nKeys
|
||||
storageLabel: I18nKeys
|
||||
storageHelperWithPhotos: I18nKeys
|
||||
storageHelperEmpty: I18nKeys
|
||||
monthLabel: I18nKeys
|
||||
monthEqual: I18nKeys
|
||||
monthFirst: I18nKeys
|
||||
monthMore: I18nKeys
|
||||
monthLess: I18nKeys
|
||||
syncLabel: I18nKeys
|
||||
syncHelper: I18nKeys
|
||||
syncHelperEmpty: I18nKeys
|
||||
}
|
||||
sectionActivityTitle: I18nKeys
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
@@ -65,53 +103,82 @@ const timeDivisions: TimeDivision[] = [
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
|
||||
]
|
||||
|
||||
function formatRelativeTime(iso: string | null | undefined) {
|
||||
if (!iso) return '时间未知'
|
||||
type NumberFormatters = {
|
||||
compact: Intl.NumberFormat
|
||||
plain: Intl.NumberFormat
|
||||
percent: Intl.NumberFormat
|
||||
relative: Intl.RelativeTimeFormat
|
||||
dateTime: Intl.DateTimeFormat
|
||||
}
|
||||
|
||||
function createFormatters(locale: string): NumberFormatters {
|
||||
return {
|
||||
compact: new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }),
|
||||
plain: new Intl.NumberFormat(locale),
|
||||
percent: new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 }),
|
||||
relative: new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }),
|
||||
dateTime: new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
|
||||
}
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number, formatter: Intl.NumberFormat) {
|
||||
if (!Number.isFinite(value)) return '--'
|
||||
if (value === 0) return '0'
|
||||
return formatter.format(value)
|
||||
}
|
||||
|
||||
function formatRelativeTimeValue(
|
||||
iso: string | null | undefined,
|
||||
formatter: Intl.RelativeTimeFormat,
|
||||
dateFormatter: Intl.DateTimeFormat,
|
||||
fallback: string,
|
||||
) {
|
||||
if (!iso) return fallback
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '时间未知'
|
||||
return fallback
|
||||
}
|
||||
|
||||
let diffInSeconds = (date.getTime() - Date.now()) / 1000
|
||||
for (const division of timeDivisions) {
|
||||
if (Math.abs(diffInSeconds) < division.amount) {
|
||||
return relativeTimeFormatter.format(Math.round(diffInSeconds), division.unit)
|
||||
return formatter.format(Math.round(diffInSeconds), division.unit)
|
||||
}
|
||||
diffInSeconds /= division.amount
|
||||
}
|
||||
|
||||
return dateTimeFormatter.format(date)
|
||||
return dateFormatter.format(date)
|
||||
}
|
||||
|
||||
function formatTakenAt(iso: string | null) {
|
||||
function formatTakenAtValue(iso: string | null, dateFormatter: Intl.DateTimeFormat) {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return dateTimeFormatter.format(date)
|
||||
return dateFormatter.format(date)
|
||||
}
|
||||
|
||||
const STATUS_META = {
|
||||
synced: {
|
||||
label: '已同步',
|
||||
labelKey: 'dashboard.overview.status.synced',
|
||||
barClass: 'bg-emerald-400/80',
|
||||
dotClass: 'bg-emerald-400/90',
|
||||
badgeClass: 'bg-emerald-500/10 text-emerald-300',
|
||||
},
|
||||
pending: {
|
||||
label: '处理中',
|
||||
labelKey: 'dashboard.overview.status.pending',
|
||||
barClass: 'bg-orange-400/80',
|
||||
dotClass: 'bg-orange-400/90',
|
||||
badgeClass: 'bg-orange-500/10 text-orange-300',
|
||||
},
|
||||
conflict: {
|
||||
label: '需关注',
|
||||
labelKey: 'dashboard.overview.status.conflict',
|
||||
barClass: 'bg-red-500/80',
|
||||
dotClass: 'bg-red-500/90',
|
||||
badgeClass: 'bg-red-500/10 text-red-300',
|
||||
},
|
||||
} satisfies Record<
|
||||
DashboardRecentActivityItem['syncStatus'],
|
||||
{ label: string; barClass: string; dotClass: string; badgeClass: string }
|
||||
{ labelKey: I18nKeys; barClass: string; dotClass: string; badgeClass: string }
|
||||
>
|
||||
|
||||
const EMPTY_STATS = {
|
||||
@@ -153,9 +220,17 @@ function StatSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
|
||||
type ActivityListProps = {
|
||||
items: DashboardRecentActivityItem[]
|
||||
formatRelativeTime: (iso: string | null | undefined) => string
|
||||
formatTakenAt: (iso: string | null) => string | null
|
||||
formatBytesLabel: (bytes: number) => string
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
function ActivityList({ items, formatRelativeTime, formatTakenAt, formatBytesLabel, t }: ActivityListProps) {
|
||||
if (items.length === 0) {
|
||||
return <div className="text-text-tertiary mt-5 text-sm">暂无最近活动,上传照片后即可看到这里的动态。</div>
|
||||
return <div className="text-text-tertiary mt-5 text-sm">{t(overviewI18nKeys.activityEmpty)}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -179,28 +254,32 @@ function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
|
||||
<img src={item.previewUrl} alt={item.title} className="size-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="text-text-tertiary flex size-full items-center justify-center text-[10px]">
|
||||
No Preview
|
||||
{t(overviewI18nKeys.activityNoPreview)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1 sm:space-y-1.5">
|
||||
<div className="text-text truncate text-xs sm:text-sm font-semibold">{item.title}</div>
|
||||
<div className="text-text-tertiary text-[11px] sm:text-xs leading-relaxed">
|
||||
<span>上传于 {formatRelativeTime(item.createdAt)}</span>
|
||||
<span>{t(overviewI18nKeys.activityUploadedAt, { time: formatRelativeTime(item.createdAt) })}</span>
|
||||
{takenAtText ? (
|
||||
<>
|
||||
<span className="mx-1.5">•</span>
|
||||
<span>拍摄时间 {takenAtText}</span>
|
||||
<span>{t(overviewI18nKeys.activityTakenAt, { time: takenAtText })}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-text-secondary flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span>{item.size != null && item.size > 0 ? formatBytes(item.size) : '大小未知'}</span>
|
||||
<span>
|
||||
{item.size != null && item.size > 0
|
||||
? formatBytesLabel(item.size)
|
||||
: t(overviewI18nKeys.activitySizeUnknown)}
|
||||
</span>
|
||||
<span className="text-text-tertiary">•</span>
|
||||
<span>{item.storageProvider}</span>
|
||||
<span className="text-text-tertiary">•</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] ${statusMeta.badgeClass}`}>
|
||||
{statusMeta.label}
|
||||
{t(statusMeta.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
{item.tags.length > 0 ? (
|
||||
@@ -215,7 +294,7 @@ function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-text-tertiary min-w-0 truncate text-right text-[11px] sm:text-right">
|
||||
ID:
|
||||
{t(overviewI18nKeys.activityIdLabel)}
|
||||
<span className="ml-1 truncate">{item.photoId}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,55 +308,80 @@ function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
|
||||
}
|
||||
|
||||
export function DashboardOverview() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { data, isLoading, isError } = useDashboardOverviewQuery()
|
||||
const locale = i18n.language ?? 'en'
|
||||
const formatters = useMemo(() => createFormatters(locale), [locale])
|
||||
const formatCompact = useCallback((value: number) => formatCompactNumber(value, formatters.compact), [formatters])
|
||||
const formatPlain = useCallback((value: number) => formatters.plain.format(value), [formatters])
|
||||
const formatPercentValue = useCallback(
|
||||
(value: number | null) => (value === null ? '--' : formatters.percent.format(value)),
|
||||
[formatters],
|
||||
)
|
||||
const formatRelativeTime = useCallback(
|
||||
(iso: string | null | undefined) =>
|
||||
formatRelativeTimeValue(iso, formatters.relative, formatters.dateTime, t(overviewI18nKeys.timeUnknown)),
|
||||
[formatters, t],
|
||||
)
|
||||
const formatTakenAt = useCallback((iso: string | null) => formatTakenAtValue(iso, formatters.dateTime), [formatters])
|
||||
|
||||
const stats = data?.stats ?? EMPTY_STATS
|
||||
const statusTotal = stats.sync.synced + stats.sync.pending + stats.sync.conflicts
|
||||
const syncCompletion = statusTotal === 0 ? null : stats.sync.synced / statusTotal
|
||||
|
||||
const monthlyDelta = stats.thisMonthUploads - stats.previousMonthUploads
|
||||
let monthlyTrendDescription = '与上月持平'
|
||||
if (stats.previousMonthUploads === 0) {
|
||||
monthlyTrendDescription = stats.thisMonthUploads === 0 ? '与上月持平' : '首次出现上传记录'
|
||||
} else if (monthlyDelta > 0) {
|
||||
monthlyTrendDescription = `比上月多 ${plainNumberFormatter.format(monthlyDelta)} 张`
|
||||
} else if (monthlyDelta < 0) {
|
||||
monthlyTrendDescription = `比上月少 ${plainNumberFormatter.format(Math.abs(monthlyDelta))} 张`
|
||||
}
|
||||
const monthlyTrendDescription = useMemo(() => {
|
||||
if (stats.previousMonthUploads === 0) {
|
||||
return stats.thisMonthUploads === 0 ? t(overviewI18nKeys.stats.monthEqual) : t(overviewI18nKeys.stats.monthFirst)
|
||||
}
|
||||
if (monthlyDelta > 0) {
|
||||
return t(overviewI18nKeys.stats.monthMore, { difference: formatPlain(monthlyDelta) })
|
||||
}
|
||||
if (monthlyDelta < 0) {
|
||||
return t(overviewI18nKeys.stats.monthLess, { difference: formatPlain(Math.abs(monthlyDelta)) })
|
||||
}
|
||||
return t(overviewI18nKeys.stats.monthEqual)
|
||||
}, [formatPlain, monthlyDelta, stats.previousMonthUploads, stats.thisMonthUploads, t])
|
||||
|
||||
const averageSize = stats.totalPhotos > 0 ? stats.totalStorageBytes / stats.totalPhotos : 0
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
key: 'total-photos',
|
||||
label: '照片总数',
|
||||
value: formatCompactNumber(stats.totalPhotos),
|
||||
helper: `${plainNumberFormatter.format(stats.totalPhotos)} 张照片`,
|
||||
label: t(overviewI18nKeys.stats.totalLabel),
|
||||
value: formatCompact(stats.totalPhotos),
|
||||
helper: t(overviewI18nKeys.stats.totalHelper, { value: formatPlain(stats.totalPhotos) }),
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
label: '占用存储',
|
||||
label: t(overviewI18nKeys.stats.storageLabel),
|
||||
value: formatBytes(stats.totalStorageBytes),
|
||||
helper: stats.totalPhotos > 0 ? `平均每张 ${formatBytes(averageSize || 0)}` : '暂无照片,存储占用为 0',
|
||||
helper:
|
||||
stats.totalPhotos > 0
|
||||
? t(overviewI18nKeys.stats.storageHelperWithPhotos, { average: formatBytes(averageSize || 0) })
|
||||
: t(overviewI18nKeys.stats.storageHelperEmpty),
|
||||
},
|
||||
{
|
||||
key: 'this-month',
|
||||
label: '本月新增',
|
||||
value: formatCompactNumber(stats.thisMonthUploads),
|
||||
label: t(overviewI18nKeys.stats.monthLabel),
|
||||
value: formatCompact(stats.thisMonthUploads),
|
||||
helper: monthlyTrendDescription,
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
label: '同步完成率',
|
||||
value: syncCompletion === null ? '--' : percentFormatter.format(syncCompletion),
|
||||
label: t(overviewI18nKeys.stats.syncLabel),
|
||||
value: formatPercentValue(syncCompletion),
|
||||
helper: statusTotal
|
||||
? `待处理 ${plainNumberFormatter.format(stats.sync.pending)} | 冲突 ${plainNumberFormatter.format(stats.sync.conflicts)}`
|
||||
: '暂无同步任务',
|
||||
? t(overviewI18nKeys.stats.syncHelper, {
|
||||
pending: formatPlain(stats.sync.pending),
|
||||
conflicts: formatPlain(stats.sync.conflicts),
|
||||
})
|
||||
: t(overviewI18nKeys.stats.syncHelperEmpty),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<MainPageLayout title="Dashboard" description="掌握图库运行状态与最近同步活动">
|
||||
<MainPageLayout title={t(overviewI18nKeys.pageTitle)} description={t(overviewI18nKeys.pageDescription)}>
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<div className="grid gap-3 sm:gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{isLoading
|
||||
@@ -305,11 +409,11 @@ export function DashboardOverview() {
|
||||
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-4 sm:px-5 py-4 sm:py-5">
|
||||
<div className="space-y-1 sm:space-y-1.5">
|
||||
<h2 className="text-text text-sm sm:text-base font-semibold">最近活动</h2>
|
||||
<h2 className="text-text text-sm sm:text-base font-semibold">{t(overviewI18nKeys.sectionActivityTitle)}</h2>
|
||||
<p className="text-text-tertiary text-xs sm:text-sm leading-relaxed">
|
||||
{data?.recentActivity?.length
|
||||
? `展示最近 ${data.recentActivity.length} 次上传和同步记录`
|
||||
: '还没有任何上传,快来添加第一张照片吧~'}
|
||||
? t(overviewI18nKeys.activitySubtitleWithCount, { count: data.recentActivity.length })
|
||||
: t(overviewI18nKeys.activitySubtitleEmpty)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -320,9 +424,15 @@ export function DashboardOverview() {
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="mt-5 text-sm text-red-400">无法获取活动数据,请稍后再试。</div>
|
||||
<div className="mt-5 text-sm text-red-400">{t(overviewI18nKeys.activityError)}</div>
|
||||
) : (
|
||||
<ActivityList items={data?.recentActivity ?? []} />
|
||||
<ActivityList
|
||||
items={data?.recentActivity ?? []}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
formatTakenAt={formatTakenAt}
|
||||
formatBytesLabel={formatBytes}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</LinearBorderPanel>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, Prompt } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
@@ -16,15 +17,133 @@ const SUMMARY_PLACEHOLDER = {
|
||||
}
|
||||
|
||||
const SUMMARY_STATS = [
|
||||
{ id: 'total', label: '总记录', accent: 'text-text', chip: '全部' },
|
||||
{ id: 'synced', label: '已同步', accent: 'text-emerald-300', chip: '正常' },
|
||||
{ id: 'pending', label: '待同步', accent: 'text-amber-300', chip: '排队中' },
|
||||
{ id: 'conflicts', label: '冲突', accent: 'text-rose-300', chip: '需处理' },
|
||||
] as const
|
||||
{
|
||||
id: 'total',
|
||||
labelKey: 'data-management.summary.stats.total.label',
|
||||
chipKey: 'data-management.summary.stats.total.chip',
|
||||
accent: 'text-text',
|
||||
},
|
||||
{
|
||||
id: 'synced',
|
||||
labelKey: 'data-management.summary.stats.synced.label',
|
||||
chipKey: 'data-management.summary.stats.synced.chip',
|
||||
accent: 'text-emerald-300',
|
||||
},
|
||||
{
|
||||
id: 'pending',
|
||||
labelKey: 'data-management.summary.stats.pending.label',
|
||||
chipKey: 'data-management.summary.stats.pending.chip',
|
||||
accent: 'text-amber-300',
|
||||
},
|
||||
{
|
||||
id: 'conflicts',
|
||||
labelKey: 'data-management.summary.stats.conflicts.label',
|
||||
chipKey: 'data-management.summary.stats.conflicts.chip',
|
||||
accent: 'text-rose-300',
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
id: keyof typeof SUMMARY_PLACEHOLDER
|
||||
labelKey: I18nKeys
|
||||
chipKey: I18nKeys
|
||||
accent: string
|
||||
}[]
|
||||
|
||||
const dataManagementKeys = {
|
||||
summary: {
|
||||
badge: 'data-management.summary.badge',
|
||||
title: 'data-management.summary.title',
|
||||
description: 'data-management.summary.description',
|
||||
error: 'data-management.summary.error',
|
||||
},
|
||||
truncate: {
|
||||
badge: 'data-management.truncate.badge',
|
||||
title: 'data-management.truncate.title',
|
||||
description: 'data-management.truncate.description',
|
||||
note: 'data-management.truncate.note',
|
||||
button: 'data-management.truncate.button',
|
||||
loading: 'data-management.truncate.loading',
|
||||
prompt: {
|
||||
title: 'data-management.truncate.prompt.title',
|
||||
description: 'data-management.truncate.prompt.description',
|
||||
confirm: 'data-management.truncate.prompt.confirm',
|
||||
cancel: 'data-management.truncate.prompt.cancel',
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
badge: 'data-management.delete.badge',
|
||||
title: 'data-management.delete.title',
|
||||
description: 'data-management.delete.description',
|
||||
note: 'data-management.delete.note',
|
||||
button: 'data-management.delete.button',
|
||||
loading: 'data-management.delete.loading',
|
||||
promptInitial: {
|
||||
title: 'data-management.delete.step1.title',
|
||||
description: 'data-management.delete.step1.description',
|
||||
confirm: 'data-management.delete.step1.confirm',
|
||||
cancel: 'data-management.delete.step1.cancel',
|
||||
},
|
||||
promptConfirm: {
|
||||
title: 'data-management.delete.step2.title',
|
||||
description: 'data-management.delete.step2.description',
|
||||
confirm: 'data-management.delete.step2.confirm',
|
||||
cancel: 'data-management.delete.step2.cancel',
|
||||
},
|
||||
promptFinal: {
|
||||
title: 'data-management.delete.step3.title',
|
||||
description: 'data-management.delete.step3.description',
|
||||
placeholder: 'data-management.delete.step3.placeholder',
|
||||
confirm: 'data-management.delete.step3.confirm',
|
||||
cancel: 'data-management.delete.step3.cancel',
|
||||
errorTitle: 'data-management.delete.step3.error.title',
|
||||
errorDescription: 'data-management.delete.step3.error.description',
|
||||
},
|
||||
},
|
||||
} as const satisfies {
|
||||
summary: {
|
||||
badge: I18nKeys
|
||||
title: I18nKeys
|
||||
description: I18nKeys
|
||||
error: I18nKeys
|
||||
}
|
||||
truncate: {
|
||||
badge: I18nKeys
|
||||
title: I18nKeys
|
||||
description: I18nKeys
|
||||
note: I18nKeys
|
||||
button: I18nKeys
|
||||
loading: I18nKeys
|
||||
prompt: {
|
||||
title: I18nKeys
|
||||
description: I18nKeys
|
||||
confirm: I18nKeys
|
||||
cancel: I18nKeys
|
||||
}
|
||||
}
|
||||
delete: {
|
||||
badge: I18nKeys
|
||||
title: I18nKeys
|
||||
description: I18nKeys
|
||||
note: I18nKeys
|
||||
button: I18nKeys
|
||||
loading: I18nKeys
|
||||
promptInitial: { title: I18nKeys; description: I18nKeys; confirm: I18nKeys; cancel: I18nKeys }
|
||||
promptConfirm: { title: I18nKeys; description: I18nKeys; confirm: I18nKeys; cancel: I18nKeys }
|
||||
promptFinal: {
|
||||
title: I18nKeys
|
||||
description: I18nKeys
|
||||
placeholder: I18nKeys
|
||||
confirm: I18nKeys
|
||||
cancel: I18nKeys
|
||||
errorTitle: I18nKeys
|
||||
errorDescription: I18nKeys
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('zh-CN')
|
||||
|
||||
export function DataManagementPanel() {
|
||||
const { t } = useTranslation()
|
||||
const summaryQuery = usePhotoAssetSummaryQuery()
|
||||
const summary = summaryQuery.data ?? SUMMARY_PLACEHOLDER
|
||||
const truncateMutation = useTruncatePhotoAssetsMutation()
|
||||
@@ -36,11 +155,11 @@ export function DataManagementPanel() {
|
||||
}
|
||||
|
||||
Prompt.prompt({
|
||||
title: '确认清空照片数据表?',
|
||||
description: '该操作会删除数据库中的所有照片记录,但会保留对象存储中的原始文件。清空后需要重新执行一次照片同步。',
|
||||
title: t(dataManagementKeys.truncate.prompt.title),
|
||||
description: t(dataManagementKeys.truncate.prompt.description),
|
||||
variant: 'danger',
|
||||
onConfirmText: '立即清空',
|
||||
onCancelText: '取消',
|
||||
onConfirmText: t(dataManagementKeys.truncate.prompt.confirm),
|
||||
onCancelText: t(dataManagementKeys.truncate.prompt.cancel),
|
||||
onConfirm: () => truncateMutation.mutateAsync().then(() => {}),
|
||||
})
|
||||
}
|
||||
@@ -52,16 +171,18 @@ export function DataManagementPanel() {
|
||||
|
||||
const launchFinalConfirm = () => {
|
||||
Prompt.input({
|
||||
title: '最终确认:永久删除账户',
|
||||
description: '请输入 DELETE 以确认。本操作会立即注销所有成员并删除不可恢复的数据。',
|
||||
placeholder: 'DELETE',
|
||||
title: t(dataManagementKeys.delete.promptFinal.title),
|
||||
description: t(dataManagementKeys.delete.promptFinal.description),
|
||||
placeholder: t(dataManagementKeys.delete.promptFinal.placeholder),
|
||||
variant: 'danger',
|
||||
onConfirmText: '永久删除',
|
||||
onCancelText: '返回',
|
||||
onConfirmText: t(dataManagementKeys.delete.promptFinal.confirm),
|
||||
onCancelText: t(dataManagementKeys.delete.promptFinal.cancel),
|
||||
onConfirm: async (value) => {
|
||||
const normalized = value.trim().toUpperCase()
|
||||
if (normalized !== 'DELETE') {
|
||||
toast.error('确认失败', { description: '请输入 DELETE 以继续。' })
|
||||
toast.error(t(dataManagementKeys.delete.promptFinal.errorTitle), {
|
||||
description: t(dataManagementKeys.delete.promptFinal.errorDescription),
|
||||
})
|
||||
launchFinalConfirm()
|
||||
return
|
||||
}
|
||||
@@ -75,21 +196,21 @@ export function DataManagementPanel() {
|
||||
|
||||
const confirmIrreversibleStep = () => {
|
||||
Prompt.prompt({
|
||||
title: '二次确认:删除整个账户',
|
||||
description: '将彻底清除当前租户的照片、设置、同步记录以及所有成员权限,且无法撤销。',
|
||||
title: t(dataManagementKeys.delete.promptConfirm.title),
|
||||
description: t(dataManagementKeys.delete.promptConfirm.description),
|
||||
variant: 'danger',
|
||||
onConfirmText: '我已知晓风险',
|
||||
onCancelText: '取消',
|
||||
onConfirmText: t(dataManagementKeys.delete.promptConfirm.confirm),
|
||||
onCancelText: t(dataManagementKeys.delete.promptConfirm.cancel),
|
||||
onConfirm: launchFinalConfirm,
|
||||
})
|
||||
}
|
||||
|
||||
Prompt.prompt({
|
||||
title: '删除账户(步骤 1/3)',
|
||||
description: '删除后会立即清空当前租户下的所有数据并登出所有成员。此过程包含 3 次确认以确保安全。',
|
||||
title: t(dataManagementKeys.delete.promptInitial.title),
|
||||
description: t(dataManagementKeys.delete.promptInitial.description),
|
||||
variant: 'danger',
|
||||
onConfirmText: '继续下一步',
|
||||
onCancelText: '取消',
|
||||
onConfirmText: t(dataManagementKeys.delete.promptInitial.confirm),
|
||||
onCancelText: t(dataManagementKeys.delete.promptInitial.cancel),
|
||||
onConfirm: confirmIrreversibleStep,
|
||||
})
|
||||
}
|
||||
@@ -101,16 +222,14 @@ export function DataManagementPanel() {
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<span className="shape-squircle inline-flex items-center gap-2 bg-accent/10 px-2.5 sm:px-3 py-1 text-[11px] sm:text-xs font-medium text-accent">
|
||||
<DynamicIcon name="database" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
|
||||
当前数据概况
|
||||
{t(dataManagementKeys.summary.badge)}
|
||||
</span>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-text text-lg sm:text-xl font-semibold">照片数据表状态</h3>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">
|
||||
以下统计来自数据库记录,不含对象存储中的原始文件。
|
||||
</p>
|
||||
<h3 className="text-text text-lg sm:text-xl font-semibold">{t(dataManagementKeys.summary.title)}</h3>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">{t(dataManagementKeys.summary.description)}</p>
|
||||
</div>
|
||||
{summaryQuery.isError ? (
|
||||
<p className="text-red text-xs sm:text-sm">无法加载数据统计,请稍后再试。</p>
|
||||
<p className="text-red text-xs sm:text-sm">{t(dataManagementKeys.summary.error)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid w-full gap-3 sm:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -123,9 +242,9 @@ export function DataManagementPanel() {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[10px] sm:text-[11px] text-text-tertiary">
|
||||
<span>{stat.label}</span>
|
||||
<span>{t(stat.labelKey)}</span>
|
||||
<span className="shape-squircle bg-white/5 px-1.5 sm:px-2 py-0.5 font-medium text-white/80 text-[9px] sm:text-[10px]">
|
||||
{stat.chip}
|
||||
{t(stat.chipKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsxm('mt-1.5 sm:mt-2 text-xl sm:text-2xl font-semibold', stat.accent)}>
|
||||
@@ -142,13 +261,11 @@ export function DataManagementPanel() {
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 text-red">
|
||||
<DynamicIcon name="triangle-alert" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
|
||||
<span className="text-xs sm:text-sm font-semibold">危险操作</span>
|
||||
<span className="text-xs sm:text-sm font-semibold">{t(dataManagementKeys.truncate.badge)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text text-base sm:text-lg font-semibold">清空照片数据表</h4>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">
|
||||
删除数据库中的所有照片记录,仅保留对象存储文件。通常用于处理数据不一致、重新同步或迁移场景。
|
||||
</p>
|
||||
<h4 className="text-text text-base sm:text-lg font-semibold">{t(dataManagementKeys.truncate.title)}</h4>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">{t(dataManagementKeys.truncate.description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -156,16 +273,14 @@ export function DataManagementPanel() {
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={truncateMutation.isPending}
|
||||
loadingText="清理中…"
|
||||
loadingText={t(dataManagementKeys.truncate.loading)}
|
||||
onClick={handleTruncate}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
清空数据库记录
|
||||
{t(dataManagementKeys.truncate.button)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">
|
||||
操作完成后请立即重新执行「照片同步」,以便使用存储中的原始文件重建数据库与 manifest。
|
||||
</p>
|
||||
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">{t(dataManagementKeys.truncate.note)}</p>
|
||||
</LinearBorderPanel>
|
||||
|
||||
<LinearBorderPanel className="bg-red-500/5 p-4 sm:p-6">
|
||||
@@ -173,14 +288,11 @@ export function DataManagementPanel() {
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 text-red">
|
||||
<DynamicIcon name="radiation" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
|
||||
<span className="text-xs sm:text-sm font-semibold">账户清除(不可逆)</span>
|
||||
<span className="text-xs sm:text-sm font-semibold">{t(dataManagementKeys.delete.badge)}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-text text-base sm:text-lg font-semibold">删除当前租户与所有数据</h4>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">
|
||||
此操作会在数据库中彻底删除当前租户、照片记录、同步日志、权限成员等所有信息。执行后将登出所有成员并无法恢复,
|
||||
系统会强制进行三次确认以避免误操作。
|
||||
</p>
|
||||
<h4 className="text-text text-base sm:text-lg font-semibold">{t(dataManagementKeys.delete.title)}</h4>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">{t(dataManagementKeys.delete.description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -188,16 +300,14 @@ export function DataManagementPanel() {
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteAccount}
|
||||
loadingText="正在销毁…"
|
||||
loadingText={t(dataManagementKeys.delete.loading)}
|
||||
isLoading={deleteTenantMutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
永久删除账户
|
||||
{t(dataManagementKeys.delete.button)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">
|
||||
如需在未来重新使用本服务,需要重新注册新的租户并重新上传所有资产。该操作不会删除对象存储中的原始文件,但会移除与之关联的所有数据库记录。
|
||||
</p>
|
||||
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">{t(dataManagementKeys.delete.note)}</p>
|
||||
</LinearBorderPanel>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -13,12 +14,16 @@ import { deleteTenantAccount, truncatePhotoAssetRecords } from './api'
|
||||
|
||||
export function useTruncatePhotoAssetsMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: truncatePhotoAssetRecords,
|
||||
onSuccess: async (result) => {
|
||||
toast.success('数据库记录已清空', {
|
||||
description: result.deleted > 0 ? `已标记删除 ${result.deleted} 条照片记录。` : '没有可清理的数据表记录。',
|
||||
toast.success(t('data-management.truncate.success.title'), {
|
||||
description:
|
||||
result.deleted > 0
|
||||
? t('data-management.truncate.success.deleted', { count: result.deleted })
|
||||
: t('data-management.truncate.success.empty'),
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: PHOTO_ASSET_LIST_QUERY_KEY }),
|
||||
@@ -27,8 +32,8 @@ export function useTruncatePhotoAssetsMutation() {
|
||||
])
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = getRequestErrorMessage(error, '无法清空数据库记录,请稍后再试。')
|
||||
toast.error('清理失败', { description: message })
|
||||
const message = getRequestErrorMessage(error, t('data-management.truncate.error.fallback'))
|
||||
toast.error(t('data-management.truncate.error.title'), { description: message })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -36,12 +41,13 @@ export function useTruncatePhotoAssetsMutation() {
|
||||
export function useDeleteTenantAccountMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteTenantAccount,
|
||||
onSuccess: async () => {
|
||||
toast.success('账户已删除', {
|
||||
description: '已清理当前租户下的全部数据,并登出所有成员。',
|
||||
toast.success(t('data-management.delete.success.title'), {
|
||||
description: t('data-management.delete.success.description'),
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -55,8 +61,8 @@ export function useDeleteTenantAccountMutation() {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = getRequestErrorMessage(error, '删除账户失败,请稍后再试。')
|
||||
toast.error('操作失败', { description: message })
|
||||
const message = getRequestErrorMessage(error, t('data-management.delete.error.fallback'))
|
||||
toast.error(t('data-management.delete.error.title'), { description: message })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { PageTabs } from '~/components/navigation/PageTabs'
|
||||
@@ -13,18 +14,19 @@ type PhotoPageScaffoldProps = {
|
||||
}
|
||||
|
||||
export function PhotoPageScaffold({ activeTab, children }: PhotoPageScaffoldProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MainPageLayout title="照片库" description="在此同步和管理服务器中的照片资产。">
|
||||
<MainPageLayout title={t('photos.page.title')} description={t('photos.page.description')}>
|
||||
<PhotoPageActions activeTab={activeTab} />
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<PageTabs
|
||||
activeId={activeTab}
|
||||
items={[
|
||||
{ id: 'library', label: '图库管理', to: TAB_ROUTE_MAP.library, end: true },
|
||||
{ id: 'sync', label: '存储同步', to: TAB_ROUTE_MAP.sync, end: true },
|
||||
{ id: 'storage', label: '素材存储', to: TAB_ROUTE_MAP.storage, end: true },
|
||||
{ id: 'usage', label: '用量记录', to: TAB_ROUTE_MAP.usage, end: true },
|
||||
{ id: 'library', labelKey: 'photos.tabs.library', to: TAB_ROUTE_MAP.library, end: true },
|
||||
{ id: 'sync', labelKey: 'photos.tabs.sync', to: TAB_ROUTE_MAP.sync, end: true },
|
||||
{ id: 'storage', labelKey: 'photos.tabs.storage', to: TAB_ROUTE_MAP.storage, end: true },
|
||||
{ id: 'usage', labelKey: 'photos.tabs.usage', to: TAB_ROUTE_MAP.usage, end: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button, Checkbox, Prompt } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
@@ -37,6 +38,7 @@ export function PhotoSyncConflictsPanel({
|
||||
onResolveBatch,
|
||||
onRequestStorageUrl,
|
||||
}: PhotoSyncConflictsPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const sortedConflicts = useMemo(() => {
|
||||
if (!conflicts) return []
|
||||
return conflicts.toSorted((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
@@ -359,7 +361,7 @@ export function PhotoSyncConflictsPanel({
|
||||
</span>
|
||||
<code className="text-text-secondary text-xs">{conflict.photoId ?? '未绑定 Photo ID'}</code>
|
||||
{typeConfig ? (
|
||||
<span className="text-text-tertiary text-xs">{typeConfig.description}</span>
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
|
||||
import { getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
|
||||
import { getI18n } from '~/i18n'
|
||||
|
||||
import { getActionTypeMeta, getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
|
||||
import type { PhotoSyncAction, PhotoSyncLogLevel, PhotoSyncProgressStage, PhotoSyncProgressState } from '../../types'
|
||||
import { BorderOverlay } from './PhotoSyncResultPanel'
|
||||
|
||||
@@ -46,12 +48,12 @@ const LOG_LEVEL_CONFIG: Record<PhotoSyncLogLevel, { label: string; className: st
|
||||
|
||||
const SUMMARY_FIELDS: Array<{
|
||||
key: keyof PhotoSyncProgressState['summary']
|
||||
label: string
|
||||
labelKey: I18nKeys
|
||||
}> = [
|
||||
{ key: 'inserted', label: '新增' },
|
||||
{ key: 'updated', label: '更新' },
|
||||
{ key: 'conflicts', label: '冲突' },
|
||||
{ key: 'errors', label: '错误' },
|
||||
{ key: 'inserted', labelKey: PHOTO_ACTION_TYPE_CONFIG.insert.labelKey },
|
||||
{ key: 'updated', labelKey: PHOTO_ACTION_TYPE_CONFIG.update.labelKey },
|
||||
{ key: 'conflicts', labelKey: PHOTO_ACTION_TYPE_CONFIG.conflict.labelKey },
|
||||
{ key: 'errors', labelKey: PHOTO_ACTION_TYPE_CONFIG.error.labelKey },
|
||||
]
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -73,13 +75,12 @@ type PhotoSyncProgressPanelProps = {
|
||||
}
|
||||
|
||||
function formatActionLabel(action: PhotoSyncAction) {
|
||||
const config = PHOTO_ACTION_TYPE_CONFIG[action.type]
|
||||
const { label: baseLabel } = getActionTypeMeta(action.type)
|
||||
if (action.type === 'conflict' && action.conflictPayload) {
|
||||
const conflictLabel = getConflictTypeLabel(action.conflictPayload.type)
|
||||
const base = config?.label ?? '冲突'
|
||||
return `${base} · ${conflictLabel}`
|
||||
return `${baseLabel} · ${conflictLabel}`
|
||||
}
|
||||
return config?.label ?? action.type
|
||||
return baseLabel
|
||||
}
|
||||
|
||||
export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps) {
|
||||
@@ -110,8 +111,9 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = getI18n()
|
||||
const summaryItems = SUMMARY_FIELDS.map((field) => ({
|
||||
label: field.label,
|
||||
label: i18n.t(field.labelKey),
|
||||
value: progress.summary[field.key],
|
||||
}))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
|
||||
import { getActionTypeMeta, getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
|
||||
import type {
|
||||
PhotoAssetSummary,
|
||||
PhotoSyncAction,
|
||||
@@ -218,11 +218,14 @@ export function PhotoSyncResultPanel({
|
||||
label: '全部',
|
||||
count: result ? result.actions.length : 0,
|
||||
},
|
||||
...Object.entries(actionTypeConfig).map(([type, config]) => ({
|
||||
type: type as PhotoSyncAction['type'],
|
||||
label: config.label,
|
||||
count: counts[type as PhotoSyncAction['type']] ?? 0,
|
||||
})),
|
||||
...Object.entries(actionTypeConfig).map(([type]) => {
|
||||
const typed = type as PhotoSyncAction['type']
|
||||
return {
|
||||
type: typed,
|
||||
label: getActionTypeMeta(typed).label,
|
||||
count: counts[typed] ?? 0,
|
||||
}
|
||||
}),
|
||||
]
|
||||
}, [result])
|
||||
|
||||
@@ -271,7 +274,7 @@ export function PhotoSyncResultPanel({
|
||||
}
|
||||
|
||||
const renderActionDetails = (action: PhotoSyncAction) => {
|
||||
const { label, badgeClass } = actionTypeConfig[action.type]
|
||||
const { label, badgeClass } = getActionTypeMeta(action.type)
|
||||
const {
|
||||
manifestBefore: beforeManifest,
|
||||
manifestAfter: afterManifest,
|
||||
@@ -486,7 +489,7 @@ export function PhotoSyncResultPanel({
|
||||
<div className="space-y-3">
|
||||
{filteredActions.map((action, index) => {
|
||||
const actionKey = `${action.storageKey}-${action.type}-${action.photoId ?? 'none'}-${action.manifestAfter?.id ?? action.manifestBefore?.id ?? 'unknown'}`
|
||||
const { label, badgeClass } = actionTypeConfig[action.type]
|
||||
const { label, badgeClass } = getActionTypeMeta(action.type)
|
||||
const resolutionLabel =
|
||||
action.resolution === 'prefer-storage'
|
||||
? '以存储为准'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from 'react'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
|
||||
import { BILLING_USAGE_EVENT_CONFIG, getUsageEventLabel } from '../../constants'
|
||||
import { BILLING_USAGE_EVENT_CONFIG, getUsageEventDescription, getUsageEventLabel } from '../../constants'
|
||||
import type { BillingUsageEvent, BillingUsageOverview } from '../../types'
|
||||
|
||||
type PhotoUsagePanelProps = {
|
||||
@@ -31,8 +31,8 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
|
||||
][]
|
||||
).map(([eventType, config]) => ({
|
||||
eventType,
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
label: getUsageEventLabel(eventType),
|
||||
description: getUsageEventDescription(eventType),
|
||||
tone: config.tone,
|
||||
value: totalMap.get(eventType) ?? 0,
|
||||
}))
|
||||
@@ -142,11 +142,8 @@ type UsageEventRowProps = {
|
||||
}
|
||||
|
||||
function UsageEventRow({ event }: UsageEventRowProps) {
|
||||
const config = BILLING_USAGE_EVENT_CONFIG[event.eventType] ?? {
|
||||
label: getUsageEventLabel(event.eventType),
|
||||
description: '',
|
||||
tone: 'muted' as const,
|
||||
}
|
||||
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)
|
||||
@@ -154,8 +151,8 @@ function UsageEventRow({ event }: UsageEventRowProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:gap-6">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-text">{config.label}</p>
|
||||
{config.description && <p className="text-xs text-text-secondary">{config.description}</p>}
|
||||
<p className="text-sm font-semibold text-text">{label}</p>
|
||||
{description && <p className="text-xs text-text-secondary">{description}</p>}
|
||||
<MetadataBadges metadata={event.metadata} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 text-right text-sm sm:min-w-[160px]">
|
||||
|
||||
@@ -1,57 +1,85 @@
|
||||
import { getI18n } from '~/i18n'
|
||||
|
||||
import type { BillingUsageEventType, PhotoSyncAction, PhotoSyncConflictType } from './types'
|
||||
|
||||
export const PHOTO_CONFLICT_TYPE_CONFIG: Record<PhotoSyncConflictType, { label: string; description: string }> = {
|
||||
const photoConflictKeys = {
|
||||
generic: 'photos.conflict.generic',
|
||||
} as const
|
||||
|
||||
export const PHOTO_CONFLICT_TYPE_CONFIG: Record<
|
||||
PhotoSyncConflictType,
|
||||
{ labelKey: I18nKeys; descriptionKey: I18nKeys }
|
||||
> = {
|
||||
'missing-in-storage': {
|
||||
label: '存储缺失',
|
||||
description: '数据库存在记录,但对应的存储对象已无法访问。',
|
||||
labelKey: 'photos.conflict.missing.label',
|
||||
descriptionKey: 'photos.conflict.missing.description',
|
||||
},
|
||||
'metadata-mismatch': {
|
||||
label: '元数据不一致',
|
||||
description: '存储对象与数据库记录的元数据不一致,需要确认以哪个为准。',
|
||||
labelKey: 'photos.conflict.metadata.label',
|
||||
descriptionKey: 'photos.conflict.metadata.description',
|
||||
},
|
||||
'photo-id-conflict': {
|
||||
label: '照片 ID 冲突',
|
||||
description: '同一个照片 ID 检测到多个对象,请选择保留的版本。',
|
||||
labelKey: 'photos.conflict.id.label',
|
||||
descriptionKey: 'photos.conflict.id.description',
|
||||
},
|
||||
}
|
||||
|
||||
export function getConflictTypeLabel(type: PhotoSyncConflictType | null | undefined): string {
|
||||
const i18n = getI18n()
|
||||
if (!type) {
|
||||
return '冲突'
|
||||
return i18n.t(photoConflictKeys.generic)
|
||||
}
|
||||
return PHOTO_CONFLICT_TYPE_CONFIG[type]?.label ?? '冲突'
|
||||
const key = PHOTO_CONFLICT_TYPE_CONFIG[type]?.labelKey
|
||||
return key ? i18n.t(key) : i18n.t(photoConflictKeys.generic)
|
||||
}
|
||||
|
||||
export const PHOTO_ACTION_TYPE_CONFIG: Record<PhotoSyncAction['type'], { label: string; badgeClass: string }> = {
|
||||
insert: { label: '新增', badgeClass: 'bg-emerald-500/10 text-emerald-400' },
|
||||
update: { label: '更新', badgeClass: 'bg-sky-500/10 text-sky-400' },
|
||||
delete: { label: '删除', badgeClass: 'bg-rose-500/10 text-rose-400' },
|
||||
conflict: { label: '冲突', badgeClass: 'bg-amber-500/10 text-amber-400' },
|
||||
error: { label: '错误', badgeClass: 'bg-rose-500/20 text-rose-200' },
|
||||
noop: { label: '跳过', badgeClass: 'bg-slate-500/10 text-slate-400' },
|
||||
export function getActionTypeMeta(type: PhotoSyncAction['type']) {
|
||||
const i18n = getI18n()
|
||||
const config = PHOTO_ACTION_TYPE_CONFIG[type]
|
||||
if (!config) {
|
||||
return { label: type, badgeClass: '' }
|
||||
}
|
||||
return { label: i18n.t(config.labelKey), badgeClass: config.badgeClass }
|
||||
}
|
||||
|
||||
export const PHOTO_ACTION_TYPE_CONFIG: Record<PhotoSyncAction['type'], { labelKey: I18nKeys; badgeClass: string }> = {
|
||||
insert: { labelKey: 'photos.actions.insert', badgeClass: 'bg-emerald-500/10 text-emerald-400' },
|
||||
update: { labelKey: 'photos.actions.update', badgeClass: 'bg-sky-500/10 text-sky-400' },
|
||||
delete: { labelKey: 'photos.actions.delete', badgeClass: 'bg-rose-500/10 text-rose-400' },
|
||||
conflict: { labelKey: 'photos.actions.conflict', badgeClass: 'bg-amber-500/10 text-amber-400' },
|
||||
error: { labelKey: 'photos.actions.error', badgeClass: 'bg-rose-500/20 text-rose-200' },
|
||||
noop: { labelKey: 'photos.actions.noop', badgeClass: 'bg-slate-500/10 text-slate-400' },
|
||||
}
|
||||
|
||||
export const BILLING_USAGE_EVENT_CONFIG: Record<
|
||||
BillingUsageEventType,
|
||||
{ label: string; description: string; tone: 'accent' | 'warning' | 'muted' }
|
||||
{ labelKey: I18nKeys; descriptionKey: I18nKeys; tone: 'accent' | 'warning' | 'muted' }
|
||||
> = {
|
||||
'photo.asset.created': {
|
||||
label: '新增照片',
|
||||
description: '通过上传或同步新增的照片资产。',
|
||||
labelKey: 'photos.usage.photo-created.label',
|
||||
descriptionKey: 'photos.usage.photo-created.description',
|
||||
tone: 'accent',
|
||||
},
|
||||
'photo.asset.deleted': {
|
||||
label: '删除照片',
|
||||
description: '从图库或存储中移除的照片资产。',
|
||||
labelKey: 'photos.usage.photo-deleted.label',
|
||||
descriptionKey: 'photos.usage.photo-deleted.description',
|
||||
tone: 'warning',
|
||||
},
|
||||
'data.sync.completed': {
|
||||
label: '同步运行',
|
||||
description: '一次数据同步执行完成时记录的汇总事件。',
|
||||
labelKey: 'photos.usage.sync-completed.label',
|
||||
descriptionKey: 'photos.usage.sync-completed.description',
|
||||
tone: 'muted',
|
||||
},
|
||||
}
|
||||
|
||||
export function getUsageEventLabel(eventType: BillingUsageEventType): string {
|
||||
return BILLING_USAGE_EVENT_CONFIG[eventType]?.label ?? eventType
|
||||
const i18n = getI18n()
|
||||
const key = BILLING_USAGE_EVENT_CONFIG[eventType]?.labelKey
|
||||
return key ? i18n.t(key) : eventType
|
||||
}
|
||||
|
||||
export function getUsageEventDescription(eventType: BillingUsageEventType): string {
|
||||
const i18n = getI18n()
|
||||
const key = BILLING_USAGE_EVENT_CONFIG[eventType]?.descriptionKey ?? null
|
||||
return key ? i18n.t(key) : ''
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { clsxm } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LinearBorderPanel } from '../../components/common/GlassPanel'
|
||||
import type {
|
||||
@@ -56,6 +57,7 @@ function SecretFieldInput<Key extends string>({
|
||||
onChange: (key: Key, value: SchemaFormValue) => void
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
@@ -75,7 +77,7 @@ function SecretFieldInput<Key extends string>({
|
||||
size="sm"
|
||||
className="border-fill-tertiary/50 text-text-secondary hover:bg-fill/30 hover:text-text border"
|
||||
>
|
||||
{revealed ? '隐藏' : '显示'}
|
||||
{revealed ? t('schema-form.secret.hide') : t('schema-form.secret.show')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -98,6 +100,7 @@ type FieldRendererProps<Key extends string> = {
|
||||
|
||||
function FieldRenderer<Key extends string>({ field, value, onChange, renderSlot, context }: FieldRendererProps<Key>) {
|
||||
const { component } = field
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (component.type === 'slot') {
|
||||
return renderSlot
|
||||
@@ -123,7 +126,7 @@ function FieldRenderer<Key extends string>({ field, value, onChange, renderSlot,
|
||||
return (
|
||||
<Select value={stringValue} onValueChange={(nextValue) => onChange(field.key, nextValue)}>
|
||||
<SelectTrigger className="border-fill-tertiary/50 bg-background focus:border-accent/40">
|
||||
<SelectValue placeholder={component.placeholder ?? '请选择'} />
|
||||
<SelectValue placeholder={component.placeholder ?? t('schema-form.select.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-fill-tertiary bg-background">
|
||||
{component.options?.map((option) => (
|
||||
@@ -202,13 +205,20 @@ function renderGroup<Key extends string>(
|
||||
)
|
||||
}
|
||||
|
||||
function renderField<Key extends string>(
|
||||
field: UiFieldNode<Key>,
|
||||
formState: SchemaFormState<Key>,
|
||||
handleChange: (key: Key, value: SchemaFormValue) => void,
|
||||
renderSlot: SlotRenderer<Key> | undefined,
|
||||
context: SchemaRendererContext<Key>,
|
||||
) {
|
||||
function FieldNode<Key extends string>({
|
||||
field,
|
||||
formState,
|
||||
handleChange,
|
||||
renderSlot,
|
||||
context,
|
||||
}: {
|
||||
field: UiFieldNode<Key>
|
||||
formState: SchemaFormState<Key>
|
||||
handleChange: (key: Key, value: SchemaFormValue) => void
|
||||
renderSlot: SlotRenderer<Key> | undefined
|
||||
context: SchemaRendererContext<Key>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (field.hidden) {
|
||||
return null
|
||||
}
|
||||
@@ -253,7 +263,7 @@ function renderField<Key extends string>(
|
||||
|
||||
<FieldRenderer field={field} value={value} onChange={handleChange} renderSlot={renderSlot} context={context} />
|
||||
|
||||
{showSensitiveHint ? <FormHelperText>出于安全考虑,仅在更新时填写新的值。</FormHelperText> : null}
|
||||
{showSensitiveHint ? <FormHelperText>{t('schema-form.secret.helper')}</FormHelperText> : null}
|
||||
|
||||
{helperText ? <FormHelperText>{helperText}</FormHelperText> : null}
|
||||
</div>
|
||||
@@ -277,7 +287,15 @@ function renderNode<Key extends string>(
|
||||
}
|
||||
|
||||
if (node.type === 'field') {
|
||||
return renderField(node, formState, handleChange, renderSlot, context)
|
||||
return (
|
||||
<FieldNode
|
||||
field={node}
|
||||
formState={formState}
|
||||
handleChange={handleChange}
|
||||
renderSlot={renderSlot}
|
||||
context={context}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderedChildren = node.children
|
||||
|
||||
@@ -3,27 +3,27 @@ import { PageTabs } from '~/components/navigation/PageTabs'
|
||||
const SETTINGS_TABS = [
|
||||
{
|
||||
id: 'site',
|
||||
label: '站点设置',
|
||||
labelKey: 'settings.nav.site',
|
||||
path: '/settings/site',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
label: '用户信息',
|
||||
labelKey: 'settings.nav.user',
|
||||
path: '/settings/user',
|
||||
end: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'account',
|
||||
label: '账号与登录',
|
||||
labelKey: 'settings.nav.account',
|
||||
path: '/settings/account',
|
||||
end: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'data',
|
||||
label: '数据管理',
|
||||
labelKey: 'settings.nav.data',
|
||||
path: '/settings/data',
|
||||
end: true,
|
||||
},
|
||||
@@ -39,7 +39,7 @@ export function SettingsNavigation({ active }: SettingsNavigationProps) {
|
||||
activeId={active}
|
||||
items={SETTINGS_TABS.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
labelKey: tab.labelKey,
|
||||
to: tab.path,
|
||||
end: tab.end,
|
||||
}))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getI18n } from '~/i18n'
|
||||
import { coreApi, coreApiBaseURL } from '~/lib/api-client'
|
||||
import { camelCaseKeys } from '~/lib/case'
|
||||
|
||||
@@ -72,7 +73,12 @@ export async function runBuilderDebugTest(file: File, options?: RunBuilderDebugO
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`调试请求失败:${response.status} ${response.statusText}`)
|
||||
throw new Error(
|
||||
getI18n().t('superadmin.builder-debug.api.request-failed', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
@@ -162,7 +168,7 @@ export async function runBuilderDebugTest(file: File, options?: RunBuilderDebugO
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error('调试过程中未收到最终结果,连接已终止。')
|
||||
throw new Error(getI18n().t('superadmin.builder-debug.api.missing-result'))
|
||||
}
|
||||
|
||||
return camelCaseKeys<BuilderDebugResult>(finalResult)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Spring } from '@afilmory/utils'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { m } from 'motion/react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
|
||||
@@ -51,6 +52,7 @@ interface SuperAdminSettingsFormProps {
|
||||
|
||||
export function SuperAdminSettingsForm({ visibleSectionIds }: SuperAdminSettingsFormProps = {}) {
|
||||
const { data, isLoading, isError, error } = useSuperAdminSettingsQuery()
|
||||
const { t } = useTranslation()
|
||||
const [fieldMap, setFieldMap] = useState<SuperAdminFieldMap>(() => new Map())
|
||||
const [formState, setFormState] = useState<FormState | null>(null)
|
||||
const [initialState, setInitialState] = useState<FormState | null>(null)
|
||||
@@ -130,23 +132,26 @@ export function SuperAdminSettingsForm({ visibleSectionIds }: SuperAdminSettings
|
||||
|
||||
const mutationMessage = useMemo(() => {
|
||||
if (updateMutation.isError) {
|
||||
const reason = updateMutation.error instanceof Error ? updateMutation.error.message : '保存失败'
|
||||
return `保存失败:${reason}`
|
||||
const reason =
|
||||
updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: t('superadmin.settings.message.unknown-error')
|
||||
return t('superadmin.settings.message.error', { reason })
|
||||
}
|
||||
|
||||
if (updateMutation.isPending) {
|
||||
return '正在保存设置...'
|
||||
return t('superadmin.settings.message.saving')
|
||||
}
|
||||
|
||||
if (!hasChanges && updateMutation.isSuccess) {
|
||||
return '保存成功,设置已更新'
|
||||
return t('superadmin.settings.message.saved')
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
return '您有尚未保存的变更'
|
||||
return t('superadmin.settings.message.dirty')
|
||||
}
|
||||
|
||||
return '所有设置已同步'
|
||||
return t('superadmin.settings.message.idle')
|
||||
}, [hasChanges, updateMutation.error, updateMutation.isError, updateMutation.isPending, updateMutation.isSuccess])
|
||||
|
||||
const shouldRenderNode = useMemo(() => {
|
||||
@@ -166,7 +171,11 @@ export function SuperAdminSettingsForm({ visibleSectionIds }: SuperAdminSettings
|
||||
return (
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="text-red text-sm">
|
||||
<span>{`无法加载超级管理员设置:${error instanceof Error ? error.message : '未知错误'}`}</span>
|
||||
<span>
|
||||
{t('superadmin.settings.error.loading', {
|
||||
reason: error instanceof Error ? error.message : t('superadmin.settings.message.unknown-error'),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
@@ -217,13 +226,17 @@ export function SuperAdminSettingsForm({ visibleSectionIds }: SuperAdminSettings
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-text-tertiary text-xs tracking-wide uppercase">当前用户总数</p>
|
||||
<p className="text-text-tertiary text-xs tracking-wide uppercase">
|
||||
{t('superadmin.settings.stats.total-users')}
|
||||
</p>
|
||||
<p className="text-text text-3xl font-semibold">{typeof totalUsers === 'number' ? totalUsers : 0}</p>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-text-tertiary text-xs tracking-wide uppercase">剩余可注册名额</p>
|
||||
<p className="text-text-tertiary text-xs tracking-wide uppercase">
|
||||
{t('superadmin.settings.stats.remaining')}
|
||||
</p>
|
||||
<p className="text-text text-3xl font-semibold">{remainingLabel}</p>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
@@ -235,11 +248,11 @@ export function SuperAdminSettingsForm({ visibleSectionIds }: SuperAdminSettings
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
isLoading={updateMutation.isPending}
|
||||
loadingText="保存中..."
|
||||
loadingText={t('superadmin.settings.button.loading')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
保存修改
|
||||
{t('superadmin.settings.button.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</m.form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { RefreshCcwIcon } from 'lucide-react'
|
||||
import { m } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
@@ -18,6 +19,7 @@ export function SuperAdminTenantManager() {
|
||||
const tenantsQuery = useSuperAdminTenantsQuery()
|
||||
const updatePlanMutation = useUpdateTenantPlanMutation()
|
||||
const updateBanMutation = useUpdateTenantBanMutation()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isLoading } = tenantsQuery
|
||||
const { isError } = tenantsQuery
|
||||
@@ -34,11 +36,11 @@ export function SuperAdminTenantManager() {
|
||||
{ tenantId: tenant.id, planId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`已将 ${tenant.name} 切换到 ${planId} 计划`)
|
||||
toast.success(t('superadmin.tenants.toast.plan-success', { name: tenant.name, planId }))
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('更新订阅失败', {
|
||||
description: error instanceof Error ? error.message : '请稍后再试',
|
||||
toast.error(t('superadmin.tenants.toast.plan-error'), {
|
||||
description: error instanceof Error ? error.message : t('common.retry-later'),
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -51,11 +53,15 @@ export function SuperAdminTenantManager() {
|
||||
{ tenantId: tenant.id, banned: next },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(next ? `已封禁租户 ${tenant.name}` : `已解除封禁 ${tenant.name}`)
|
||||
toast.success(
|
||||
next
|
||||
? t('superadmin.tenants.toast.ban-success', { name: tenant.name })
|
||||
: t('superadmin.tenants.toast.unban-success', { name: tenant.name }),
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('更新封禁状态失败', {
|
||||
description: error instanceof Error ? error.message : '请稍后再试',
|
||||
toast.error(t('superadmin.tenants.toast.ban-error'), {
|
||||
description: error instanceof Error ? error.message : t('common.retry-later'),
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -71,7 +77,9 @@ export function SuperAdminTenantManager() {
|
||||
if (isError) {
|
||||
return (
|
||||
<LinearBorderPanel className="p-6 text-sm text-red">
|
||||
无法加载租户数据:{tenantsQuery.error instanceof Error ? tenantsQuery.error.message : '未知错误'}
|
||||
{t('superadmin.tenants.error.loading', {
|
||||
reason: tenantsQuery.error instanceof Error ? tenantsQuery.error.message : t('common.unknown-error'),
|
||||
})}
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
@@ -85,8 +93,8 @@ export function SuperAdminTenantManager() {
|
||||
<LinearBorderPanel className="p-6 bg-background-secondary">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-text text-lg font-semibold">租户列表</h2>
|
||||
<p className="text-text-secondary text-sm">手动切换订阅计划或封禁违规租户。</p>
|
||||
<h2 className="text-text text-lg font-semibold">{t('superadmin.tenants.title')}</h2>
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.tenants.description')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -97,22 +105,22 @@ export function SuperAdminTenantManager() {
|
||||
disabled={tenantsQuery.isFetching}
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
{tenantsQuery.isFetching ? '正在刷新…' : '刷新列表'}
|
||||
{tenantsQuery.isFetching ? t('superadmin.tenants.refresh.loading') : t('superadmin.tenants.refresh.button')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{tenants.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">当前没有可管理的租户。</p>
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.tenants.empty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto mt-4">
|
||||
<table className="min-w-full divide-y divide-border/40 text-sm">
|
||||
<thead>
|
||||
<tr className="text-text-tertiary text-xs uppercase tracking-wide">
|
||||
<th className="px-3 py-2 text-left">租户</th>
|
||||
<th className="px-3 py-2 text-left">订阅计划</th>
|
||||
<th className="px-3 py-2 text-center">状态</th>
|
||||
<th className="px-3 py-2 text-center">封禁</th>
|
||||
<th className="px-3 py-2 text-left">创建时间</th>
|
||||
<th className="px-3 py-2 text-left">{t('superadmin.tenants.table.tenant')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('superadmin.tenants.table.plan')}</th>
|
||||
<th className="px-3 py-2 text-center">{t('superadmin.tenants.table.status')}</th>
|
||||
<th className="px-3 py-2 text-center">{t('superadmin.tenants.table.ban')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('superadmin.tenants.table.created')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
@@ -142,7 +150,11 @@ export function SuperAdminTenantManager() {
|
||||
onClick={() => handleToggleBanned(tenant)}
|
||||
disabled={isBanUpdating(tenant.id)}
|
||||
>
|
||||
{isBanUpdating(tenant.id) ? '处理中…' : tenant.banned ? '解除封禁' : '封禁'}
|
||||
{isBanUpdating(tenant.id)
|
||||
? t('superadmin.tenants.button.processing')
|
||||
: tenant.banned
|
||||
? t('superadmin.tenants.button.unban')
|
||||
: t('superadmin.tenants.button.ban')}
|
||||
</Button>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-text-secondary text-xs">{formatDateLabel(tenant.createdAt)}</td>
|
||||
@@ -168,11 +180,12 @@ function PlanSelector({
|
||||
disabled?: boolean
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Select value={value} onValueChange={(value) => onChange(value)} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择订阅计划" />
|
||||
<SelectValue placeholder={t('superadmin.tenants.plan.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map((plan) => (
|
||||
@@ -195,16 +208,33 @@ function PlanDescription({ plan }: { plan: BillingPlanDefinition | undefined })
|
||||
}
|
||||
|
||||
function StatusBadge({ status, banned }: { status: SuperAdminTenantSummary['status']; banned: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
if (banned) {
|
||||
return <span className="bg-rose-500/10 text-rose-400 rounded-full px-2 py-0.5 text-xs">已封禁</span>
|
||||
return (
|
||||
<span className="bg-rose-500/10 text-rose-400 rounded-full px-2 py-0.5 text-xs">
|
||||
{t('superadmin.tenants.status.banned')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === 'active') {
|
||||
return <span className="bg-emerald-500/10 text-emerald-400 rounded-full px-2 py-0.5 text-xs">活跃</span>
|
||||
return (
|
||||
<span className="bg-emerald-500/10 text-emerald-400 rounded-full px-2 py-0.5 text-xs">
|
||||
{t('superadmin.tenants.status.active')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === 'suspended') {
|
||||
return <span className="bg-amber-500/10 text-amber-400 rounded-full px-2 py-0.5 text-xs">已暂停</span>
|
||||
return (
|
||||
<span className="bg-amber-500/10 text-amber-400 rounded-full px-2 py-0.5 text-xs">
|
||||
{t('superadmin.tenants.status.suspended')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="bg-slate-500/10 text-slate-400 rounded-full px-2 py-0.5 text-xs">未激活</span>
|
||||
return (
|
||||
<span className="bg-slate-500/10 text-slate-400 rounded-full px-2 py-0.5 text-xs">
|
||||
{t('superadmin.tenants.status.inactive')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string): string {
|
||||
|
||||
@@ -2,31 +2,80 @@ import { Button } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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'
|
||||
import type { BillingPlanSummary } from '~/modules/billing'
|
||||
import { useTenantPlanQuery } from '~/modules/billing'
|
||||
|
||||
const QUOTA_LABELS: Record<string, string> = {
|
||||
monthlyAssetProcessLimit: '每月可新增照片',
|
||||
libraryItemLimit: '图库容量',
|
||||
maxUploadSizeMb: '单次上传大小',
|
||||
maxSyncObjectSizeMb: '同步素材大小',
|
||||
const planI18nKeys = {
|
||||
pageTitle: 'plan.page.title',
|
||||
pageDescription: 'plan.page.description',
|
||||
errorLoadPrefix: 'plan.error.load-prefix',
|
||||
errorUnknown: 'plan.error.unknown',
|
||||
toastCheckoutUnavailable: 'plan.toast.checkout-unavailable',
|
||||
toastMissingCheckoutUrl: 'plan.toast.missing-checkout-url',
|
||||
toastCheckoutFailure: 'plan.toast.checkout-failure',
|
||||
toastMissingPortalAccount: 'plan.toast.missing-portal-account',
|
||||
toastMissingPortalUrl: 'plan.toast.missing-portal-url',
|
||||
toastPortalFailure: 'plan.toast.portal-failure',
|
||||
badgeInternal: 'plan.badge.internal',
|
||||
badgeCurrent: 'plan.badge.current',
|
||||
quotaUnlimited: 'plan.quotas.unlimited',
|
||||
checkoutLoading: 'plan.checkout.loading',
|
||||
checkoutUpgrade: 'plan.checkout.upgrade',
|
||||
checkoutComingSoon: 'plan.checkout.coming-soon',
|
||||
portalLoading: 'plan.portal.loading',
|
||||
portalManage: 'plan.portal.manage',
|
||||
} as const satisfies Record<
|
||||
| 'pageTitle'
|
||||
| 'pageDescription'
|
||||
| 'errorLoadPrefix'
|
||||
| 'errorUnknown'
|
||||
| 'toastCheckoutUnavailable'
|
||||
| 'toastMissingCheckoutUrl'
|
||||
| 'toastCheckoutFailure'
|
||||
| 'toastMissingPortalAccount'
|
||||
| 'toastMissingPortalUrl'
|
||||
| 'toastPortalFailure'
|
||||
| 'badgeInternal'
|
||||
| 'badgeCurrent'
|
||||
| 'quotaUnlimited'
|
||||
| 'checkoutLoading'
|
||||
| 'checkoutUpgrade'
|
||||
| 'checkoutComingSoon'
|
||||
| 'portalLoading'
|
||||
| 'portalManage',
|
||||
I18nKeys
|
||||
>
|
||||
|
||||
const planI18nPrefixes = {
|
||||
quotaLabelPrefix: 'plan.quotas.label.',
|
||||
quotaUnitPrefix: 'plan.quotas.unit.',
|
||||
} as const
|
||||
|
||||
const QUOTA_LABEL_KEYS: Record<string, I18nKeys> = {
|
||||
monthlyAssetProcessLimit: `${planI18nPrefixes.quotaLabelPrefix}monthlyAssetProcessLimit`,
|
||||
libraryItemLimit: `${planI18nPrefixes.quotaLabelPrefix}libraryItemLimit`,
|
||||
maxUploadSizeMb: `${planI18nPrefixes.quotaLabelPrefix}maxUploadSizeMb`,
|
||||
maxSyncObjectSizeMb: `${planI18nPrefixes.quotaLabelPrefix}maxSyncObjectSizeMb`,
|
||||
}
|
||||
|
||||
const QUOTA_UNITS: Record<string, string> = {
|
||||
monthlyAssetProcessLimit: '张',
|
||||
libraryItemLimit: '张',
|
||||
maxUploadSizeMb: ' MB',
|
||||
maxSyncObjectSizeMb: ' MB',
|
||||
const QUOTA_UNIT_KEYS: Record<string, I18nKeys | null> = {
|
||||
monthlyAssetProcessLimit: `${planI18nPrefixes.quotaUnitPrefix}photos`,
|
||||
libraryItemLimit: `${planI18nPrefixes.quotaUnitPrefix}photos`,
|
||||
maxUploadSizeMb: `${planI18nPrefixes.quotaUnitPrefix}megabytes`,
|
||||
maxSyncObjectSizeMb: `${planI18nPrefixes.quotaUnitPrefix}megabytes`,
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
const planQuery = useTenantPlanQuery()
|
||||
const queryClient = useQueryClient()
|
||||
const session = (queryClient.getQueryData<SessionResponse | null>(AUTH_SESSION_QUERY_KEY) ??
|
||||
@@ -52,11 +101,12 @@ export function Component() {
|
||||
}, [availablePlans, plan])
|
||||
|
||||
return (
|
||||
<MainPageLayout title="订阅计划" description="查看当前订阅状态与资源限制,并在此处发起升级或管理订阅。">
|
||||
<MainPageLayout title={t(planI18nKeys.pageTitle)} description={t(planI18nKeys.pageDescription)}>
|
||||
<div className="space-y-6">
|
||||
{planQuery.isError && (
|
||||
<div className="text-red text-sm">
|
||||
无法加载订阅信息:{planQuery.error instanceof Error ? planQuery.error.message : '未知错误'}
|
||||
{t(planI18nKeys.errorLoadPrefix)}{' '}
|
||||
{planQuery.error instanceof Error ? planQuery.error.message : t(planI18nKeys.errorUnknown)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -118,6 +168,7 @@ function PlanCard({
|
||||
tenantSlug: string | null
|
||||
creemCustomerId: string | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [checkoutLoading, setCheckoutLoading] = useState(false)
|
||||
const [portalLoading, setPortalLoading] = useState(false)
|
||||
const productId = plan.payment?.creemProductId ?? null
|
||||
@@ -128,7 +179,7 @@ function PlanCard({
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!canCheckout || !tenantId || !productId) {
|
||||
toast.error('该方案暂未开放,请稍后再试。')
|
||||
toast.error(t(planI18nKeys.toastCheckoutUnavailable))
|
||||
return
|
||||
}
|
||||
setCheckoutLoading(true)
|
||||
@@ -147,15 +198,15 @@ function PlanCard({
|
||||
metadata,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(error.message ?? 'Creem 返回了未知错误')
|
||||
throw new Error(error.message ?? t(planI18nKeys.toastCheckoutFailure))
|
||||
}
|
||||
if (data?.url) {
|
||||
window.location.href = data.url
|
||||
return
|
||||
}
|
||||
toast.error('Creem 未返回有效的结算链接,请稍后再试。')
|
||||
toast.error(t(planI18nKeys.toastMissingCheckoutUrl))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '无法创建订阅结算会话')
|
||||
toast.error(error instanceof Error ? error.message : t(planI18nKeys.toastCheckoutFailure))
|
||||
} finally {
|
||||
setCheckoutLoading(false)
|
||||
}
|
||||
@@ -163,7 +214,7 @@ function PlanCard({
|
||||
|
||||
const handlePortal = async () => {
|
||||
if (!showPortalButton || !creemCustomerId) {
|
||||
toast.error('找不到订阅账户,请稍后再试。')
|
||||
toast.error(t(planI18nKeys.toastMissingPortalAccount))
|
||||
return
|
||||
}
|
||||
setPortalLoading(true)
|
||||
@@ -171,15 +222,15 @@ function PlanCard({
|
||||
const portalPayload = creemCustomerId ? { customerId: creemCustomerId } : undefined
|
||||
const { data, error } = await authClient.creem.createPortal(portalPayload)
|
||||
if (error) {
|
||||
throw new Error(error.message ?? '无法打开订阅管理')
|
||||
throw new Error(error.message ?? t(planI18nKeys.toastPortalFailure))
|
||||
}
|
||||
if (data?.url) {
|
||||
window.location.href = data.url
|
||||
return
|
||||
}
|
||||
toast.error('Creem 未返回订阅管理地址,请稍后再试。')
|
||||
toast.error(t(planI18nKeys.toastMissingPortalUrl))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '无法打开订阅管理')
|
||||
toast.error(error instanceof Error ? error.message : t(planI18nKeys.toastPortalFailure))
|
||||
} finally {
|
||||
setPortalLoading(false)
|
||||
}
|
||||
@@ -208,8 +259,8 @@ function PlanCard({
|
||||
<ul className="mt-6 space-y-2">
|
||||
{Object.entries(plan.quotas).map(([key, value]) => (
|
||||
<li key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-tertiary">{QUOTA_LABELS[key] ?? key}</span>
|
||||
<span className="text-text font-medium">{renderQuotaValue(value, QUOTA_UNITS[key])}</span>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -222,7 +273,11 @@ function PlanCard({
|
||||
disabled={!canCheckout || checkoutLoading}
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
{checkoutLoading ? '请稍候…' : canCheckout ? '升级此方案' : '敬请期待'}
|
||||
{checkoutLoading
|
||||
? t(planI18nKeys.checkoutLoading)
|
||||
: canCheckout
|
||||
? t(planI18nKeys.checkoutUpgrade)
|
||||
: t(planI18nKeys.checkoutComingSoon)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -235,7 +290,7 @@ function PlanCard({
|
||||
disabled={portalLoading}
|
||||
onClick={handlePortal}
|
||||
>
|
||||
{portalLoading ? '打开中…' : '管理订阅'}
|
||||
{portalLoading ? t(planI18nKeys.portalLoading) : t(planI18nKeys.portalManage)}
|
||||
</Button>
|
||||
)}
|
||||
</LinearBorderPanel>
|
||||
@@ -261,16 +316,22 @@ function buildCheckoutSuccessUrl(tenantSlug: string | null): string {
|
||||
}
|
||||
|
||||
function CurrentBadge({ planId }: { planId: string }) {
|
||||
const label = planId === 'friend' ? '内部方案' : '当前方案'
|
||||
return <span className="bg-accent/10 text-accent rounded-full px-2 py-0.5 text-xs font-semibold">{label}</span>
|
||||
const { t } = useTranslation()
|
||||
const labelKey = planId === 'friend' ? planI18nKeys.badgeInternal : planI18nKeys.badgeCurrent
|
||||
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, unit?: string): string {
|
||||
function renderQuotaValue(value: number | null, unitKey: I18nKeys | null): string {
|
||||
const i18n = getI18n()
|
||||
if (value === null || value === undefined) {
|
||||
return '无限制'
|
||||
return i18n.t(planI18nKeys.quotaUnlimited)
|
||||
}
|
||||
const numeral = value.toLocaleString('zh-CN')
|
||||
return unit ? `${numeral}${unit}` : numeral
|
||||
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 {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { SocialConnectionSettings } from '~/modules/auth/components/SocialConnectionSettings'
|
||||
import { SettingsNavigation } from '~/modules/settings'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MainPageLayout title="账号与登录" description="绑定第三方账号,使用 OAuth 快速登录后台。">
|
||||
<MainPageLayout title={t('settings.account.title')} description={t('settings.account.description')}>
|
||||
<div className="space-y-6">
|
||||
<SettingsNavigation active="account" />
|
||||
<SocialConnectionSettings />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { DataManagementPanel } from '~/modules/data-management'
|
||||
import { SettingsNavigation } from '~/modules/settings'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MainPageLayout title="数据管理" description="执行数据库级别的维护操作,以保持照片数据与对象存储一致。">
|
||||
<MainPageLayout title={t('settings.data.title')} description={t('settings.data.description')}>
|
||||
<div className="space-y-6">
|
||||
<SettingsNavigation active="data" />
|
||||
<DataManagementPanel />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { SettingsNavigation } from '~/modules/settings'
|
||||
import { SiteSettingsForm } from '~/modules/site-settings'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MainPageLayout title="站点设置" description="配置前台站点的品牌信息、社交渠道与地图展示。">
|
||||
<MainPageLayout title={t('settings.site.title')} description={t('settings.site.description')}>
|
||||
<div className="space-y-6">
|
||||
<SettingsNavigation active="site" />
|
||||
<SiteSettingsForm />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { SettingsNavigation } from '~/modules/settings'
|
||||
import { SiteUserProfileForm } from '~/modules/site-settings'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MainPageLayout title="用户信息" description="维护展示在前台的作者资料、头像与别名。">
|
||||
<MainPageLayout title={t('settings.user.title')} description={t('settings.user.description')}>
|
||||
<div className="space-y-6">
|
||||
<SettingsNavigation active="user" />
|
||||
<SiteUserProfileForm />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { useAccessDeniedValue, useSetAccessDenied } from '~/atoms/access-denied'
|
||||
@@ -13,6 +14,7 @@ import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session'
|
||||
import { signOutBySource } from '~/modules/auth/auth-client'
|
||||
|
||||
export const Component: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -25,9 +27,8 @@ export const Component: FC = () => {
|
||||
const status = state.status ?? accessDenied?.status ?? 403
|
||||
const reason = state.reason ?? accessDenied?.reason
|
||||
|
||||
const title = status === 403 ? '权限不足' : '无法访问'
|
||||
const description =
|
||||
reason ?? '你当前的账号没有访问该功能所需的权限,请联系工作区管理员,或切换到拥有权限的账号后重试。'
|
||||
const title = t(status === 403 ? 'no-access.title.forbidden' : 'no-access.title.unavailable')
|
||||
const description = reason ?? t('no-access.description')
|
||||
|
||||
const hint = useMemo(() => {
|
||||
if (!originPath || originPath === ROUTE_PATHS.NO_ACCESS) {
|
||||
@@ -67,16 +68,16 @@ export const Component: FC = () => {
|
||||
{hint && (
|
||||
<div className="bg-material-medium border-fill-tertiary mb-6 rounded-lg border px-4 py-3">
|
||||
<p className="text-text-secondary text-sm">
|
||||
请求路径: <span className="text-text font-medium">{hint}</span>
|
||||
{t('no-access.request-path')} <span className="text-text font-medium">{hint}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button variant="primary" className="flex-1" onClick={handleRetry}>
|
||||
重新尝试
|
||||
{t('no-access.retry')}
|
||||
</Button>
|
||||
<Button variant="ghost" className="flex-1" onClick={handleBackToLogin}>
|
||||
返回登录
|
||||
{t('no-access.back-to-login')}
|
||||
</Button>
|
||||
</div>
|
||||
</m.div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { BuilderSettingsForm } from '~/modules/builder-settings'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MainPageLayout title="构建器设置">
|
||||
<MainPageLayout title={t('superadmin.builder.title')}>
|
||||
<BuilderSettingsForm />
|
||||
</MainPageLayout>
|
||||
)
|
||||
|
||||
@@ -5,9 +5,11 @@ import { m } from 'motion/react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { getI18n } from '~/i18n'
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
import type { PhotoSyncLogLevel } from '~/modules/photos/types'
|
||||
import type { BuilderDebugProgressEvent, BuilderDebugResult } from '~/modules/super-admin'
|
||||
@@ -15,6 +17,92 @@ import { runBuilderDebugTest } from '~/modules/super-admin'
|
||||
|
||||
const MAX_LOG_ENTRIES = 300
|
||||
|
||||
const builderDebugKeys = {
|
||||
title: 'superadmin.builder-debug.title',
|
||||
description: 'superadmin.builder-debug.description',
|
||||
toasts: {
|
||||
pickFile: 'superadmin.builder-debug.toast.pick-file',
|
||||
successTitle: 'superadmin.builder-debug.toast.success.title',
|
||||
successDescription: 'superadmin.builder-debug.toast.success.description',
|
||||
cancelled: 'superadmin.builder-debug.toast.cancelled',
|
||||
failureFallback: 'superadmin.builder-debug.toast.failure-fallback',
|
||||
failureTitle: 'superadmin.builder-debug.toast.failure.title',
|
||||
manualCancelledMessage: 'superadmin.builder-debug.toast.manual-cancelled-message',
|
||||
manualCancelledLog: 'superadmin.builder-debug.toast.manual-cancelled-log',
|
||||
copySuccess: 'superadmin.builder-debug.toast.copy-success',
|
||||
copyFailureTitle: 'superadmin.builder-debug.toast.copy-failure.title',
|
||||
copyFailureDescription: 'superadmin.builder-debug.toast.copy-failure.description',
|
||||
},
|
||||
input: {
|
||||
title: 'superadmin.builder-debug.input.title',
|
||||
subtitle: 'superadmin.builder-debug.input.subtitle',
|
||||
placeholder: 'superadmin.builder-debug.input.placeholder',
|
||||
hint: 'superadmin.builder-debug.input.hint',
|
||||
max: 'superadmin.builder-debug.input.max',
|
||||
clear: 'superadmin.builder-debug.input.clear',
|
||||
fileMeta: 'superadmin.builder-debug.input.file-meta',
|
||||
},
|
||||
actions: {
|
||||
start: 'superadmin.builder-debug.actions.start',
|
||||
cancel: 'superadmin.builder-debug.actions.cancel',
|
||||
},
|
||||
notes: 'superadmin.builder-debug.notes.keep-page-open',
|
||||
recent: {
|
||||
title: 'superadmin.builder-debug.recent.title',
|
||||
file: 'superadmin.builder-debug.recent.file',
|
||||
size: 'superadmin.builder-debug.recent.size',
|
||||
storageKey: 'superadmin.builder-debug.recent.storage-key',
|
||||
},
|
||||
safety: {
|
||||
title: 'superadmin.builder-debug.safety.title',
|
||||
noDb: 'superadmin.builder-debug.safety.items.no-db',
|
||||
noStorage: 'superadmin.builder-debug.safety.items.no-storage',
|
||||
realtime: 'superadmin.builder-debug.safety.items.realtime',
|
||||
},
|
||||
logs: {
|
||||
title: 'superadmin.builder-debug.logs.title',
|
||||
subtitle: 'superadmin.builder-debug.logs.subtitle',
|
||||
source: 'superadmin.builder-debug.logs.source',
|
||||
initializing: 'superadmin.builder-debug.logs.initializing',
|
||||
empty: 'superadmin.builder-debug.logs.empty',
|
||||
},
|
||||
output: {
|
||||
title: 'superadmin.builder-debug.output.title',
|
||||
subtitle: 'superadmin.builder-debug.output.subtitle',
|
||||
copy: 'superadmin.builder-debug.output.copy',
|
||||
noManifest: 'superadmin.builder-debug.output.no-manifest',
|
||||
afterRun: 'superadmin.builder-debug.output.after-run',
|
||||
},
|
||||
summary: {
|
||||
resultType: 'superadmin.builder-debug.summary.result-type',
|
||||
storageKey: 'superadmin.builder-debug.summary.storage-key',
|
||||
thumbnail: 'superadmin.builder-debug.summary.thumbnail',
|
||||
thumbnailMissing: 'superadmin.builder-debug.summary.thumbnail-missing',
|
||||
cleaned: 'superadmin.builder-debug.summary.cleaned',
|
||||
cleanedYes: 'superadmin.builder-debug.summary.cleaned-yes',
|
||||
cleanedNo: 'superadmin.builder-debug.summary.cleaned-no',
|
||||
},
|
||||
statuses: {
|
||||
idle: 'superadmin.builder-debug.status.idle',
|
||||
running: 'superadmin.builder-debug.status.running',
|
||||
success: 'superadmin.builder-debug.status.success',
|
||||
error: 'superadmin.builder-debug.status.error',
|
||||
},
|
||||
logStatus: {
|
||||
start: 'superadmin.builder-debug.log.status.start',
|
||||
complete: 'superadmin.builder-debug.log.status.complete',
|
||||
error: 'superadmin.builder-debug.log.status.error',
|
||||
},
|
||||
logMessages: {
|
||||
start: 'superadmin.builder-debug.log.message.start',
|
||||
complete: 'superadmin.builder-debug.log.message.complete',
|
||||
},
|
||||
placeholders: {
|
||||
unknown: 'common.unknown',
|
||||
},
|
||||
logLevelPrefix: 'superadmin.builder-debug.log.level.',
|
||||
} as const
|
||||
|
||||
const LEVEL_THEME: Record<PhotoSyncLogLevel, string> = {
|
||||
info: 'border-sky-500/30 bg-sky-500/10 text-sky-100',
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100',
|
||||
@@ -22,11 +110,11 @@ const LEVEL_THEME: Record<PhotoSyncLogLevel, string> = {
|
||||
error: 'border-rose-500/30 bg-rose-500/10 text-rose-100',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<RunStatus, { label: string; className: string }> = {
|
||||
idle: { label: '就绪', className: 'text-text-tertiary' },
|
||||
running: { label: '调试中', className: 'text-accent' },
|
||||
success: { label: '已完成', className: 'text-emerald-400' },
|
||||
error: { label: '失败', className: 'text-rose-400' },
|
||||
const STATUS_LABEL: Record<RunStatus, { labelKey: I18nKeys; className: string }> = {
|
||||
idle: { labelKey: builderDebugKeys.statuses.idle, className: 'text-text-tertiary' },
|
||||
running: { labelKey: builderDebugKeys.statuses.running, className: 'text-accent' },
|
||||
success: { labelKey: builderDebugKeys.statuses.success, className: 'text-emerald-400' },
|
||||
error: { labelKey: builderDebugKeys.statuses.error, className: 'text-rose-400' },
|
||||
}
|
||||
|
||||
type RunStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
@@ -73,6 +161,7 @@ function formatBytes(bytes: number | undefined | null): string {
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -81,10 +170,8 @@ export function Component() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-text text-2xl font-semibold">Builder 调试工具</h1>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
该工具用于单张图片的 Builder 管线验收。调试过程中不会写入数据库,所有上传与生成的文件会在任务完成后立刻清理。
|
||||
</p>
|
||||
<h1 className="text-text text-2xl font-semibold">{t(builderDebugKeys.title)}</h1>
|
||||
<p className="text-text-tertiary text-sm">{t(builderDebugKeys.description)}</p>
|
||||
</header>
|
||||
|
||||
<BuilderDebugConsole />
|
||||
@@ -93,6 +180,7 @@ export function Component() {
|
||||
}
|
||||
|
||||
function BuilderDebugConsole() {
|
||||
const { t } = useTranslation()
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [runStatus, setRunStatus] = useState<RunStatus>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
@@ -179,7 +267,7 @@ function BuilderDebugConsole() {
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!selectedFile) {
|
||||
toast.info('请选择需要调试的图片文件')
|
||||
toast.info(t(builderDebugKeys.toasts.pickFile))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -201,21 +289,23 @@ function BuilderDebugConsole() {
|
||||
|
||||
setResult(debugResult)
|
||||
setRunStatus('success')
|
||||
toast.success('调试完成', { description: 'Builder 管线执行成功,产物已清理。' })
|
||||
toast.success(t(builderDebugKeys.toasts.successTitle), {
|
||||
description: t(builderDebugKeys.toasts.successDescription),
|
||||
})
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
toast.info('调试已取消')
|
||||
toast.info(t(builderDebugKeys.toasts.cancelled))
|
||||
setRunStatus('idle')
|
||||
} else {
|
||||
const message = getRequestErrorMessage(error, '调试失败,请检查后重试。')
|
||||
const message = getRequestErrorMessage(error, t(builderDebugKeys.toasts.failureFallback))
|
||||
setErrorMessage(message)
|
||||
setRunStatus('error')
|
||||
toast.error('调试失败', { description: message })
|
||||
toast.error(t(builderDebugKeys.toasts.failureTitle), { description: message })
|
||||
}
|
||||
} finally {
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}, [handleProgressEvent, selectedFile])
|
||||
}, [handleProgressEvent, selectedFile, t])
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!isRunning) {
|
||||
@@ -224,17 +314,17 @@ function BuilderDebugConsole() {
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
setRunStatus('idle')
|
||||
setErrorMessage('调试已被手动取消。')
|
||||
setErrorMessage(t(builderDebugKeys.toasts.manualCancelledMessage))
|
||||
setLogEntries((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'error',
|
||||
message: '手动取消调试任务',
|
||||
message: t(builderDebugKeys.toasts.manualCancelledLog),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
])
|
||||
toast.info('调试已取消')
|
||||
toast.info(t(builderDebugKeys.toasts.cancelled))
|
||||
}
|
||||
|
||||
const handleCopyManifest = async () => {
|
||||
@@ -244,10 +334,10 @@ function BuilderDebugConsole() {
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(manifestJson)
|
||||
toast.success('已复制 manifest 数据')
|
||||
toast.success(t(builderDebugKeys.toasts.copySuccess))
|
||||
} catch (error) {
|
||||
toast.error('复制失败', {
|
||||
description: getRequestErrorMessage(error, '请手动复制内容'),
|
||||
toast.error(t(builderDebugKeys.toasts.copyFailureTitle), {
|
||||
description: getRequestErrorMessage(error, t(builderDebugKeys.toasts.copyFailureDescription)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -260,8 +350,8 @@ function BuilderDebugConsole() {
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-text text-base font-semibold">调试输入</p>
|
||||
<p className="text-text-tertiary text-xs">选择一张原始图片,系统将模拟 Builder 处理链路。</p>
|
||||
<p className="text-text text-base font-semibold">{t(builderDebugKeys.input.title)}</p>
|
||||
<p className="text-text-tertiary text-xs">{t(builderDebugKeys.input.subtitle)}</p>
|
||||
</div>
|
||||
<StatusBadge status={runStatus} />
|
||||
</div>
|
||||
@@ -275,9 +365,9 @@ function BuilderDebugConsole() {
|
||||
>
|
||||
<Upload className="mb-3 h-6 w-6 text-text" />
|
||||
<p className="text-text text-sm font-medium">
|
||||
{selectedFile ? selectedFile.name : '点击或拖拽图片到此区域'}
|
||||
{selectedFile ? selectedFile.name : t(builderDebugKeys.input.placeholder)}
|
||||
</p>
|
||||
<p className="text-text-tertiary mt-1 text-xs">仅支持单张图片,最大 25 MB</p>
|
||||
<p className="text-text-tertiary mt-1 text-xs">{t(builderDebugKeys.input.max)}</p>
|
||||
</label>
|
||||
<input
|
||||
id="builder-debug-file"
|
||||
@@ -293,11 +383,14 @@ function BuilderDebugConsole() {
|
||||
<div>
|
||||
<p className="text-text text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-text-tertiary text-xs mt-0.5">
|
||||
{formatBytes(selectedFile.size)} · {selectedFile.type || 'unknown'}
|
||||
{t(builderDebugKeys.input.fileMeta, {
|
||||
size: formatBytes(selectedFile.size),
|
||||
type: selectedFile.type || t(builderDebugKeys.placeholders.unknown),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={handleClearFile} disabled={isRunning}>
|
||||
清除
|
||||
{t(builderDebugKeys.input.clear)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -307,38 +400,36 @@ function BuilderDebugConsole() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={handleStart} disabled={!selectedFile || isRunning}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
启动调试
|
||||
{t(builderDebugKeys.actions.start)}
|
||||
</Button>
|
||||
{isRunning ? (
|
||||
<Button type="button" variant="ghost" onClick={handleCancel}>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
取消调试
|
||||
{t(builderDebugKeys.actions.cancel)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
执行期间请保持页面开启。调试依赖与 Data Sync 相同的 builder 配置,并实时返回日志。
|
||||
</p>
|
||||
<p className="text-text-tertiary text-xs">{t(builderDebugKeys.notes)}</p>
|
||||
{errorMessage ? <p className="text-rose-400 text-xs">{errorMessage}</p> : null}
|
||||
</section>
|
||||
|
||||
{runMeta ? (
|
||||
<section className="space-y-2 rounded-lg bg-background-secondary/70 px-4 py-3 text-xs">
|
||||
<p className="text-text text-sm font-semibold">最近一次任务</p>
|
||||
<p className="text-text text-sm font-semibold">{t(builderDebugKeys.recent.title)}</p>
|
||||
<div className="space-y-1">
|
||||
<DetailRow label="文件">{runMeta.filename}</DetailRow>
|
||||
<DetailRow label="大小">{formatBytes(runMeta.size)}</DetailRow>
|
||||
<DetailRow label="Storage Key">{runMeta.storageKey}</DetailRow>
|
||||
<DetailRow label={t(builderDebugKeys.recent.file)}>{runMeta.filename}</DetailRow>
|
||||
<DetailRow label={t(builderDebugKeys.recent.size)}>{formatBytes(runMeta.size)}</DetailRow>
|
||||
<DetailRow label={t(builderDebugKeys.recent.storageKey)}>{runMeta.storageKey}</DetailRow>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg bg-fill/10 px-3 py-2 text-[11px] leading-5 text-text-tertiary">
|
||||
<p>⚠️ 调试以安全模式运行:</p>
|
||||
<p>{t(builderDebugKeys.safety.title)}</p>
|
||||
<ul className="mt-1 list-disc pl-4">
|
||||
<li>不写入照片资产数据库记录</li>
|
||||
<li>不在存储中保留任何调试产物</li>
|
||||
<li>所有日志均实时输出,供排查使用</li>
|
||||
<li>{t(builderDebugKeys.safety.noDb)}</li>
|
||||
<li>{t(builderDebugKeys.safety.noStorage)}</li>
|
||||
<li>{t(builderDebugKeys.safety.realtime)}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
@@ -347,10 +438,12 @@ function BuilderDebugConsole() {
|
||||
<LinearBorderPanel className="bg-background-tertiary/70 relative flex min-h-[420px] flex-col rounded-xl p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-text text-base font-semibold">实时日志</p>
|
||||
<p className="text-text-tertiary text-xs">最新 {logEntries.length} 条消息</p>
|
||||
<p className="text-text text-base font-semibold">{t(builderDebugKeys.logs.title)}</p>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
{t(builderDebugKeys.logs.subtitle, { count: logEntries.length })}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-text-tertiary text-xs">来源:Builder + Data Sync Relay</span>
|
||||
<span className="text-text-tertiary text-xs">{t(builderDebugKeys.logs.source)}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -359,7 +452,7 @@ function BuilderDebugConsole() {
|
||||
>
|
||||
{logEntries.length === 0 ? (
|
||||
<div className="text-text-tertiary flex h-full items-center justify-center text-sm">
|
||||
{isRunning ? '正在初始化调试环境...' : '尚无日志'}
|
||||
{isRunning ? t(builderDebugKeys.logs.initializing) : t(builderDebugKeys.logs.empty)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2 text-xs">
|
||||
@@ -378,22 +471,31 @@ function BuilderDebugConsole() {
|
||||
<LinearBorderPanel className="bg-background-tertiary/70 rounded-xl p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-text text-base font-semibold">调试输出</p>
|
||||
<p className="text-text-tertiary text-xs">展示 Builder 返回的 manifest 摘要</p>
|
||||
<p className="text-text text-base font-semibold">{t(builderDebugKeys.output.title)}</p>
|
||||
<p className="text-text-tertiary text-xs">{t(builderDebugKeys.output.subtitle)}</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleCopyManifest} disabled={!manifestJson}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制 manifest
|
||||
{t(builderDebugKeys.output.copy)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
<SummaryTile label="Result Type" value={result.resultType.toUpperCase()} />
|
||||
<SummaryTile label="Storage Key" value={result.storageKey} isMono />
|
||||
<SummaryTile label="缩略图 URL" value={result.thumbnailUrl || '未生成'} isMono />
|
||||
<SummaryTile label="产物已清理" value={result.filesDeleted ? 'Yes' : 'No'} />
|
||||
<SummaryTile label={t(builderDebugKeys.summary.resultType)} value={result.resultType.toUpperCase()} />
|
||||
<SummaryTile label={t(builderDebugKeys.summary.storageKey)} value={result.storageKey} isMono />
|
||||
<SummaryTile
|
||||
label={t(builderDebugKeys.summary.thumbnail)}
|
||||
value={result.thumbnailUrl || t(builderDebugKeys.summary.thumbnailMissing)}
|
||||
isMono
|
||||
/>
|
||||
<SummaryTile
|
||||
label={t(builderDebugKeys.summary.cleaned)}
|
||||
value={
|
||||
result.filesDeleted ? t(builderDebugKeys.summary.cleanedYes) : t(builderDebugKeys.summary.cleanedNo)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{manifestJson ? (
|
||||
@@ -401,11 +503,11 @@ function BuilderDebugConsole() {
|
||||
{manifestJson}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-text-tertiary text-sm">当前任务未生成 manifest 数据。</p>
|
||||
<p className="text-text-tertiary text-sm">{t(builderDebugKeys.output.noManifest)}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-tertiary mt-4 text-sm">运行调试后,这里会显示 manifest 内容与概要。</div>
|
||||
<div className="text-text-tertiary mt-4 text-sm">{t(builderDebugKeys.output.afterRun)}</div>
|
||||
)}
|
||||
</LinearBorderPanel>
|
||||
</div>
|
||||
@@ -423,12 +525,13 @@ function SummaryTile({ label, value, isMono }: { label: string; value: string; i
|
||||
|
||||
function StatusBadge({ status }: { status: RunStatus }) {
|
||||
const config = STATUS_LABEL[status]
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<span className={clsxm('relative inline-flex h-2.5 w-2.5 items-center justify-center', config.className)}>
|
||||
<span className="bg-current inline-flex h-1.5 w-1.5 rounded-full" />
|
||||
</span>
|
||||
<span className={config.className}>{config.label}</span>
|
||||
<span className={config.className}>{t(config.labelKey)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -443,10 +546,13 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
|
||||
}
|
||||
|
||||
function LogPill({ entry }: { entry: DebugLogEntry }) {
|
||||
const { t } = useTranslation()
|
||||
if (entry.type === 'log') {
|
||||
return (
|
||||
<div className={clsxm('min-w-0 flex-1 rounded-lg border px-3 py-2 text-xs', LEVEL_THEME[entry.level])}>
|
||||
<p className="font-semibold uppercase tracking-wide text-[10px]">{entry.level}</p>
|
||||
<p className="font-semibold uppercase tracking-wide text-[10px]">
|
||||
{t(`${builderDebugKeys.logLevelPrefix}${entry.level}`)}
|
||||
</p>
|
||||
<p className="mt-0.5 wrap-break-word text-[11px]">{entry.message}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -458,10 +564,15 @@ function LogPill({ entry }: { entry: DebugLogEntry }) {
|
||||
: entry.type === 'start'
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'bg-emerald-500/10 text-emerald-100'
|
||||
const label = entry.type === 'start' ? 'START' : entry.type === 'complete' ? 'COMPLETE' : 'ERROR'
|
||||
const labelKey =
|
||||
entry.type === 'start'
|
||||
? builderDebugKeys.logStatus.start
|
||||
: entry.type === 'complete'
|
||||
? builderDebugKeys.logStatus.complete
|
||||
: builderDebugKeys.logStatus.error
|
||||
return (
|
||||
<div className={clsxm('min-w-0 flex-1 rounded-lg px-3 py-2 text-xs', tone)}>
|
||||
<p className="font-semibold uppercase tracking-wide text-[10px]">{label}</p>
|
||||
<p className="font-semibold uppercase tracking-wide text-[10px]">{t(labelKey)}</p>
|
||||
<p className="mt-0.5 wrap-break-word text-[11px]">{entry.message}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -470,13 +581,14 @@ function LogPill({ entry }: { entry: DebugLogEntry }) {
|
||||
function buildLogEntry(event: BuilderDebugProgressEvent): DebugLogEntry | null {
|
||||
const id = nanoid()
|
||||
const timestamp = Date.now()
|
||||
const i18n = getI18n()
|
||||
|
||||
switch (event.type) {
|
||||
case 'start': {
|
||||
return {
|
||||
id,
|
||||
type: 'start',
|
||||
message: `上传 ${event.payload.filename},准备执行 Builder`,
|
||||
message: i18n.t(builderDebugKeys.logMessages.start, { filename: event.payload.filename }),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
@@ -484,7 +596,7 @@ function buildLogEntry(event: BuilderDebugProgressEvent): DebugLogEntry | null {
|
||||
return {
|
||||
id,
|
||||
type: 'complete',
|
||||
message: `构建完成 · 结果 ${event.payload.resultType}`,
|
||||
message: i18n.t(builderDebugKeys.logMessages.complete, { resultType: event.payload.resultType }),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { ScrollArea } from '@afilmory/ui'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Navigate, NavLink, Outlet } from 'react-router'
|
||||
|
||||
import { useAuthUserValue, useIsSuperAdmin } from '~/atoms/auth'
|
||||
import { SuperAdminUserMenu } from '~/components/common/SuperAdminUserMenu'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthUserValue()
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const navItems = [
|
||||
{ to: '/superadmin/settings', label: '系统设置', end: true },
|
||||
{ to: '/superadmin/plans', label: '订阅计划', end: true },
|
||||
{ to: '/superadmin/tenants', label: '租户管理', end: true },
|
||||
{ to: '/superadmin/settings', labelKey: 'superadmin.nav.settings', end: true },
|
||||
{ to: '/superadmin/plans', labelKey: 'superadmin.nav.plans', end: true },
|
||||
{ to: '/superadmin/tenants', labelKey: 'superadmin.nav.tenants', end: true },
|
||||
{
|
||||
label: '构建器',
|
||||
labelKey: 'superadmin.nav.builder',
|
||||
to: '/superadmin/builder',
|
||||
end: true,
|
||||
},
|
||||
{ to: '/superadmin/debug', label: 'Builder 调试', end: false },
|
||||
{ to: '/superadmin/debug', labelKey: 'superadmin.nav.builder-debug', end: false },
|
||||
] as const
|
||||
|
||||
if (user && !isSuperAdmin) {
|
||||
@@ -31,7 +33,7 @@ export function Component() {
|
||||
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
{/* Logo/Brand */}
|
||||
<div className="text-text text-base font-semibold">Afilmory · System Settings</div>
|
||||
<div className="text-text text-base font-semibold">{t('superadmin.brand')}</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{navItems.map((tab) => (
|
||||
@@ -41,7 +43,7 @@ export function Component() {
|
||||
className="relative overflow-hidden rounded-md shape-squircle px-3 py-1.5 group data-[state=active]:bg-accent/80 data-[state=active]:text-white"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
>
|
||||
<span className="relative z-10 text-[13px] font-medium">{tab.label}</span>
|
||||
<span className="relative z-10 text-[13px] font-medium">{t(tab.labelKey)}</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SuperAdminSettingsForm } from '~/modules/super-admin'
|
||||
|
||||
const PLAN_SECTION_IDS = ['billing-plan-settings'] as const
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@@ -14,10 +16,8 @@ export function Component() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-text text-2xl font-semibold">订阅计划配置</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
管理各个订阅计划的资源配额、定价信息与 Creem Product 连接,仅超级管理员可编辑。
|
||||
</p>
|
||||
<h1 className="text-text text-2xl font-semibold">{t('superadmin.plans.title')}</h1>
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.plans.description')}</p>
|
||||
</header>
|
||||
|
||||
<SuperAdminSettingsForm visibleSectionIds={PLAN_SECTION_IDS} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SuperAdminSettingsForm } from '~/modules/super-admin'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@@ -12,8 +14,8 @@ export function Component() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-text text-2xl font-semibold">系统设置</h1>
|
||||
<p className="text-text-secondary text-sm">管理整个平台的注册策略与本地登录渠道,由超级管理员统一维护。</p>
|
||||
<h1 className="text-text text-2xl font-semibold">{t('superadmin.settings.title')}</h1>
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.settings.description')}</p>
|
||||
</header>
|
||||
|
||||
<SuperAdminSettingsForm visibleSectionIds={['registration-control', 'oauth-providers']} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SuperAdminTenantManager } from '~/modules/super-admin'
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@@ -12,8 +14,8 @@ export function Component() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-text text-2xl font-semibold">租户订阅管理</h1>
|
||||
<p className="text-text-secondary text-sm">为特定租户切换订阅计划或封禁违规租户。</p>
|
||||
<h1 className="text-text text-2xl font-semibold">{t('superadmin.tenants.title')}</h1>
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.tenants.description')}</p>
|
||||
</header>
|
||||
|
||||
<SuperAdminTenantManager />
|
||||
|
||||
@@ -1,13 +1,345 @@
|
||||
{
|
||||
"access-denied.default-reason": "You do not have permission to access this page.",
|
||||
"analytics.page.description": "Track your photo collection statistics and trends.",
|
||||
"analytics.page.title": "Analytics",
|
||||
"analytics.sections.devices.description": "Popular capture devices derived from EXIF metadata.",
|
||||
"analytics.sections.devices.empty": "No device statistics yet.",
|
||||
"analytics.sections.devices.error": "Unable to load device data. Please try again later.",
|
||||
"analytics.sections.devices.title": "Top Devices",
|
||||
"analytics.sections.storage.current": "Added this month",
|
||||
"analytics.sections.storage.delta.compare": "{{delta}} vs last month",
|
||||
"analytics.sections.storage.delta.equal": "Same as last month",
|
||||
"analytics.sections.storage.delta.first": "First recorded usage",
|
||||
"analytics.sections.storage.description": "Capacity breakdown by storage provider.",
|
||||
"analytics.sections.storage.empty": "No storage usage data.",
|
||||
"analytics.sections.storage.error": "Unable to load storage usage. Please try again later.",
|
||||
"analytics.sections.storage.photos": "Photos",
|
||||
"analytics.sections.storage.provider-meta": "{{percent}}% · {{photoCount}} photos",
|
||||
"analytics.sections.storage.title": "Storage Usage",
|
||||
"analytics.sections.storage.total": "Total usage",
|
||||
"analytics.sections.tags.description": "Most used tags from recent uploads.",
|
||||
"analytics.sections.tags.empty": "No tag statistics yet.",
|
||||
"analytics.sections.tags.error": "Unable to load tag data. Please try again later.",
|
||||
"analytics.sections.tags.title": "Popular Tags",
|
||||
"analytics.sections.upload.best": "Best month",
|
||||
"analytics.sections.upload.compare-equal": "Same as last month",
|
||||
"analytics.sections.upload.current": "Uploads this month",
|
||||
"analytics.sections.upload.description": "Uploads over the past 12 months.",
|
||||
"analytics.sections.upload.empty": "No upload data yet.",
|
||||
"analytics.sections.upload.error": "Unable to load upload trends. Please try again later.",
|
||||
"analytics.sections.upload.first-record": "First recorded uploads",
|
||||
"analytics.sections.upload.growth-equal": "Same as last month",
|
||||
"analytics.sections.upload.title": "Upload Trends",
|
||||
"analytics.sections.upload.tooltip": "{{month}} · {{value}} photos",
|
||||
"analytics.sections.upload.total": "Total uploads",
|
||||
"analytics.units.photos": "{{value}} photos",
|
||||
"app.name": "Afilmory Dashboard",
|
||||
"auth.social.empty.description": "Super admins have not enabled any third-party login methods. This tenant cannot bind OAuth accounts yet.",
|
||||
"auth.social.empty.title": "No OAuth providers configured",
|
||||
"auth.social.error.accounts": "Unable to fetch binding status",
|
||||
"auth.social.error.providers": "Unable to load available OAuth providers",
|
||||
"auth.social.provider.connect": "Link {{provider}}",
|
||||
"auth.social.provider.connected": "Linked · {{time}}",
|
||||
"auth.social.provider.connecting": "Redirecting…",
|
||||
"auth.social.provider.disconnect": "Disconnect",
|
||||
"auth.social.provider.disconnecting": "Disconnecting…",
|
||||
"auth.social.provider.last-warning": "At least one sign-in method must remain linked.",
|
||||
"auth.social.provider.unconnected": "Not linked yet. Use the button below to authorize.",
|
||||
"auth.social.section.description": "Link a provider to log in with that account and sync basic profile data. Disconnecting does not delete the dashboard account.",
|
||||
"auth.social.section.label": "Sign-in methods",
|
||||
"auth.social.section.title": "OAuth connections",
|
||||
"auth.social.toast.connect-failure": "Failed to start linking with {{provider}}",
|
||||
"auth.social.toast.disconnect-failure": "Failed to disconnect",
|
||||
"auth.social.toast.disconnect-success": "Disconnected from {{provider}}",
|
||||
"blocker.unsaved.before-unload": "You have unsaved changes. Are you sure you want to leave?",
|
||||
"blocker.unsaved.cancel": "Stay on page",
|
||||
"blocker.unsaved.confirm": "Leave anyway",
|
||||
"blocker.unsaved.description": "You have unsaved edits. Leaving this page will discard them. Continue?",
|
||||
"blocker.unsaved.title": "Unsaved changes",
|
||||
"builder-settings.button.loading": "Saving…",
|
||||
"builder-settings.button.save": "Save changes",
|
||||
"builder-settings.error.loading": "Unable to load builder settings: {{reason}}",
|
||||
"builder-settings.message.dirty": "You have unsaved changes",
|
||||
"builder-settings.message.error": "Failed to save settings: {{reason}}",
|
||||
"builder-settings.message.idle": "All settings are up to date",
|
||||
"builder-settings.message.saved": "Settings saved successfully",
|
||||
"builder-settings.message.saving": "Saving builder settings…",
|
||||
"builder-settings.message.unknown-error": "Unknown error",
|
||||
"common.retry-later": "Please try again later.",
|
||||
"common.unknown": "unknown",
|
||||
"common.unknown-error": "Unknown error",
|
||||
"dashboard.overview.activity.empty": "No recent activity. Upload photos to see updates here.",
|
||||
"dashboard.overview.activity.error": "Unable to load activity data. Please try again later.",
|
||||
"dashboard.overview.activity.id-label": "ID:",
|
||||
"dashboard.overview.activity.no-preview": "No Preview",
|
||||
"dashboard.overview.activity.size-unknown": "Size unknown",
|
||||
"dashboard.overview.activity.subtitle": "Showing the latest {{count}} uploads and sync events",
|
||||
"dashboard.overview.activity.subtitle-empty": "No uploads yet—add your first photo!",
|
||||
"dashboard.overview.activity.taken-at": "Taken {{time}}",
|
||||
"dashboard.overview.activity.uploaded-at": "Uploaded {{time}}",
|
||||
"dashboard.overview.page.description": "Monitor library health and recent sync activity.",
|
||||
"dashboard.overview.page.title": "Dashboard",
|
||||
"dashboard.overview.section.activity.title": "Recent activity",
|
||||
"dashboard.overview.stats.month.helper.equal": "Same as last month",
|
||||
"dashboard.overview.stats.month.helper.first": "Uploads recorded for the first time",
|
||||
"dashboard.overview.stats.month.helper.less": "{{difference}} fewer than last month",
|
||||
"dashboard.overview.stats.month.helper.more": "{{difference}} more than last month",
|
||||
"dashboard.overview.stats.month.label": "Uploads this month",
|
||||
"dashboard.overview.stats.storage.helper.empty": "No photos yet; storage usage is 0",
|
||||
"dashboard.overview.stats.storage.helper.with-photos": "Average {{average}} per photo",
|
||||
"dashboard.overview.stats.storage.label": "Storage used",
|
||||
"dashboard.overview.stats.sync.helper": "Pending {{pending}} | Conflicts {{conflicts}}",
|
||||
"dashboard.overview.stats.sync.helper-empty": "No sync jobs yet",
|
||||
"dashboard.overview.stats.sync.label": "Sync completion",
|
||||
"dashboard.overview.stats.total.helper": "{{value}} photos",
|
||||
"dashboard.overview.stats.total.label": "Total photos",
|
||||
"dashboard.overview.status.conflict": "Needs attention",
|
||||
"dashboard.overview.status.pending": "Processing",
|
||||
"dashboard.overview.status.synced": "Synced",
|
||||
"dashboard.overview.time.unknown": "Unknown time",
|
||||
"data-management.delete.badge": "Account purge (irreversible)",
|
||||
"data-management.delete.button": "Delete account permanently",
|
||||
"data-management.delete.description": "This removes the tenant, photo records, sync logs, and member permissions from the database. Members are signed out immediately and the operation cannot be undone. Three confirmations are required to avoid mistakes.",
|
||||
"data-management.delete.error.fallback": "Failed to delete the tenant. Please try again later.",
|
||||
"data-management.delete.error.title": "Operation failed",
|
||||
"data-management.delete.loading": "Destroying…",
|
||||
"data-management.delete.note": "To reuse the service later, register a new tenant and re-upload your assets. Object storage files remain, but all database references are removed.",
|
||||
"data-management.delete.step1.cancel": "Cancel",
|
||||
"data-management.delete.step1.confirm": "Continue",
|
||||
"data-management.delete.step1.description": "Deleting will immediately clear all tenant data and sign out every member. This flow has three confirmations for safety.",
|
||||
"data-management.delete.step1.title": "Delete account (Step 1/3)",
|
||||
"data-management.delete.step2.cancel": "Cancel",
|
||||
"data-management.delete.step2.confirm": "I understand the risk",
|
||||
"data-management.delete.step2.description": "This permanently removes photos, settings, sync logs, and member permissions for this tenant.",
|
||||
"data-management.delete.step2.title": "Second confirmation: remove entire account",
|
||||
"data-management.delete.step3.cancel": "Back",
|
||||
"data-management.delete.step3.confirm": "Delete permanently",
|
||||
"data-management.delete.step3.description": "Type DELETE to confirm. This will log out all members and remove unrecoverable data immediately.",
|
||||
"data-management.delete.step3.error.description": "Please enter DELETE to continue.",
|
||||
"data-management.delete.step3.error.title": "Confirmation failed",
|
||||
"data-management.delete.step3.placeholder": "DELETE",
|
||||
"data-management.delete.step3.title": "Final confirmation: permanently delete account",
|
||||
"data-management.delete.success.description": "All data for this tenant has been removed and members were signed out.",
|
||||
"data-management.delete.success.title": "Tenant deleted",
|
||||
"data-management.delete.title": "Delete current tenant and data",
|
||||
"data-management.summary.badge": "Current data overview",
|
||||
"data-management.summary.description": "Statistics are derived from database records only and exclude original files in storage.",
|
||||
"data-management.summary.error": "Unable to load statistics. Please try again later.",
|
||||
"data-management.summary.stats.conflicts.chip": "Needs review",
|
||||
"data-management.summary.stats.conflicts.label": "Conflicts",
|
||||
"data-management.summary.stats.pending.chip": "Queued",
|
||||
"data-management.summary.stats.pending.label": "Pending",
|
||||
"data-management.summary.stats.synced.chip": "Healthy",
|
||||
"data-management.summary.stats.synced.label": "Synced",
|
||||
"data-management.summary.stats.total.chip": "All",
|
||||
"data-management.summary.stats.total.label": "Total records",
|
||||
"data-management.summary.title": "Photo table status",
|
||||
"data-management.truncate.badge": "Dangerous action",
|
||||
"data-management.truncate.button": "Clear database records",
|
||||
"data-management.truncate.description": "Delete every photo record from the database while keeping the original storage files. Useful for resolving inconsistencies or migrations.",
|
||||
"data-management.truncate.error.fallback": "Unable to clear database records. Please try again later.",
|
||||
"data-management.truncate.error.title": "Cleanup failed",
|
||||
"data-management.truncate.loading": "Cleaning…",
|
||||
"data-management.truncate.note": "After this action finishes, run Photo Sync again to rebuild the database and manifest from storage.",
|
||||
"data-management.truncate.prompt.cancel": "Cancel",
|
||||
"data-management.truncate.prompt.confirm": "Clear now",
|
||||
"data-management.truncate.prompt.description": "This removes all photo records from the database but keeps the original objects in storage. You must run Photo Sync afterward.",
|
||||
"data-management.truncate.prompt.title": "Confirm clearing photo tables?",
|
||||
"data-management.truncate.success.deleted": "Marked {{count}} photo records for deletion.",
|
||||
"data-management.truncate.success.empty": "No records needed cleanup.",
|
||||
"data-management.truncate.success.title": "Database cleared",
|
||||
"data-management.truncate.title": "Clear photo tables",
|
||||
"error.boundary.description": "We encountered an unexpected error",
|
||||
"error.boundary.go-back": "Go Back",
|
||||
"error.boundary.help": "If this problem persists, please report it to our team.",
|
||||
"error.boundary.reload": "Reload Application",
|
||||
"error.boundary.report": "Report on GitHub",
|
||||
"error.boundary.title": "Something went wrong",
|
||||
"errors.request.generic": "Request failed. Please try again later.",
|
||||
"header.plan.badge": "Plan",
|
||||
"nav.analytics": "Analytics",
|
||||
"nav.library": "Library",
|
||||
"nav.overview": "Overview",
|
||||
"nav.photos": "Photos",
|
||||
"nav.settings": "Settings",
|
||||
"nav.users": "Users"
|
||||
"nav.users": "Users",
|
||||
"no-access.back-to-login": "Back to Login",
|
||||
"no-access.description": "You don’t have the necessary permissions to access this feature. Contact your workspace admin or switch to an account with access.",
|
||||
"no-access.request-path": "Request path:",
|
||||
"no-access.retry": "Try Again",
|
||||
"no-access.title.forbidden": "Permission denied",
|
||||
"no-access.title.unavailable": "Access unavailable",
|
||||
"photos.actions.conflict": "Conflict",
|
||||
"photos.actions.delete": "Deleted",
|
||||
"photos.actions.error": "Error",
|
||||
"photos.actions.insert": "Inserted",
|
||||
"photos.actions.noop": "Skipped",
|
||||
"photos.actions.update": "Updated",
|
||||
"photos.conflict.generic": "Conflict",
|
||||
"photos.conflict.id.description": "Multiple objects share the same photo ID. Choose the version to keep.",
|
||||
"photos.conflict.id.label": "Photo ID conflict",
|
||||
"photos.conflict.metadata.description": "Storage object metadata differs from the database record. Decide which source should win.",
|
||||
"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.page.description": "Sync and manage photo assets on the server.",
|
||||
"photos.page.title": "Photo Library",
|
||||
"photos.tabs.library": "Library",
|
||||
"photos.tabs.storage": "Storage",
|
||||
"photos.tabs.sync": "Sync",
|
||||
"photos.tabs.usage": "Usage",
|
||||
"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.sync-completed.description": "Summary event when a data sync run finishes.",
|
||||
"photos.usage.sync-completed.label": "Sync completed",
|
||||
"plan.badge.current": "Current Plan",
|
||||
"plan.badge.internal": "Internal Plan",
|
||||
"plan.checkout.coming-soon": "Coming Soon",
|
||||
"plan.checkout.loading": "Please wait…",
|
||||
"plan.checkout.upgrade": "Upgrade Plan",
|
||||
"plan.error.load-prefix": "Unable to load subscription:",
|
||||
"plan.error.unknown": "Unknown error",
|
||||
"plan.page.description": "Review current subscription limits and manage plan upgrades in one place.",
|
||||
"plan.page.title": "Subscription Plans",
|
||||
"plan.portal.loading": "Opening…",
|
||||
"plan.portal.manage": "Manage Subscription",
|
||||
"plan.quotas.label.libraryItemLimit": "Library capacity",
|
||||
"plan.quotas.label.maxSyncObjectSizeMb": "Sync object size",
|
||||
"plan.quotas.label.maxUploadSizeMb": "Upload size",
|
||||
"plan.quotas.label.monthlyAssetProcessLimit": "Monthly processed photos",
|
||||
"plan.quotas.unit.megabytes": "{{value}} MB",
|
||||
"plan.quotas.unit.photos": "{{value}} photos",
|
||||
"plan.quotas.unlimited": "Unlimited",
|
||||
"plan.toast.checkout-failure": "Unable to create checkout session. Please try again later.",
|
||||
"plan.toast.checkout-unavailable": "This plan is not available yet. Please try again soon.",
|
||||
"plan.toast.missing-checkout-url": "Checkout link is unavailable. Please try again later.",
|
||||
"plan.toast.missing-portal-account": "Subscription account not found. Please try again later.",
|
||||
"plan.toast.missing-portal-url": "Portal link is unavailable. Please try again later.",
|
||||
"plan.toast.portal-failure": "Unable to open subscription portal. Please try again later.",
|
||||
"schema-form.secret.helper": "For security, provide a new value only when updating.",
|
||||
"schema-form.secret.hide": "Hide",
|
||||
"schema-form.secret.show": "Show",
|
||||
"schema-form.select.placeholder": "Select an option",
|
||||
"settings.account.description": "Link third-party accounts and sign in to the dashboard with OAuth.",
|
||||
"settings.account.title": "Account & Login",
|
||||
"settings.data.description": "Run database maintenance tasks to keep photo data consistent with object storage.",
|
||||
"settings.data.title": "Data Management",
|
||||
"settings.nav.account": "Account & Login",
|
||||
"settings.nav.data": "Data Management",
|
||||
"settings.nav.site": "Site Settings",
|
||||
"settings.nav.user": "User Profile",
|
||||
"settings.site.description": "Configure branding, social links, and map display for the public site.",
|
||||
"settings.site.title": "Site Settings",
|
||||
"settings.user.description": "Maintain the public author profile, avatar, and aliases.",
|
||||
"settings.user.title": "User Profile",
|
||||
"superadmin.brand": "Afilmory · System Settings",
|
||||
"superadmin.builder-debug.actions.cancel": "Cancel Debug",
|
||||
"superadmin.builder-debug.actions.start": "Run Debug",
|
||||
"superadmin.builder-debug.api.missing-result": "No final debug result was received before the connection ended.",
|
||||
"superadmin.builder-debug.api.request-failed": "Debug request failed: {{status}} {{statusText}}",
|
||||
"superadmin.builder-debug.description": "Run single-image builder pipeline verification without touching persistent data.",
|
||||
"superadmin.builder-debug.input.clear": "Clear",
|
||||
"superadmin.builder-debug.input.file-meta": "{{size}} · {{type}}",
|
||||
"superadmin.builder-debug.input.max": "Only one image, up to 25 MB",
|
||||
"superadmin.builder-debug.input.placeholder": "Click or drag an image here",
|
||||
"superadmin.builder-debug.input.subtitle": "Select a source image to simulate the builder pipeline.",
|
||||
"superadmin.builder-debug.input.title": "Debug input",
|
||||
"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.message.complete": "Build finished · result {{resultType}}",
|
||||
"superadmin.builder-debug.log.message.start": "Uploading {{filename}} · preparing builder run",
|
||||
"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": "No logs yet",
|
||||
"superadmin.builder-debug.logs.initializing": "Preparing the debug environment…",
|
||||
"superadmin.builder-debug.logs.source": "Source: Builder + Data Sync Relay",
|
||||
"superadmin.builder-debug.logs.subtitle": "Latest {{count}} messages",
|
||||
"superadmin.builder-debug.logs.title": "Realtime logs",
|
||||
"superadmin.builder-debug.notes.keep-page-open": "Keep this page open while the task runs. Logs stream directly from the same builder config used by Data Sync.",
|
||||
"superadmin.builder-debug.output.after-run": "Manifest details will appear here after running the debug task.",
|
||||
"superadmin.builder-debug.output.copy": "Copy manifest",
|
||||
"superadmin.builder-debug.output.no-manifest": "This run did not produce manifest data.",
|
||||
"superadmin.builder-debug.output.subtitle": "Review the manifest summary returned by Builder.",
|
||||
"superadmin.builder-debug.output.title": "Debug output",
|
||||
"superadmin.builder-debug.recent.file": "File",
|
||||
"superadmin.builder-debug.recent.size": "Size",
|
||||
"superadmin.builder-debug.recent.storage-key": "Storage Key",
|
||||
"superadmin.builder-debug.recent.title": "Last task",
|
||||
"superadmin.builder-debug.safety.items.no-db": "No photo asset records are written to the database",
|
||||
"superadmin.builder-debug.safety.items.no-storage": "No debug artifacts are kept in storage",
|
||||
"superadmin.builder-debug.safety.items.realtime": "Logs stream in real time for troubleshooting",
|
||||
"superadmin.builder-debug.safety.title": "⚠️ Debug runs in safe mode:",
|
||||
"superadmin.builder-debug.status.error": "Failed",
|
||||
"superadmin.builder-debug.status.idle": "Ready",
|
||||
"superadmin.builder-debug.status.running": "Running",
|
||||
"superadmin.builder-debug.status.success": "Complete",
|
||||
"superadmin.builder-debug.summary.cleaned": "Artifacts cleaned",
|
||||
"superadmin.builder-debug.summary.cleaned-no": "No",
|
||||
"superadmin.builder-debug.summary.cleaned-yes": "Yes",
|
||||
"superadmin.builder-debug.summary.result-type": "Result type",
|
||||
"superadmin.builder-debug.summary.storage-key": "Storage Key",
|
||||
"superadmin.builder-debug.summary.thumbnail": "Thumbnail URL",
|
||||
"superadmin.builder-debug.summary.thumbnail-missing": "Not generated",
|
||||
"superadmin.builder-debug.title": "Builder Debug Console",
|
||||
"superadmin.builder-debug.toast.cancelled": "Debug cancelled",
|
||||
"superadmin.builder-debug.toast.copy-failure.description": "Please copy the content manually.",
|
||||
"superadmin.builder-debug.toast.copy-failure.title": "Copy failed",
|
||||
"superadmin.builder-debug.toast.copy-success": "Manifest copied to clipboard",
|
||||
"superadmin.builder-debug.toast.failure-fallback": "Debug failed, please check and retry.",
|
||||
"superadmin.builder-debug.toast.failure.title": "Debug failed",
|
||||
"superadmin.builder-debug.toast.manual-cancelled-log": "Debug run stopped by user",
|
||||
"superadmin.builder-debug.toast.manual-cancelled-message": "Debug session was cancelled manually.",
|
||||
"superadmin.builder-debug.toast.pick-file": "Select an image before running a debug session.",
|
||||
"superadmin.builder-debug.toast.success.description": "Builder pipeline completed successfully and cleaned up artifacts.",
|
||||
"superadmin.builder-debug.toast.success.title": "Debug finished",
|
||||
"superadmin.builder.title": "Builder Settings",
|
||||
"superadmin.nav.builder": "Builder",
|
||||
"superadmin.nav.builder-debug": "Builder Debug",
|
||||
"superadmin.nav.plans": "Plans",
|
||||
"superadmin.nav.settings": "System Settings",
|
||||
"superadmin.nav.tenants": "Tenants",
|
||||
"superadmin.plans.description": "Manage plan quotas, pricing, and Creem product mappings. Only super admins can edit these settings.",
|
||||
"superadmin.plans.title": "Plan Configuration",
|
||||
"superadmin.settings.button.loading": "Saving…",
|
||||
"superadmin.settings.button.save": "Save changes",
|
||||
"superadmin.settings.description": "Control platform-wide registration policies and local sign-in channels. Managed centrally by super admins.",
|
||||
"superadmin.settings.error.loading": "Unable to load super admin settings: {{reason}}",
|
||||
"superadmin.settings.message.dirty": "You have unsaved changes",
|
||||
"superadmin.settings.message.error": "Failed to save settings: {{reason}}",
|
||||
"superadmin.settings.message.idle": "All settings are up to date",
|
||||
"superadmin.settings.message.saved": "Settings saved successfully",
|
||||
"superadmin.settings.message.saving": "Saving settings…",
|
||||
"superadmin.settings.message.unknown-error": "Unknown error",
|
||||
"superadmin.settings.stats.remaining": "Remaining registration slots",
|
||||
"superadmin.settings.stats.total-users": "Total users",
|
||||
"superadmin.settings.title": "System Settings",
|
||||
"superadmin.tenants.button.ban": "Ban",
|
||||
"superadmin.tenants.button.processing": "Working…",
|
||||
"superadmin.tenants.button.unban": "Unban",
|
||||
"superadmin.tenants.description": "Switch plans for specific tenants or suspend those that violate policies.",
|
||||
"superadmin.tenants.empty": "There are no tenants to manage right now.",
|
||||
"superadmin.tenants.error.loading": "Unable to load tenant data: {{reason}}",
|
||||
"superadmin.tenants.plan.placeholder": "Select a plan",
|
||||
"superadmin.tenants.refresh.button": "Refresh list",
|
||||
"superadmin.tenants.refresh.loading": "Refreshing…",
|
||||
"superadmin.tenants.status.active": "Active",
|
||||
"superadmin.tenants.status.banned": "Banned",
|
||||
"superadmin.tenants.status.inactive": "Inactive",
|
||||
"superadmin.tenants.status.suspended": "Suspended",
|
||||
"superadmin.tenants.table.ban": "Ban",
|
||||
"superadmin.tenants.table.created": "Created",
|
||||
"superadmin.tenants.table.plan": "Plan",
|
||||
"superadmin.tenants.table.status": "Status",
|
||||
"superadmin.tenants.table.tenant": "Tenant",
|
||||
"superadmin.tenants.title": "Tenant Subscription Management",
|
||||
"superadmin.tenants.toast.ban-error": "Failed to update ban status.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1,13 +1,345 @@
|
||||
{
|
||||
"access-denied.default-reason": "您没有权限访问该页面",
|
||||
"analytics.page.description": "追踪照片集合的统计与趋势。",
|
||||
"analytics.page.title": "数据分析",
|
||||
"analytics.sections.devices.description": "根据 EXIF 信息统计的热门拍摄设备。",
|
||||
"analytics.sections.devices.empty": "暂无设备统计数据。",
|
||||
"analytics.sections.devices.error": "无法加载设备数据,请稍后再试。",
|
||||
"analytics.sections.devices.title": "热门设备",
|
||||
"analytics.sections.storage.current": "本月新增",
|
||||
"analytics.sections.storage.delta.compare": "{{delta}} 对比上月",
|
||||
"analytics.sections.storage.delta.equal": "与上月持平",
|
||||
"analytics.sections.storage.delta.first": "首次记录",
|
||||
"analytics.sections.storage.description": "按存储提供方统计的容量占比。",
|
||||
"analytics.sections.storage.empty": "暂无存储使用数据。",
|
||||
"analytics.sections.storage.error": "无法加载存储数据,请稍后再试。",
|
||||
"analytics.sections.storage.photos": "照片数量",
|
||||
"analytics.sections.storage.provider-meta": "{{percent}}% · {{photoCount}} 张",
|
||||
"analytics.sections.storage.title": "存储使用",
|
||||
"analytics.sections.storage.total": "总占用",
|
||||
"analytics.sections.tags.description": "最近上传中最常使用的标签。",
|
||||
"analytics.sections.tags.empty": "暂无标签统计数据。",
|
||||
"analytics.sections.tags.error": "无法加载标签数据,请稍后再试。",
|
||||
"analytics.sections.tags.title": "热门标签",
|
||||
"analytics.sections.upload.best": "表现最佳",
|
||||
"analytics.sections.upload.compare-equal": "与上月持平",
|
||||
"analytics.sections.upload.current": "本月上传",
|
||||
"analytics.sections.upload.description": "近 12 个月的上传趋势。",
|
||||
"analytics.sections.upload.empty": "暂无上传记录。",
|
||||
"analytics.sections.upload.error": "无法加载上传趋势,请稍后再试。",
|
||||
"analytics.sections.upload.first-record": "首次出现上传记录",
|
||||
"analytics.sections.upload.growth-equal": "与上月持平",
|
||||
"analytics.sections.upload.title": "上传趋势",
|
||||
"analytics.sections.upload.tooltip": "{{month}} · {{value}} 张",
|
||||
"analytics.sections.upload.total": "累计上传",
|
||||
"analytics.units.photos": "{{value}} 张",
|
||||
"app.name": "Afilmory 管理后台",
|
||||
"auth.social.empty.description": "超级管理员尚未在系统设置中启用任何第三方登录方式,当前租户无法执行 OAuth 绑定。",
|
||||
"auth.social.empty.title": "未配置可用的 OAuth Provider",
|
||||
"auth.social.error.accounts": "无法查询绑定状态",
|
||||
"auth.social.error.providers": "无法加载可用的 OAuth Provider",
|
||||
"auth.social.provider.connect": "绑定 {{provider}}",
|
||||
"auth.social.provider.connected": "已绑定 · {{time}}",
|
||||
"auth.social.provider.connecting": "跳转中…",
|
||||
"auth.social.provider.disconnect": "解除绑定",
|
||||
"auth.social.provider.disconnecting": "解绑中…",
|
||||
"auth.social.provider.last-warning": "需要至少保留一个已绑定的登录方式。",
|
||||
"auth.social.provider.unconnected": "尚未绑定,点击下方按钮完成授权。",
|
||||
"auth.social.section.description": "绑定后即可使用对应平台的账号快速登录后台,并同步基础资料。解除绑定不会删除原有后台账号。",
|
||||
"auth.social.section.label": "登录方式",
|
||||
"auth.social.section.title": "OAuth 账号绑定",
|
||||
"auth.social.toast.connect-failure": "无法开启 {{provider}} 绑定",
|
||||
"auth.social.toast.disconnect-failure": "解绑失败",
|
||||
"auth.social.toast.disconnect-success": "已解除与 {{provider}} 的绑定",
|
||||
"blocker.unsaved.before-unload": "您有未保存的更改,确定要离开吗?",
|
||||
"blocker.unsaved.cancel": "留在此页",
|
||||
"blocker.unsaved.confirm": "继续离开",
|
||||
"blocker.unsaved.description": "离开当前页面会丢失未保存的更改,确定要继续吗?",
|
||||
"blocker.unsaved.title": "您有尚未保存的变更",
|
||||
"builder-settings.button.loading": "保存中…",
|
||||
"builder-settings.button.save": "保存修改",
|
||||
"builder-settings.error.loading": "无法加载构建器设置:{{reason}}",
|
||||
"builder-settings.message.dirty": "您有尚未保存的更改",
|
||||
"builder-settings.message.error": "保存失败:{{reason}}",
|
||||
"builder-settings.message.idle": "所有设置已同步",
|
||||
"builder-settings.message.saved": "保存成功,配置已更新",
|
||||
"builder-settings.message.saving": "正在保存构建器设置…",
|
||||
"builder-settings.message.unknown-error": "未知错误",
|
||||
"common.retry-later": "请稍后再试",
|
||||
"common.unknown": "未知",
|
||||
"common.unknown-error": "未知错误",
|
||||
"dashboard.overview.activity.empty": "暂无最近活动,上传照片后即可看到这里的动态。",
|
||||
"dashboard.overview.activity.error": "无法获取活动数据,请稍后再试。",
|
||||
"dashboard.overview.activity.id-label": "ID:",
|
||||
"dashboard.overview.activity.no-preview": "暂无预览",
|
||||
"dashboard.overview.activity.size-unknown": "大小未知",
|
||||
"dashboard.overview.activity.subtitle": "展示最近 {{count}} 次上传和同步记录",
|
||||
"dashboard.overview.activity.subtitle-empty": "还没有任何上传,快来添加第一张照片吧~",
|
||||
"dashboard.overview.activity.taken-at": "拍摄时间 {{time}}",
|
||||
"dashboard.overview.activity.uploaded-at": "上传于 {{time}}",
|
||||
"dashboard.overview.page.description": "掌握图库运行状态与最近同步活动",
|
||||
"dashboard.overview.page.title": "Dashboard",
|
||||
"dashboard.overview.section.activity.title": "最近活动",
|
||||
"dashboard.overview.stats.month.helper.equal": "与上月持平",
|
||||
"dashboard.overview.stats.month.helper.first": "首次出现上传记录",
|
||||
"dashboard.overview.stats.month.helper.less": "比上月少 {{difference}} 张",
|
||||
"dashboard.overview.stats.month.helper.more": "比上月多 {{difference}} 张",
|
||||
"dashboard.overview.stats.month.label": "本月新增",
|
||||
"dashboard.overview.stats.storage.helper.empty": "暂无照片,存储占用为 0",
|
||||
"dashboard.overview.stats.storage.helper.with-photos": "平均每张 {{average}}",
|
||||
"dashboard.overview.stats.storage.label": "占用存储",
|
||||
"dashboard.overview.stats.sync.helper": "待处理 {{pending}} | 冲突 {{conflicts}}",
|
||||
"dashboard.overview.stats.sync.helper-empty": "暂无同步任务",
|
||||
"dashboard.overview.stats.sync.label": "同步完成率",
|
||||
"dashboard.overview.stats.total.helper": "{{value}} 张照片",
|
||||
"dashboard.overview.stats.total.label": "照片总数",
|
||||
"dashboard.overview.status.conflict": "需关注",
|
||||
"dashboard.overview.status.pending": "处理中",
|
||||
"dashboard.overview.status.synced": "已同步",
|
||||
"dashboard.overview.time.unknown": "时间未知",
|
||||
"data-management.delete.badge": "账户清除(不可逆)",
|
||||
"data-management.delete.button": "永久删除账户",
|
||||
"data-management.delete.description": "此操作会在数据库中彻底删除当前租户、照片记录、同步日志、权限成员等所有信息。执行后将登出所有成员并无法恢复,系统会强制进行三次确认以避免误操作。",
|
||||
"data-management.delete.error.fallback": "删除账户失败,请稍后再试。",
|
||||
"data-management.delete.error.title": "操作失败",
|
||||
"data-management.delete.loading": "正在销毁…",
|
||||
"data-management.delete.note": "如需在未来重新使用本服务,需要重新注册新的租户并重新上传所有资产。该操作不会删除对象存储中的原始文件,但会移除与之关联的所有数据库记录。",
|
||||
"data-management.delete.step1.cancel": "取消",
|
||||
"data-management.delete.step1.confirm": "继续下一步",
|
||||
"data-management.delete.step1.description": "删除后会立即清空当前租户下的所有数据并登出所有成员。此过程包含 3 次确认以确保安全。",
|
||||
"data-management.delete.step1.title": "删除账户(步骤 1/3)",
|
||||
"data-management.delete.step2.cancel": "取消",
|
||||
"data-management.delete.step2.confirm": "我已知晓风险",
|
||||
"data-management.delete.step2.description": "将彻底清除当前租户的照片、设置、同步记录以及所有成员权限,且无法撤销。",
|
||||
"data-management.delete.step2.title": "二次确认:删除整个账户",
|
||||
"data-management.delete.step3.cancel": "返回",
|
||||
"data-management.delete.step3.confirm": "永久删除",
|
||||
"data-management.delete.step3.description": "请输入 DELETE 以确认。本操作会立即注销所有成员并删除不可恢复的数据。",
|
||||
"data-management.delete.step3.error.description": "请输入 DELETE 以继续。",
|
||||
"data-management.delete.step3.error.title": "确认失败",
|
||||
"data-management.delete.step3.placeholder": "DELETE",
|
||||
"data-management.delete.step3.title": "最终确认:永久删除账户",
|
||||
"data-management.delete.success.description": "已清理当前租户下的全部数据,并登出所有成员。",
|
||||
"data-management.delete.success.title": "账户已删除",
|
||||
"data-management.delete.title": "删除当前租户与所有数据",
|
||||
"data-management.summary.badge": "当前数据概况",
|
||||
"data-management.summary.description": "以下统计来自数据库记录,不含对象存储中的原始文件。",
|
||||
"data-management.summary.error": "无法加载数据统计,请稍后再试。",
|
||||
"data-management.summary.stats.conflicts.chip": "需处理",
|
||||
"data-management.summary.stats.conflicts.label": "冲突",
|
||||
"data-management.summary.stats.pending.chip": "排队中",
|
||||
"data-management.summary.stats.pending.label": "待同步",
|
||||
"data-management.summary.stats.synced.chip": "正常",
|
||||
"data-management.summary.stats.synced.label": "已同步",
|
||||
"data-management.summary.stats.total.chip": "全部",
|
||||
"data-management.summary.stats.total.label": "总记录",
|
||||
"data-management.summary.title": "照片数据表状态",
|
||||
"data-management.truncate.badge": "危险操作",
|
||||
"data-management.truncate.button": "清空数据库记录",
|
||||
"data-management.truncate.description": "删除数据库中的所有照片记录,仅保留对象存储文件。通常用于处理数据不一致、重新同步或迁移场景。",
|
||||
"data-management.truncate.error.fallback": "无法清空数据库记录,请稍后再试。",
|
||||
"data-management.truncate.error.title": "清理失败",
|
||||
"data-management.truncate.loading": "清理中…",
|
||||
"data-management.truncate.note": "操作完成后请立即重新执行「照片同步」,以便使用存储中的原始文件重建数据库与 manifest。",
|
||||
"data-management.truncate.prompt.cancel": "取消",
|
||||
"data-management.truncate.prompt.confirm": "立即清空",
|
||||
"data-management.truncate.prompt.description": "该操作会删除数据库中的所有照片记录,但会保留对象存储中的原始文件。清空后需要重新执行一次照片同步。",
|
||||
"data-management.truncate.prompt.title": "确认清空照片数据表?",
|
||||
"data-management.truncate.success.deleted": "已标记删除 {{count}} 条照片记录。",
|
||||
"data-management.truncate.success.empty": "没有可清理的数据表记录。",
|
||||
"data-management.truncate.success.title": "数据库记录已清空",
|
||||
"data-management.truncate.title": "清空照片数据表",
|
||||
"error.boundary.description": "我们遇到了一个意料之外的错误",
|
||||
"error.boundary.go-back": "返回上一页",
|
||||
"error.boundary.help": "如果问题持续出现,请反馈给我们的团队。",
|
||||
"error.boundary.reload": "重新加载",
|
||||
"error.boundary.report": "在 GitHub 上反馈",
|
||||
"error.boundary.title": "系统出现问题",
|
||||
"errors.request.generic": "请求失败,请稍后重试。",
|
||||
"header.plan.badge": "订阅",
|
||||
"nav.analytics": "数据分析",
|
||||
"nav.library": "图库",
|
||||
"nav.overview": "概览",
|
||||
"nav.photos": "照片",
|
||||
"nav.settings": "设置",
|
||||
"nav.users": "成员"
|
||||
"nav.users": "成员",
|
||||
"no-access.back-to-login": "返回登录",
|
||||
"no-access.description": "你当前的账号没有访问该功能所需的权限,请联系工作区管理员,或切换到拥有权限的账号后重试。",
|
||||
"no-access.request-path": "请求路径:",
|
||||
"no-access.retry": "重新尝试",
|
||||
"no-access.title.forbidden": "权限不足",
|
||||
"no-access.title.unavailable": "无法访问",
|
||||
"photos.actions.conflict": "冲突",
|
||||
"photos.actions.delete": "删除",
|
||||
"photos.actions.error": "错误",
|
||||
"photos.actions.insert": "新增",
|
||||
"photos.actions.noop": "跳过",
|
||||
"photos.actions.update": "更新",
|
||||
"photos.conflict.generic": "冲突",
|
||||
"photos.conflict.id.description": "同一个照片 ID 检测到多个对象,请选择保留的版本。",
|
||||
"photos.conflict.id.label": "照片 ID 冲突",
|
||||
"photos.conflict.metadata.description": "存储对象与数据库记录的元数据不一致,需要确认以哪个为准。",
|
||||
"photos.conflict.metadata.label": "元数据不一致",
|
||||
"photos.conflict.missing.description": "数据库存在记录,但对应的存储对象已无法访问。",
|
||||
"photos.conflict.missing.label": "存储缺失",
|
||||
"photos.page.description": "在此同步和管理服务器中的照片资产。",
|
||||
"photos.page.title": "照片库",
|
||||
"photos.tabs.library": "图库管理",
|
||||
"photos.tabs.storage": "素材存储",
|
||||
"photos.tabs.sync": "存储同步",
|
||||
"photos.tabs.usage": "用量记录",
|
||||
"photos.usage.photo-created.description": "通过上传或同步新增的照片资产。",
|
||||
"photos.usage.photo-created.label": "新增照片",
|
||||
"photos.usage.photo-deleted.description": "从图库或存储中移除的照片资产。",
|
||||
"photos.usage.photo-deleted.label": "删除照片",
|
||||
"photos.usage.sync-completed.description": "一次数据同步执行完成时记录的汇总事件。",
|
||||
"photos.usage.sync-completed.label": "同步运行",
|
||||
"plan.badge.current": "当前方案",
|
||||
"plan.badge.internal": "内部方案",
|
||||
"plan.checkout.coming-soon": "敬请期待",
|
||||
"plan.checkout.loading": "请稍候…",
|
||||
"plan.checkout.upgrade": "升级此方案",
|
||||
"plan.error.load-prefix": "无法加载订阅信息:",
|
||||
"plan.error.unknown": "未知错误",
|
||||
"plan.page.description": "查看当前订阅状态与资源限制,并在此处发起升级或管理订阅。",
|
||||
"plan.page.title": "订阅计划",
|
||||
"plan.portal.loading": "打开中…",
|
||||
"plan.portal.manage": "管理订阅",
|
||||
"plan.quotas.label.libraryItemLimit": "图库容量",
|
||||
"plan.quotas.label.maxSyncObjectSizeMb": "同步素材大小",
|
||||
"plan.quotas.label.maxUploadSizeMb": "单次上传大小",
|
||||
"plan.quotas.label.monthlyAssetProcessLimit": "每月可新增照片",
|
||||
"plan.quotas.unit.megabytes": "{{value}} MB",
|
||||
"plan.quotas.unit.photos": "{{value}} 张",
|
||||
"plan.quotas.unlimited": "无限制",
|
||||
"plan.toast.checkout-failure": "无法创建订阅结算会话,请稍后再试。",
|
||||
"plan.toast.checkout-unavailable": "该方案暂未开放,请稍后再试。",
|
||||
"plan.toast.missing-checkout-url": "Creem 未返回有效的结算链接,请稍后再试。",
|
||||
"plan.toast.missing-portal-account": "找不到订阅账户,请稍后再试。",
|
||||
"plan.toast.missing-portal-url": "Creem 未返回订阅管理地址,请稍后再试。",
|
||||
"plan.toast.portal-failure": "无法打开订阅管理,请稍后再试。",
|
||||
"schema-form.secret.helper": "出于安全考虑,仅在更新时填写新的值。",
|
||||
"schema-form.secret.hide": "隐藏",
|
||||
"schema-form.secret.show": "显示",
|
||||
"schema-form.select.placeholder": "请选择",
|
||||
"settings.account.description": "绑定第三方账号,使用 OAuth 快速登录后台。",
|
||||
"settings.account.title": "账号与登录",
|
||||
"settings.data.description": "执行数据库级别的维护操作,以保持照片数据与对象存储一致。",
|
||||
"settings.data.title": "数据管理",
|
||||
"settings.nav.account": "账号与登录",
|
||||
"settings.nav.data": "数据管理",
|
||||
"settings.nav.site": "站点设置",
|
||||
"settings.nav.user": "用户信息",
|
||||
"settings.site.description": "配置前台站点的品牌信息、社交渠道与地图展示。",
|
||||
"settings.site.title": "站点设置",
|
||||
"settings.user.description": "维护展示在前台的作者资料、头像与别名。",
|
||||
"settings.user.title": "用户信息",
|
||||
"superadmin.brand": "Afilmory · 系统管理",
|
||||
"superadmin.builder-debug.actions.cancel": "取消调试",
|
||||
"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.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.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.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.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.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.safety.title": "⚠️ 调试以安全模式运行:",
|
||||
"superadmin.builder-debug.status.error": "失败",
|
||||
"superadmin.builder-debug.status.idle": "就绪",
|
||||
"superadmin.builder-debug.status.running": "调试中",
|
||||
"superadmin.builder-debug.status.success": "已完成",
|
||||
"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.thumbnail": "缩略图 URL",
|
||||
"superadmin.builder-debug.summary.thumbnail-missing": "未生成",
|
||||
"superadmin.builder-debug.title": "Builder 调试工具",
|
||||
"superadmin.builder-debug.toast.cancelled": "调试已取消",
|
||||
"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.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.success.title": "调试完成",
|
||||
"superadmin.builder.title": "构建器设置",
|
||||
"superadmin.nav.builder": "构建器",
|
||||
"superadmin.nav.builder-debug": "Builder 调试",
|
||||
"superadmin.nav.plans": "订阅计划",
|
||||
"superadmin.nav.settings": "系统设置",
|
||||
"superadmin.nav.tenants": "租户管理",
|
||||
"superadmin.plans.description": "管理各个订阅计划的资源配额、定价信息与 Creem Product 连接,仅超级管理员可编辑。",
|
||||
"superadmin.plans.title": "订阅计划配置",
|
||||
"superadmin.settings.button.loading": "保存中...",
|
||||
"superadmin.settings.button.save": "保存修改",
|
||||
"superadmin.settings.description": "管理整个平台的注册策略与本地登录渠道,由超级管理员统一维护。",
|
||||
"superadmin.settings.error.loading": "无法加载超级管理员设置:{{reason}}",
|
||||
"superadmin.settings.message.dirty": "您有尚未保存的变更",
|
||||
"superadmin.settings.message.error": "保存失败:{{reason}}",
|
||||
"superadmin.settings.message.idle": "所有设置已同步",
|
||||
"superadmin.settings.message.saved": "保存成功,设置已更新",
|
||||
"superadmin.settings.message.saving": "正在保存设置...",
|
||||
"superadmin.settings.message.unknown-error": "未知错误",
|
||||
"superadmin.settings.stats.remaining": "剩余可注册名额",
|
||||
"superadmin.settings.stats.total-users": "当前用户总数",
|
||||
"superadmin.settings.title": "系统设置",
|
||||
"superadmin.tenants.button.ban": "封禁",
|
||||
"superadmin.tenants.button.processing": "处理中…",
|
||||
"superadmin.tenants.button.unban": "解除封禁",
|
||||
"superadmin.tenants.description": "为特定租户切换订阅计划或封禁违规租户。",
|
||||
"superadmin.tenants.empty": "当前没有可管理的租户。",
|
||||
"superadmin.tenants.error.loading": "无法加载租户数据:{{reason}}",
|
||||
"superadmin.tenants.plan.placeholder": "选择订阅计划",
|
||||
"superadmin.tenants.refresh.button": "刷新列表",
|
||||
"superadmin.tenants.refresh.loading": "正在刷新…",
|
||||
"superadmin.tenants.status.active": "活跃",
|
||||
"superadmin.tenants.status.banned": "已封禁",
|
||||
"superadmin.tenants.status.inactive": "未激活",
|
||||
"superadmin.tenants.status.suspended": "已暂停",
|
||||
"superadmin.tenants.table.ban": "封禁",
|
||||
"superadmin.tenants.table.created": "创建时间",
|
||||
"superadmin.tenants.table.plan": "订阅计划",
|
||||
"superadmin.tenants.table.status": "状态",
|
||||
"superadmin.tenants.table.tenant": "租户",
|
||||
"superadmin.tenants.title": "租户订阅管理",
|
||||
"superadmin.tenants.toast.ban-error": "更新封禁状态失败",
|
||||
"superadmin.tenants.toast.ban-success": "已封禁租户 {{name}}",
|
||||
"superadmin.tenants.toast.plan-error": "更新订阅失败",
|
||||
"superadmin.tenants.toast.plan-success": "已将 {{name}} 切换到 {{planId}} 计划",
|
||||
"superadmin.tenants.toast.unban-success": "已解除封禁 {{name}}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user