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:
Innei
2025-11-12 23:30:58 +08:00
parent 76d60fd672
commit 33cbbfe2b4
9 changed files with 224 additions and 123 deletions

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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' })
}
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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>
}

View File

@@ -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;

View File

@@ -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>