feat(data-management): add data management module for photo asset maintenance

- Introduced the DataManagementModule, including a controller and service for managing photo asset records.
- Implemented functionality to truncate photo asset records from the database, enhancing data management capabilities.
- Updated existing photo asset deletion logic to support optional deletion from storage.
- Added a new DataManagementPanel in the dashboard for user interaction with data management features.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-14 16:38:15 +08:00
parent 2b30668072
commit 5e4b4bb4d1
33 changed files with 628 additions and 72 deletions

View File

@@ -149,7 +149,7 @@ export class PhotoAssetService {
return summary
}
async deleteAssets(ids: readonly string[]): Promise<void> {
async deleteAssets(ids: readonly string[], options?: { deleteFromStorage?: boolean }): Promise<void> {
if (ids.length === 0) {
return
}
@@ -166,14 +166,20 @@ export class PhotoAssetService {
return
}
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const storageManager = this.createStorageManager(builderConfig, storageConfig)
const thumbnailRemotePrefix = this.resolveThumbnailRemotePrefix(storageConfig)
const deletedThumbnailKeys = new Set<string>()
const deletedVideoKeys = new Set<string>()
const shouldDeleteFromStorage = options?.deleteFromStorage === true
if (shouldDeleteFromStorage) {
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const storageManager = this.createStorageManager(builderConfig, storageConfig)
const thumbnailRemotePrefix = this.resolveThumbnailRemotePrefix(storageConfig)
const deletedThumbnailKeys = new Set<string>()
const deletedVideoKeys = new Set<string>()
for (const record of records) {
if (record.storageProvider === DATABASE_ONLY_PROVIDER) {
continue
}
for (const record of records) {
if (record.storageProvider !== DATABASE_ONLY_PROVIDER) {
try {
await storageManager.deleteFile(record.storageKey)
} catch (error) {

View File

@@ -8,7 +8,8 @@ import type { PhotoAssetListItem, PhotoAssetSummary } from './photo-asset.servic
import { PhotoAssetService } from './photo-asset.service'
type DeleteAssetsDto = {
ids: string[]
ids?: string[]
deleteFromStorage?: boolean
}
@Controller('photos')
@@ -29,8 +30,9 @@ export class PhotoController {
@Delete('assets')
async deleteAssets(@Body() body: DeleteAssetsDto) {
const ids = Array.isArray(body?.ids) ? body.ids : []
await this.photoAssetService.deleteAssets(ids)
return { ids, deleted: true }
const deleteFromStorage = body?.deleteFromStorage === true
await this.photoAssetService.deleteAssets(ids, { deleteFromStorage })
return { ids, deleted: true, deleteFromStorage }
}
@Post('assets/upload')

View File

@@ -26,6 +26,7 @@ import { DataSyncModule } from './infrastructure/data-sync/data-sync.module'
import { StaticWebModule } from './infrastructure/static-web/static-web.module'
import { AuthModule } from './platform/auth/auth.module'
import { DashboardModule } from './platform/dashboard/dashboard.module'
import { DataManagementModule } from './platform/data-management/data-management.module'
import { SuperAdminModule } from './platform/super-admin/super-admin.module'
import { TenantModule } from './platform/tenant/tenant.module'
@@ -55,6 +56,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
PhotoModule,
ReactionModule,
DashboardModule,
DataManagementModule,
TenantModule,
DataSyncModule,
FeedModule,

View File

@@ -365,12 +365,33 @@ export class AuthController {
}
@AllowPlaceholderTenant()
@SkipTenantGuard()
@Get('/callback/*')
async callback(@ContextParam() context: Context) {
const query = context.req.query()
const { tenantSlug } = query
const reqUrl = new URL(context.req.url)
if (tenantSlug) {
reqUrl.hostname = `${tenantSlug}.${reqUrl.hostname}`
reqUrl.searchParams.delete('tenantSlug')
return context.redirect(reqUrl.toString(), 302)
}
return await this.auth.handler(context)
}
@AllowPlaceholderTenant()
@SkipTenantGuard()
@Get('/*')
async passthroughGet(@ContextParam() context: Context) {
return await this.auth.handler(context)
}
@AllowPlaceholderTenant()
@SkipTenantGuard()
@Post('/*')
async passthroughPost(@ContextParam() context: Context) {
return await this.auth.handler(context)

View File

@@ -0,0 +1,15 @@
import { Controller, Post } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
import { DataManagementService } from './data-management.service'
@Controller('data-management')
@Roles('admin')
export class DataManagementController {
constructor(private readonly dataManagementService: DataManagementService) {}
@Post('photo-assets/truncate')
async truncatePhotoAssetRecords() {
return await this.dataManagementService.clearPhotoAssetRecords()
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@afilmory/framework'
import { DataManagementController } from './data-management.controller'
import { DataManagementService } from './data-management.service'
@Module({
controllers: [DataManagementController],
providers: [DataManagementService],
})
export class DataManagementModule {}

View File

@@ -0,0 +1,32 @@
import { photoAssets } from '@afilmory/db'
import { EventEmitterService } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
@injectable()
export class DataManagementService {
constructor(
private readonly dbAccessor: DbAccessor,
private readonly eventEmitter: EventEmitterService,
) {}
async clearPhotoAssetRecords(): Promise<{ deleted: number }> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const deletedRecords = await db
.delete(photoAssets)
.where(eq(photoAssets.tenantId, tenant.tenant.id))
.returning({ id: photoAssets.id })
if (deletedRecords.length > 0) {
await this.eventEmitter.emit('photo.manifest.changed', { tenantId: tenant.tenant.id })
}
return {
deleted: deletedRecords.length,
}
}
}

View File

@@ -61,8 +61,8 @@ export function UserMenu({ user }: UserMenuProps) {
{/* User Info - Hidden on small screens */}
<div className="hidden text-left md:block">
<div className="text-text text-[13px] leading-tight font-medium">{user.name || user.email}</div>
<div className="text-text-tertiary text-[11px] leading-tight capitalize">{user.role}</div>
<div className="text-text text-sm leading-tight font-medium">{user.name || user.email}</div>
<div className="text-text-tertiary text-[10px] leading-tight capitalize">{user.role}</div>
</div>
{/* Chevron Icon */}

View File

@@ -0,0 +1,11 @@
import { coreApi } from '~/lib/api-client'
type TruncatePhotoAssetsResponse = {
deleted: number
}
export async function truncatePhotoAssetRecords() {
return await coreApi<TruncatePhotoAssetsResponse>('/data-management/photo-assets/truncate', {
method: 'POST',
})
}

View File

@@ -0,0 +1,114 @@
import { Button, Prompt } from '@afilmory/ui'
import { clsxm } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { usePhotoAssetSummaryQuery } from '~/modules/photos/hooks'
import { useTruncatePhotoAssetsMutation } from '../hooks'
const SUMMARY_PLACEHOLDER = {
total: 0,
synced: 0,
pending: 0,
conflicts: 0,
}
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
const numberFormatter = new Intl.NumberFormat('zh-CN')
export function DataManagementPanel() {
const summaryQuery = usePhotoAssetSummaryQuery()
const summary = summaryQuery.data ?? SUMMARY_PLACEHOLDER
const truncateMutation = useTruncatePhotoAssetsMutation()
const handleTruncate = () => {
if (truncateMutation.isPending) {
return
}
Prompt.prompt({
title: '确认清空照片数据表?',
description: '该操作会删除数据库中的所有照片记录,但会保留对象存储中的原始文件。清空后需要重新执行一次照片同步。',
variant: 'danger',
onConfirmText: '立即清空',
onCancelText: '取消',
onConfirm: () => truncateMutation.mutateAsync().then(() => {}),
})
}
return (
<div className="space-y-6">
<LinearBorderPanel className="rounded-3xl bg-background-secondary/40 p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<span className="shape-squircle inline-flex items-center gap-2 bg-accent/10 px-3 py-1 text-xs font-medium text-accent">
<DynamicIcon name="database" className="h-4 w-4" />
</span>
<div className="space-y-2">
<h3 className="text-text text-xl font-semibold"></h3>
<p className="text-text-secondary text-sm"></p>
</div>
{summaryQuery.isError ? <p className="text-red text-sm"></p> : null}
</div>
<div className="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4">
{SUMMARY_STATS.map((stat) => (
<div
key={stat.id}
className={clsxm(
'rounded-2xl border border-white/5 bg-background-tertiary/60 px-4 py-3 shadow-sm backdrop-blur',
summaryQuery.isLoading && 'animate-pulse',
)}
>
<div className="flex items-center justify-between text-[11px] text-text-tertiary">
<span>{stat.label}</span>
<span className="shape-squircle bg-white/5 px-2 py-0.5 font-medium text-white/80">{stat.chip}</span>
</div>
<div className={clsxm('mt-2 text-2xl font-semibold', stat.accent)}>
{summaryQuery.isLoading ? '—' : numberFormatter.format(summary[stat.id])}
</div>
</div>
))}
</div>
</div>
</LinearBorderPanel>
<LinearBorderPanel className="rounded-3xl bg-background-secondary/40 p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2 text-red">
<DynamicIcon name="triangle-alert" className="h-4 w-4" />
<span className="text-sm font-semibold"></span>
</div>
<div>
<h4 className="text-text text-lg font-semibold"></h4>
<p className="text-text-secondary text-sm">
</p>
</div>
</div>
<Button
type="button"
variant="destructive"
size="sm"
isLoading={truncateMutation.isPending}
loadingText="清理中…"
onClick={handleTruncate}
>
</Button>
</div>
<p className="text-text-tertiary mt-4 text-xs">
便使 manifest
</p>
</LinearBorderPanel>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getRequestErrorMessage } from '~/lib/errors'
import { DASHBOARD_OVERVIEW_QUERY_KEY } from '~/modules/dashboard/hooks'
import { PHOTO_ASSET_LIST_QUERY_KEY, PHOTO_ASSET_SUMMARY_QUERY_KEY } from '~/modules/photos/hooks'
import { truncatePhotoAssetRecords } from './api'
export function useTruncatePhotoAssetsMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: truncatePhotoAssetRecords,
onSuccess: async (result) => {
toast.success('数据库记录已清空', {
description: result.deleted > 0 ? `已标记删除 ${result.deleted} 条照片记录。` : '没有可清理的数据表记录。',
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: PHOTO_ASSET_LIST_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: PHOTO_ASSET_SUMMARY_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: DASHBOARD_OVERVIEW_QUERY_KEY }),
])
},
onError: (error) => {
const message = getRequestErrorMessage(error, '无法清空数据库记录,请稍后再试。')
toast.error('清理失败', { description: message })
},
})
}

View File

@@ -0,0 +1,2 @@
export * from './components/DataManagementPanel'
export * from './hooks'

View File

@@ -163,10 +163,13 @@ export async function getPhotoAssetSummary(): Promise<PhotoAssetSummary> {
return camelCaseKeys<PhotoAssetSummary>(summary)
}
export async function deletePhotoAssets(ids: string[]): Promise<void> {
export async function deletePhotoAssets(ids: string[], options?: { deleteFromStorage?: boolean }): Promise<void> {
await coreApi('/photos/assets', {
method: 'DELETE',
body: { ids },
body: {
ids,
deleteFromStorage: options?.deleteFromStorage === true,
},
})
}

View File

@@ -1,3 +1,4 @@
import { Prompt } from '@afilmory/ui'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router'
@@ -26,6 +27,8 @@ import type {
PhotoSyncResolution,
PhotoSyncResult,
} from '../types'
import { DeleteFromStorageOption } from './library/DeleteFromStorageOption'
import type {DeleteAssetOptions} from './library/PhotoLibraryGrid';
import { PhotoLibraryGrid } from './library/PhotoLibraryGrid'
import { PhotoPageActions } from './PhotoPageActions'
import { PhotoSyncConflictsPanel } from './sync/PhotoSyncConflictsPanel'
@@ -240,10 +243,13 @@ export function PhotoPage() {
}, [])
const handleDeleteAssets = useCallback(
async (ids: string[]) => {
async (ids: string[], options?: DeleteAssetOptions) => {
if (ids.length === 0) return
try {
await deleteMutation.mutateAsync(ids)
await deleteMutation.mutateAsync({
ids,
deleteFromStorage: options?.deleteFromStorage ?? false,
})
toast.success(`已删除 ${ids.length} 个资源`)
setSelectedIds((prev) => prev.filter((item) => !ids.includes(item)))
void listQuery.refetch()
@@ -284,7 +290,28 @@ export function PhotoPage() {
)
const handleDeleteSelected = useCallback(() => {
void handleDeleteAssets(selectedIds)
if (selectedIds.length === 0) {
return
}
const ids = [...selectedIds]
let deleteFromStorage = false
Prompt.prompt({
title: `确认删除选中的 ${ids.length} 个资源?`,
description: '删除后将无法恢复。如需同时删除存储提供商中的文件,可勾选下方选项。',
variant: 'danger',
onConfirmText: '删除',
onCancelText: '取消',
content: (
<DeleteFromStorageOption
onChange={(checked) => {
deleteFromStorage = checked
}}
/>
),
onConfirm: () => handleDeleteAssets(ids, { deleteFromStorage }),
})
}, [handleDeleteAssets, selectedIds])
const handleSelectAll = useCallback(() => {
@@ -296,8 +323,8 @@ export function PhotoPage() {
}, [listQuery.data])
const handleDeleteSingle = useCallback(
(asset: PhotoAssetListItem) => {
void handleDeleteAssets([asset.id])
(asset: PhotoAssetListItem, options?: DeleteAssetOptions) => {
void handleDeleteAssets([asset.id], options)
},
[handleDeleteAssets],
)

View File

@@ -0,0 +1,28 @@
import { Checkbox } from '@afilmory/ui'
type DeleteFromStorageOptionProps = {
defaultChecked?: boolean
disabled?: boolean
onChange?: (checked: boolean) => void
}
export function DeleteFromStorageOption({ defaultChecked = false, disabled, onChange }: DeleteFromStorageOptionProps) {
return (
<label className="flex w-full items-start gap-3 rounded-xl border border-border/50 bg-background-secondary/40 px-3 py-2 text-left text-text">
<Checkbox
size="md"
defaultChecked={defaultChecked}
disabled={disabled}
onCheckedChange={(value) => {
onChange?.(Boolean(value))
}}
/>
<div className="space-y-1 text-sm leading-relaxed">
<p className="font-medium"></p>
<p className="text-xs text-text-tertiary">
Git
</p>
</div>
</label>
)
}

View File

@@ -3,15 +3,20 @@ import { clsxm } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import type { PhotoAssetListItem } from '../../types'
import { DeleteFromStorageOption } from './DeleteFromStorageOption'
import { Masonry } from './Masonry'
export type DeleteAssetOptions = {
deleteFromStorage?: boolean
}
type PhotoLibraryGridProps = {
assets: PhotoAssetListItem[] | undefined
isLoading: boolean
selectedIds: Set<string>
onToggleSelect: (id: string) => void
onOpenAsset: (asset: PhotoAssetListItem) => void
onDeleteAsset: (asset: PhotoAssetListItem) => Promise<void> | void
onDeleteAsset: (asset: PhotoAssetListItem, options?: DeleteAssetOptions) => Promise<void> | void
isDeleting?: boolean
}
@@ -27,7 +32,7 @@ function PhotoGridItem({
isSelected: boolean
onToggleSelect: (id: string) => void
onOpenAsset: (asset: PhotoAssetListItem) => void
onDeleteAsset: (asset: PhotoAssetListItem) => Promise<void> | void
onDeleteAsset: (asset: PhotoAssetListItem, options?: DeleteAssetOptions) => Promise<void> | void
isDeleting?: boolean
}) {
const manifest = asset.manifest?.data
@@ -43,13 +48,22 @@ function PhotoGridItem({
const assetLabel = manifest?.title ?? manifest?.id ?? asset.photoId
const handleDelete = () => {
let deleteFromStorage = false
Prompt.prompt({
title: '确认删除该资源?',
description: `删除后将无法恢复,是否继续删除「${assetLabel}」?`,
description: `删除后将无法恢复,是否继续删除「${assetLabel}」?如需同时删除远程存储文件,可勾选下方选项。`,
variant: 'danger',
onConfirmText: '删除',
onCancelText: '取消',
onConfirm: () => Promise.resolve(onDeleteAsset(asset)),
content: (
<DeleteFromStorageOption
onChange={(checked) => {
deleteFromStorage = checked
}}
/>
),
onConfirm: () => Promise.resolve(onDeleteAsset(asset, { deleteFromStorage })),
})
}

View File

@@ -214,7 +214,7 @@ export function PhotoSyncResultPanel({
</span>
</div>
{action.reason ? <p className="text-text-tertiary text-sm">{action.reason}</p> : null}
{action.reason ? <p className="text-text-tertiary text-sm mt-2">{action.reason}</p> : null}
{conflictTypeLabel || conflictPayload?.incomingStorageKey ? (
<div className="text-text-tertiary text-xs">
@@ -242,13 +242,13 @@ export function PhotoSyncResultPanel({
{action.snapshots ? (
<div className="text-text-tertiary grid gap-4 text-xs md:grid-cols-2">
{action.snapshots.before ? (
<div>
<div className="mt-4">
<p className="text-text font-semibold"></p>
<MetadataSnapshot snapshot={action.snapshots.before} />
</div>
) : null}
{action.snapshots.after ? (
<div>
<div className="mt-4">
<p className="text-text font-semibold"></p>
<MetadataSnapshot snapshot={action.snapshots.after} />
</div>
@@ -355,7 +355,7 @@ export function PhotoSyncResultPanel({
</div>
{filteredActions.length === 0 ? (
<p className="text-text-tertiary text-sm">
<p className="text-text-tertiary text-sm mt-4">
{result ? '当前筛选下没有需要查看的操作。' : '同步完成,未检测到需要处理的对象。'}
</p>
) : (

View File

@@ -41,10 +41,13 @@ export function useDeletePhotoAssetsMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (ids: string[]) => {
await deletePhotoAssets(ids)
mutationFn: async (variables: { ids: string[]; deleteFromStorage?: boolean }) => {
await deletePhotoAssets(variables.ids, {
deleteFromStorage: variables.deleteFromStorage,
})
},
onSuccess: (_, ids) => {
onSuccess: (_, variables) => {
const {ids} = variables
void queryClient.invalidateQueries({
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
})

View File

@@ -7,18 +7,20 @@ const SETTINGS_TABS = [
path: '/settings/site',
end: true,
},
{
id: 'builder',
label: '构建器',
path: '/settings/builder',
end: true,
},
{
id: 'account',
label: '账号与登录',
path: '/settings/account',
end: true,
},
{
id: 'data',
label: '数据管理',
path: '/settings/data',
end: true,
},
] as const
type SettingsNavigationProps = {

View File

@@ -49,12 +49,18 @@ export function StorageProvidersManager() {
const markDirty = () => setIsDirty(true)
const handleEditProvider = (provider: StorageProvider | null) => {
Modal.present(ProviderEditModal, {
provider,
activeProviderId,
onSave: handleSaveProvider,
onSetActive: handleSetActive,
})
Modal.present(
ProviderEditModal,
{
provider,
activeProviderId,
onSave: handleSaveProvider,
onSetActive: handleSetActive,
},
{
dismissOnOutsideClick: false,
},
)
}
const handleAddProvider = () => {
@@ -196,6 +202,27 @@ export function StorageProvidersManager() {
/>
</m.div>
))}
{!hasProviders && (
<m.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={Spring.presets.smooth}
className="col-span-full"
>
<div className="bg-background-tertiary border-fill-tertiary flex flex-col items-center justify-center gap-3 rounded-lg border p-8 text-center">
<div className="space-y-1">
<p className="text-text-secondary text-sm"></p>
<p className="text-text-tertiary text-xs">
</p>
</div>
<Button type="button" size="sm" variant="primary" onClick={handleAddProvider}>
</Button>
</div>
</m.div>
)}
</m.div>
{/* Status Message */}

View File

@@ -0,0 +1,91 @@
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useMemo } from 'react'
import { useNavigate } from 'react-router'
import { buildTenantUrl } from '~/modules/auth/utils/domain'
import { buildHomeUrl, getCurrentHostname } from './tenant-utils'
interface RegistrationBlockedNoticeProps {
tenantSlug: string | null
}
export const RegistrationBlockedNotice = ({ tenantSlug }: RegistrationBlockedNoticeProps) => {
const navigate = useNavigate()
const hostname = useMemo(() => getCurrentHostname(), [])
const workspaceLoginUrl = useMemo(() => {
if (!tenantSlug) {
return null
}
try {
return buildTenantUrl(tenantSlug, '/platform/login')
} catch {
return null
}
}, [tenantSlug])
const handleOpenWorkspace = () => {
if (workspaceLoginUrl) {
window.location.href = workspaceLoginUrl
return
}
navigate('/login', { replace: true })
}
const handleReturnHome = () => {
const homeUrl = buildHomeUrl()
window.location.href = homeUrl
}
return (
<div className="relative flex min-h-dvh flex-1 flex-col bg-background text-text">
<div className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6">
<LinearBorderContainer>
<div className="bg-background-tertiary relative w-full max-w-[640px] overflow-hidden border border-white/5">
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute -inset-32 bg-linear-to-br from-accent/20 via-transparent to-transparent blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%)]" />
</div>
<div className="relative p-10 sm:p-12">
<m.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={Spring.presets.smooth}>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">400</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">Workspace already configured</h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">
This workspace has already completed onboarding. You can return to your dashboard or go back to the
login screen to switch accounts before continuing.
</p>
{(hostname || tenantSlug) && (
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
{tenantSlug ? (
<p className="text-text-secondary">
Workspace slug: <span className="text-text font-medium">{tenantSlug}</span>
</p>
) : null}
{hostname ? (
<p className="text-text-secondary mt-1">
Requested host: <span className="text-text font-medium">{hostname}</span>
</p>
) : null}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<Button variant="primary" className="glassmorphic-btn flex-1" onClick={handleOpenWorkspace}>
Go to workspace
</Button>
<Button variant="ghost" className="flex-1" onClick={handleReturnHome}>
Return home
</Button>
</div>
</m.div>
</div>
</div>
</LinearBorderContainer>
</div>
</div>
)
}

View File

@@ -1,14 +0,0 @@
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { BuilderSettingsForm } from '~/modules/builder-settings'
import { SettingsNavigation } from '~/modules/settings'
export function Component() {
return (
<MainPageLayout title="构建器" description="调整照片构建任务的并发、日志输出与仓库同步策略。">
<div className="space-y-6">
<SettingsNavigation active="builder" />
<BuilderSettingsForm />
</div>
</MainPageLayout>
)
}

View File

@@ -0,0 +1,14 @@
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { DataManagementPanel } from '~/modules/data-management'
import { SettingsNavigation } from '~/modules/settings'
export function Component() {
return (
<MainPageLayout title="数据管理" description="执行数据库级别的维护操作,以保持照片数据与对象存储一致。">
<div className="space-y-6">
<SettingsNavigation active="data" />
<DataManagementPanel />
</div>
</MainPageLayout>
)
}

View File

@@ -1,5 +1,39 @@
import { useLoaderData } from 'react-router'
import { fetchSession } from '~/modules/auth/api/session'
import { RegistrationWizard } from '~/modules/auth/components/RegistrationWizard'
import { RegistrationBlockedNotice } from '~/modules/welcome/components/RegistrationBlockedNotice'
type WelcomeLoaderData = {
isTenantRegistered: boolean
tenantSlug: string | null
}
export function Component() {
const { isTenantRegistered, tenantSlug } = useLoaderData<WelcomeLoaderData>()
if (isTenantRegistered) {
return <RegistrationBlockedNotice tenantSlug={tenantSlug} />
}
return <RegistrationWizard />
}
export async function loader() {
try {
const session = await fetchSession()
if (session?.tenant && !session.tenant.isPlaceholder) {
return {
isTenantRegistered: true,
tenantSlug: session.tenant.slug ?? null,
}
}
} catch {
// Ignore session fetch failures and allow onboarding flow to continue; page logic handles unauthenticated cases.
}
return {
isTenantRegistered: false,
tenantSlug: null,
}
}

View File

@@ -0,0 +1,22 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { BuilderSettingsForm } from '~/modules/builder-settings'
export function Component() {
return (
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
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>
</header>
<BuilderSettingsForm />
</m.div>
)
}

View File

@@ -9,6 +9,11 @@ export function Component() {
const isSuperAdmin = useIsSuperAdmin()
const navItems = [
{ to: '/superadmin/settings', label: '系统设置', end: true },
{
label: '构建器',
to: '/settings/builder',
end: true,
},
{ to: '/superadmin/debug', label: 'Builder 调试', end: false },
] as const

View File

@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
import type { AfilmoryBuilder } from '../builder/builder.js'
import type { StorageManager } from '../storage/index.js'
import type { StorageConfig } from '../storage/interfaces.js'
import type { GitHubConfig, S3Config, StorageConfig } from '../storage/interfaces.js'
import type { PhotoProcessingLoggers } from './logger-adapter.js'
export interface PhotoExecutionContext {
@@ -44,11 +44,11 @@ export function createStorageKeyNormalizer(storageConfig: StorageConfig): (key:
switch (storageConfig.provider) {
case 's3': {
basePrefix = sanitizeStoragePath(storageConfig.prefix)
basePrefix = sanitizeStoragePath((storageConfig as S3Config).prefix)
break
}
case 'github': {
basePrefix = sanitizeStoragePath(storageConfig.path)
basePrefix = sanitizeStoragePath((storageConfig as GitHubConfig).path)
break
}
default: {

View File

@@ -28,7 +28,7 @@ export default function eagleStoragePlugin(options: EagleStoragePluginOptions =
const eagleConfig = storage
const key = payload.item.s3Key
const meta = await readImageMetadata(eagleConfig.libraryPath, key)
const meta = await readImageMetadata((eagleConfig as EagleConfig).libraryPath, key)
// Append folder names as tags if enabled
if (eagleConfig.folderAsTag) {
@@ -36,7 +36,7 @@ export default function eagleStoragePlugin(options: EagleStoragePluginOptions =
const indexCacheKey = 'afilmory:eagle:folderIndex'
let folderIndex = runShared.get(indexCacheKey) as Map<string, string[]> | undefined
if (!folderIndex) {
folderIndex = await getEagleFolderIndex(eagleConfig.libraryPath)
folderIndex = await getEagleFolderIndex((eagleConfig as EagleConfig).libraryPath)
runShared.set(indexCacheKey, folderIndex)
}
const folderNames = (meta.folders ?? [])
@@ -52,7 +52,7 @@ export default function eagleStoragePlugin(options: EagleStoragePluginOptions =
}
}
// Apply omitTagNamesInMetadata filter
const omit = new Set(eagleConfig.omitTagNamesInMetadata ?? [])
const omit = new Set((eagleConfig as EagleConfig).omitTagNamesInMetadata ?? [])
if (omit.size > 0 && meta.tags) {
meta.tags = meta.tags.filter((t) => !omit.has(t))
}

View File

@@ -1,5 +1,5 @@
import { StorageManager } from '../../storage/index.js'
import type { StorageConfig } from '../../storage/interfaces.js'
import type { S3Config, StorageConfig } from '../../storage/interfaces.js'
import type { BuilderPlugin } from '../types.js'
import type { ThumbnailPluginData } from './shared.js'
import {
@@ -56,7 +56,7 @@ function joinSegments(...segments: Array<string | null | undefined>): string {
function resolveRemotePrefix(config: UploadableStorageConfig, directory: string): string {
switch (config.provider) {
case 's3': {
const base = trimSlashes(config.prefix)
const base = trimSlashes((config as S3Config).prefix)
return joinSegments(base, directory)
}
case 'github': {

View File

@@ -85,6 +85,11 @@ export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Con
HTMLMotionProps<'div'> & {
from?: FlipDirection
transition?: Transition
/**
* Whether the dialog can be dismissed by clicking outside (on the overlay).
* Defaults to `true`.
*/
dismissOnOutsideClick?: boolean
}
const contentTransition: Transition = {
@@ -97,6 +102,8 @@ function DialogContent({
children,
from = 'top',
transition = contentTransition,
dismissOnOutsideClick = true,
onInteractOutside,
...props
}: DialogContentProps) {
const { isOpen } = useDialog()
@@ -118,7 +125,17 @@ function DialogContent({
transition={{ duration: 0.2, ease: 'easeInOut' }}
/>
</DialogOverlay>
<DialogPrimitive.Content asChild forceMount {...props}>
<DialogPrimitive.Content
asChild
forceMount
{...props}
onInteractOutside={(event) => {
if (!dismissOnOutsideClick) {
event.preventDefault()
}
onInteractOutside?.(event)
}}
>
<motion.div
key="dialog-content"
data-slot="dialog-content"

View File

@@ -60,14 +60,21 @@ function ModalWrapper({ item }: { item: ModalItem }) {
const { contentProps, contentClassName } = Component
const mergedContentConfig = {
...contentProps,
...item.modalContent,
}
const { dismissOnOutsideClick = true, ...restContentConfig } = mergedContentConfig
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className={clsxm('w-full max-w-md', contentClassName)}
transition={Spring.presets.smooth}
onAnimationComplete={handleAnimationComplete}
{...contentProps}
{...item.modalContent}
dismissOnOutsideClick={item.dismissOnOutsideClick ?? dismissOnOutsideClick}
{...restContentConfig}
>
<Component modalId={item.id} dismiss={dismiss} {...(item.props as any)} />
</DialogContent>

View File

@@ -1,17 +1,28 @@
import { atom } from 'jotai'
import { modalStore } from './store'
import type { ModalComponent, ModalContentConfig, ModalItem } from './types'
import type { ModalComponent, ModalItem, ModalPresentConfig } from './types'
export const modalItemsAtom = atom<ModalItem[]>([])
const modalCloseRegistry = new Map<string, () => void>()
export const Modal = {
present<P = unknown>(Component: ModalComponent<P>, props?: P, modalContent?: ModalContentConfig): string {
present<P = unknown>(Component: ModalComponent<P>, props?: P, config?: ModalPresentConfig): string {
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const items = modalStore.get(modalItemsAtom)
modalStore.set(modalItemsAtom, [...items, { id, component: Component as ModalComponent<any>, props, modalContent }])
const { dismissOnOutsideClick, ...modalContent } = config ?? {}
modalStore.set(modalItemsAtom, [
...items,
{
id,
component: Component as ModalComponent<any>,
props,
modalContent,
dismissOnOutsideClick,
},
])
return id
},

View File

@@ -2,7 +2,14 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog'
import type { HTMLMotionProps } from 'motion/react'
import type { FC } from 'react'
export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Content> & HTMLMotionProps<'div'>
export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Content> &
HTMLMotionProps<'div'> & {
/**
* Whether the dialog can be dismissed by clicking outside (on the overlay).
* Defaults to `true`.
*/
dismissOnOutsideClick?: boolean
}
export type ModalComponentProps = {
modalId: string
@@ -16,9 +23,22 @@ export type ModalComponent<P = unknown> = FC<ModalComponentProps & P> & {
export type ModalContentConfig = Partial<DialogContentProps>
export type ModalPresentConfig = ModalContentConfig & {
/**
* Control whether this modal can be dismissed by clicking outside.
* Defaults to `true` when omitted.
*/
dismissOnOutsideClick?: boolean
}
export type ModalItem = {
id: string
component: ModalComponent<any>
props?: unknown
modalContent?: ModalContentConfig
/**
* When `false`, prevent dismissing this modal via outside clicks.
* `undefined` means "use default" (treated as `true`).
*/
dismissOnOutsideClick?: boolean
}