mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
refactor(errors): simplify BizException and BizErrorResponse interfaces
- Removed generic type parameters from BizExceptionOptions and BizErrorResponse interfaces for clarity. - Updated BizException class to directly use the message property from options, enhancing readability. - Adjusted toResponse method in BizException to streamline response structure by removing unnecessary details handling. fix(controller): update error handling in StorageSettingController - Changed error code in ensureKeyAllowed method to use COMMON_BAD_REQUEST for better accuracy in error reporting. fix(dashboard): update HTML files to use link tag for favicon - Replaced meta tag with link tag for favicon in index.html, tenant-missing.html, and tenant-restricted.html for improved compatibility. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,46 +1,41 @@
|
||||
import type { ErrorCode, ErrorDescriptor } from './error-codes'
|
||||
import { ERROR_CODE_DESCRIPTORS } from './error-codes'
|
||||
|
||||
export interface BizExceptionOptions<TDetails = unknown> {
|
||||
export interface BizExceptionOptions {
|
||||
message?: string
|
||||
details?: TDetails
|
||||
cause?: unknown
|
||||
}
|
||||
|
||||
export interface BizErrorResponse<TDetails = unknown> {
|
||||
export interface BizErrorResponse {
|
||||
ok: boolean
|
||||
code: ErrorCode
|
||||
message: string
|
||||
details?: TDetails
|
||||
}
|
||||
|
||||
export class BizException<TDetails = unknown> extends Error {
|
||||
export class BizException extends Error {
|
||||
readonly code: ErrorCode
|
||||
readonly details?: TDetails
|
||||
|
||||
private readonly httpStatus: number
|
||||
|
||||
constructor(code: ErrorCode, options?: BizExceptionOptions<TDetails>) {
|
||||
readonly message: string
|
||||
constructor(code: ErrorCode, options?: BizExceptionOptions) {
|
||||
const descriptor: ErrorDescriptor = ERROR_CODE_DESCRIPTORS[code]
|
||||
super(options?.message ?? descriptor.message, options?.cause ? { cause: options.cause } : undefined)
|
||||
this.name = 'BizException'
|
||||
this.code = code
|
||||
this.details = options?.details
|
||||
this.httpStatus = descriptor.httpStatus
|
||||
this.message = options?.message ?? descriptor.message
|
||||
}
|
||||
|
||||
getHttpStatus(): number {
|
||||
return this.httpStatus
|
||||
}
|
||||
|
||||
toResponse(): BizErrorResponse<TDetails> {
|
||||
const response: BizErrorResponse<TDetails> = {
|
||||
toResponse(): BizErrorResponse {
|
||||
return {
|
||||
ok: false,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
}
|
||||
|
||||
if (this.details !== undefined) {
|
||||
response.details = this.details
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,9 @@ export class RolesGuard implements CanActivate {
|
||||
const userMask = roleBitWithInheritance(roleNameToBit(userRoleName))
|
||||
const hasRole = (requiredMask & userMask) !== 0
|
||||
if (!hasRole) {
|
||||
this.log.warn(
|
||||
`Denied access: user ${(authContext.user as { id?: string }).id ?? 'unknown'} role=${userRoleName ?? 'n/a'} lacks permission mask=${requiredMask} on ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN)
|
||||
const message = `Insufficient permissions for user ${(authContext.user as { id?: string }).id ?? 'unknown'} role=${userRoleName ?? 'n/a'} lacks permission mask=${requiredMask} on ${method} ${path}`
|
||||
this.log.warn(message)
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, { message })
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -12,7 +12,7 @@ const STORAGE_SETTING_KEYS = ['builder.storage.providers', 'builder.storage.acti
|
||||
type StorageSettingKey = (typeof STORAGE_SETTING_KEYS)[number]
|
||||
|
||||
@Controller('storage/settings')
|
||||
@Roles('superadmin')
|
||||
@Roles('admin')
|
||||
export class StorageSettingController {
|
||||
constructor(private readonly storageSettingService: StorageSettingService) {}
|
||||
|
||||
@@ -76,7 +76,7 @@ export class StorageSettingController {
|
||||
|
||||
private ensureKeyAllowed(key: string) {
|
||||
if (!key.startsWith('builder.storage.')) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, { message: 'Only storage settings are available' })
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Only storage settings are available' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const DEFAULT_ASSET_LINK_RELS = new Set([
|
||||
'preload',
|
||||
'prefetch',
|
||||
'icon',
|
||||
|
||||
'shortcut icon',
|
||||
'apple-touch-icon',
|
||||
'manifest',
|
||||
@@ -196,7 +197,7 @@ export abstract class StaticAssetService {
|
||||
private extractRelativePath(fullPath: string): string {
|
||||
const index = fullPath.indexOf(this.routeSegment)
|
||||
if (index === -1) {
|
||||
return this.stripLeadingSlashes(fullPath)
|
||||
return ''
|
||||
}
|
||||
|
||||
const sliceStart = index + this.routeSegment.length
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<meta name="description" content="Afilmory Dashboard for managing your gallery" />
|
||||
<meta name="favicon" content="/favicon.ico" />
|
||||
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Afilmory Dashboard</title>
|
||||
</head>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
@@ -24,14 +25,13 @@ import type {
|
||||
PhotoSyncResolution,
|
||||
PhotoSyncResult,
|
||||
} from '../types'
|
||||
import { PhotoLibraryActionBar } from './library/PhotoLibraryActionBar'
|
||||
import { PhotoLibraryGrid } from './library/PhotoLibraryGrid'
|
||||
import { PhotoSyncActions } from './sync/PhotoSyncActions'
|
||||
import { PhotoPageActions } from './PhotoPageActions'
|
||||
import { PhotoSyncConflictsPanel } from './sync/PhotoSyncConflictsPanel'
|
||||
import { PhotoSyncProgressPanel } from './sync/PhotoSyncProgressPanel'
|
||||
import { PhotoSyncResultPanel } from './sync/PhotoSyncResultPanel'
|
||||
|
||||
type PhotoPageTab = 'sync' | 'library' | 'storage'
|
||||
export type PhotoPageTab = 'sync' | 'library' | 'storage'
|
||||
|
||||
const BATCH_RESOLVING_ID = '__batch__'
|
||||
|
||||
@@ -207,31 +207,59 @@ export function PhotoPage() {
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleDeleteAssets = async (ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
await deleteMutation.mutateAsync(ids)
|
||||
toast.success(`已删除 ${ids.length} 个资源`)
|
||||
setSelectedIds((prev) => prev.filter((item) => !ids.includes(item)))
|
||||
void listQuery.refetch()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除失败,请稍后重试。'
|
||||
toast.error('删除失败', { description: message })
|
||||
}
|
||||
}
|
||||
const handleDeleteAssets = useCallback(
|
||||
async (ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
await deleteMutation.mutateAsync(ids)
|
||||
toast.success(`已删除 ${ids.length} 个资源`)
|
||||
setSelectedIds((prev) => prev.filter((item) => !ids.includes(item)))
|
||||
void listQuery.refetch()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除失败,请稍后重试。'
|
||||
toast.error('删除失败', { description: message })
|
||||
}
|
||||
},
|
||||
[deleteMutation, listQuery, setSelectedIds],
|
||||
)
|
||||
|
||||
const handleUploadAssets = async (files: FileList) => {
|
||||
const fileArray = Array.from(files)
|
||||
if (fileArray.length === 0) return
|
||||
try {
|
||||
await uploadMutation.mutateAsync(fileArray)
|
||||
toast.success(`成功上传 ${fileArray.length} 张图片`)
|
||||
const handleUploadAssets = useCallback(
|
||||
async (files: FileList) => {
|
||||
const fileArray = Array.from(files)
|
||||
if (fileArray.length === 0) return
|
||||
try {
|
||||
await uploadMutation.mutateAsync(fileArray)
|
||||
toast.success(`成功上传 ${fileArray.length} 张图片`)
|
||||
void listQuery.refetch()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '上传失败,请稍后重试。'
|
||||
toast.error('上传失败', { description: message })
|
||||
}
|
||||
},
|
||||
[listQuery, uploadMutation],
|
||||
)
|
||||
|
||||
const handleSyncCompleted = useCallback(
|
||||
(data: PhotoSyncResult, context: { dryRun: boolean }) => {
|
||||
setResult(data)
|
||||
setLastWasDryRun(context.dryRun)
|
||||
setSyncProgress(null)
|
||||
void summaryQuery.refetch()
|
||||
void listQuery.refetch()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '上传失败,请稍后重试。'
|
||||
toast.error('上传失败', { description: message })
|
||||
}
|
||||
}
|
||||
},
|
||||
[listQuery, summaryQuery],
|
||||
)
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
void handleDeleteAssets(selectedIds)
|
||||
}, [handleDeleteAssets, selectedIds])
|
||||
|
||||
const handleDeleteSingle = useCallback(
|
||||
(asset: PhotoAssetListItem) => {
|
||||
void handleDeleteAssets([asset.id])
|
||||
},
|
||||
[handleDeleteAssets],
|
||||
)
|
||||
|
||||
const handleResolveConflict = useCallback(
|
||||
async (conflict: PhotoSyncConflict, strategy: PhotoSyncResolution) => {
|
||||
@@ -325,10 +353,6 @@ export function PhotoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSingle = (asset: PhotoAssetListItem) => {
|
||||
void handleDeleteAssets([asset.id])
|
||||
}
|
||||
|
||||
const handleTabChange = (tab: PhotoPageTab) => {
|
||||
setActiveTab(tab)
|
||||
const next = new URLSearchParams(searchParams.toString())
|
||||
@@ -347,36 +371,84 @@ export function PhotoPage() {
|
||||
const showConflictsPanel =
|
||||
conflictsQuery.isLoading || conflictsQuery.isFetching || (conflictsQuery.data?.length ?? 0) > 0
|
||||
|
||||
let tabContent: ReactNode | null = null
|
||||
|
||||
switch (activeTab) {
|
||||
case 'storage': {
|
||||
tabContent = <StorageProvidersManager />
|
||||
break
|
||||
}
|
||||
case 'sync': {
|
||||
let progressPanel: ReactNode | null = null
|
||||
if (syncProgress) {
|
||||
progressPanel = <PhotoSyncProgressPanel progress={syncProgress} />
|
||||
}
|
||||
|
||||
let conflictsPanel: ReactNode | null = null
|
||||
if (showConflictsPanel) {
|
||||
conflictsPanel = (
|
||||
<PhotoSyncConflictsPanel
|
||||
conflicts={conflictsQuery.data}
|
||||
isLoading={conflictsQuery.isLoading || conflictsQuery.isFetching}
|
||||
resolvingId={resolvingConflictId}
|
||||
isBatchResolving={resolvingConflictId === BATCH_RESOLVING_ID}
|
||||
onResolve={handleResolveConflict}
|
||||
onResolveBatch={handleResolveConflictsBatch}
|
||||
onRequestStorageUrl={getPhotoStorageUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
tabContent = (
|
||||
<>
|
||||
{progressPanel}
|
||||
<div className="space-y-6">
|
||||
{conflictsPanel}
|
||||
<PhotoSyncResultPanel
|
||||
result={result}
|
||||
lastWasDryRun={lastWasDryRun}
|
||||
baselineSummary={summaryQuery.data}
|
||||
isSummaryLoading={summaryQuery.isLoading}
|
||||
onRequestStorageUrl={getPhotoStorageUrl}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'library': {
|
||||
tabContent = (
|
||||
<PhotoLibraryGrid
|
||||
assets={listQuery.data}
|
||||
isLoading={isListLoading}
|
||||
selectedIds={selectedSet}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onOpenAsset={handleOpenAsset}
|
||||
onDeleteAsset={handleDeleteSingle}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
tabContent = null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MainPageLayout title="照片库" description="在此同步和管理服务器中的照片资产。">
|
||||
{activeTab !== 'storage' ? (
|
||||
<MainPageLayout.Actions>
|
||||
{activeTab === 'sync' ? (
|
||||
<PhotoSyncActions
|
||||
onCompleted={(data, context) => {
|
||||
setResult(data)
|
||||
setLastWasDryRun(context.dryRun)
|
||||
setSyncProgress(null)
|
||||
void summaryQuery.refetch()
|
||||
void listQuery.refetch()
|
||||
}}
|
||||
onProgress={handleProgressEvent}
|
||||
onError={handleSyncError}
|
||||
/>
|
||||
) : (
|
||||
<PhotoLibraryActionBar
|
||||
selectionCount={selectedIds.length}
|
||||
isUploading={uploadMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onUpload={handleUploadAssets}
|
||||
onDeleteSelected={() => {
|
||||
void handleDeleteAssets(selectedIds)
|
||||
}}
|
||||
onClearSelection={handleClearSelection}
|
||||
/>
|
||||
)}
|
||||
</MainPageLayout.Actions>
|
||||
) : null}
|
||||
<PhotoPageActions
|
||||
activeTab={activeTab}
|
||||
selectionCount={selectedIds.length}
|
||||
isUploading={uploadMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onUpload={handleUploadAssets}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
onClearSelection={handleClearSelection}
|
||||
onSyncCompleted={handleSyncCompleted}
|
||||
onSyncProgress={handleProgressEvent}
|
||||
onSyncError={handleSyncError}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<PageTabs
|
||||
@@ -389,46 +461,7 @@ export function PhotoPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeTab === 'storage' ? (
|
||||
<StorageProvidersManager />
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'sync' && syncProgress ? <PhotoSyncProgressPanel progress={syncProgress} /> : null}
|
||||
|
||||
{activeTab === 'sync' ? (
|
||||
<div className="space-y-6">
|
||||
{showConflictsPanel ? (
|
||||
<PhotoSyncConflictsPanel
|
||||
conflicts={conflictsQuery.data}
|
||||
isLoading={conflictsQuery.isLoading || conflictsQuery.isFetching}
|
||||
resolvingId={resolvingConflictId}
|
||||
isBatchResolving={resolvingConflictId === BATCH_RESOLVING_ID}
|
||||
onResolve={handleResolveConflict}
|
||||
onResolveBatch={handleResolveConflictsBatch}
|
||||
onRequestStorageUrl={getPhotoStorageUrl}
|
||||
/>
|
||||
) : null}
|
||||
<PhotoSyncResultPanel
|
||||
result={result}
|
||||
lastWasDryRun={lastWasDryRun}
|
||||
baselineSummary={summaryQuery.data}
|
||||
isSummaryLoading={summaryQuery.isLoading}
|
||||
onRequestStorageUrl={getPhotoStorageUrl}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PhotoLibraryGrid
|
||||
assets={listQuery.data}
|
||||
isLoading={isListLoading}
|
||||
selectedIds={selectedSet}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onOpenAsset={handleOpenAsset}
|
||||
onDeleteAsset={handleDeleteSingle}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tabContent}
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
|
||||
import type { PhotoSyncProgressEvent, PhotoSyncResult } from '../types'
|
||||
import { PhotoLibraryActionBar } from './library/PhotoLibraryActionBar'
|
||||
import type { PhotoPageTab } from './PhotoPage'
|
||||
import { PhotoSyncActions } from './sync/PhotoSyncActions'
|
||||
|
||||
type PhotoPageActionsProps = {
|
||||
activeTab: PhotoPageTab
|
||||
selectionCount: number
|
||||
isUploading: boolean
|
||||
isDeleting: boolean
|
||||
onUpload: (files: FileList) => void | Promise<void>
|
||||
onDeleteSelected: () => void
|
||||
onClearSelection: () => void
|
||||
onSyncCompleted: (result: PhotoSyncResult, context: { dryRun: boolean }) => void
|
||||
onSyncProgress: (event: PhotoSyncProgressEvent) => void
|
||||
onSyncError: (error: Error) => void
|
||||
}
|
||||
|
||||
export function PhotoPageActions({
|
||||
activeTab,
|
||||
selectionCount,
|
||||
isUploading,
|
||||
isDeleting,
|
||||
onUpload,
|
||||
onDeleteSelected,
|
||||
onClearSelection,
|
||||
onSyncCompleted,
|
||||
onSyncProgress,
|
||||
onSyncError,
|
||||
}: PhotoPageActionsProps) {
|
||||
if (activeTab === 'storage') {
|
||||
return null
|
||||
}
|
||||
|
||||
let actionContent: ReactNode | null = null
|
||||
|
||||
switch (activeTab) {
|
||||
case 'sync': {
|
||||
actionContent = (
|
||||
<PhotoSyncActions onCompleted={onSyncCompleted} onProgress={onSyncProgress} onError={onSyncError} />
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'library': {
|
||||
actionContent = (
|
||||
<PhotoLibraryActionBar
|
||||
selectionCount={selectionCount}
|
||||
isUploading={isUploading}
|
||||
isDeleting={isDeleting}
|
||||
onUpload={onUpload}
|
||||
onDeleteSelected={onDeleteSelected}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
actionContent = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!actionContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <MainPageLayout.Actions>{actionContent}</MainPageLayout.Actions>
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
rel="stylesheet"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<style>
|
||||
html {
|
||||
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
rel="stylesheet"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<style>
|
||||
html {
|
||||
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
|
||||
@@ -23,4 +24,3 @@
|
||||
<script type="module" src="/src/entries/tenant-restricted.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user