mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
11
be/apps/dashboard/src/modules/data-management/api.ts
Normal file
11
be/apps/dashboard/src/modules/data-management/api.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
30
be/apps/dashboard/src/modules/data-management/hooks.ts
Normal file
30
be/apps/dashboard/src/modules/data-management/hooks.ts
Normal 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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
2
be/apps/dashboard/src/modules/data-management/index.ts
Normal file
2
be/apps/dashboard/src/modules/data-management/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components/DataManagementPanel'
|
||||
export * from './hooks'
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
14
be/apps/dashboard/src/pages/(main)/settings/data.tsx
Normal file
14
be/apps/dashboard/src/pages/(main)/settings/data.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
22
be/apps/dashboard/src/pages/superadmin/builder.tsx
Normal file
22
be/apps/dashboard/src/pages/superadmin/builder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user