diff --git a/be/apps/core/src/errors/biz-exception.ts b/be/apps/core/src/errors/biz-exception.ts index 3d318d57..afdb5f25 100644 --- a/be/apps/core/src/errors/biz-exception.ts +++ b/be/apps/core/src/errors/biz-exception.ts @@ -1,46 +1,41 @@ import type { ErrorCode, ErrorDescriptor } from './error-codes' import { ERROR_CODE_DESCRIPTORS } from './error-codes' -export interface BizExceptionOptions { +export interface BizExceptionOptions { message?: string - details?: TDetails cause?: unknown } -export interface BizErrorResponse { +export interface BizErrorResponse { + ok: boolean code: ErrorCode message: string - details?: TDetails } -export class BizException extends Error { +export class BizException extends Error { readonly code: ErrorCode - readonly details?: TDetails + private readonly httpStatus: number - constructor(code: ErrorCode, options?: BizExceptionOptions) { + 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 { - const response: BizErrorResponse = { + toResponse(): BizErrorResponse { + return { + ok: false, code: this.code, message: this.message, } - - if (this.details !== undefined) { - response.details = this.details - } - - return response } } diff --git a/be/apps/core/src/guards/roles.guard.ts b/be/apps/core/src/guards/roles.guard.ts index dc1f2f08..1e1c0a65 100644 --- a/be/apps/core/src/guards/roles.guard.ts +++ b/be/apps/core/src/guards/roles.guard.ts @@ -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 diff --git a/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts b/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts index faa0f4e0..5268db96 100644 --- a/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts +++ b/be/apps/core/src/modules/configuration/storage-setting/storage-setting.controller.ts @@ -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' }) } } } diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts index bf12845f..4c9b2f5d 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts @@ -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 diff --git a/be/apps/dashboard/index.html b/be/apps/dashboard/index.html index 46f8cf32..2cb31612 100644 --- a/be/apps/dashboard/index.html +++ b/be/apps/dashboard/index.html @@ -4,7 +4,8 @@ - + + Afilmory Dashboard diff --git a/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx b/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx index 30cad145..9c5866ea 100644 --- a/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx +++ b/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx @@ -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 = + break + } + case 'sync': { + let progressPanel: ReactNode | null = null + if (syncProgress) { + progressPanel = + } + + let conflictsPanel: ReactNode | null = null + if (showConflictsPanel) { + conflictsPanel = ( + + ) + } + + tabContent = ( + <> + {progressPanel} +
+ {conflictsPanel} + +
+ + ) + break + } + case 'library': { + tabContent = ( + + ) + break + } + default: { + tabContent = null + } + } + return ( - {activeTab !== 'storage' ? ( - - {activeTab === 'sync' ? ( - { - setResult(data) - setLastWasDryRun(context.dryRun) - setSyncProgress(null) - void summaryQuery.refetch() - void listQuery.refetch() - }} - onProgress={handleProgressEvent} - onError={handleSyncError} - /> - ) : ( - { - void handleDeleteAssets(selectedIds) - }} - onClearSelection={handleClearSelection} - /> - )} - - ) : null} +
- {activeTab === 'storage' ? ( - - ) : ( - <> - {activeTab === 'sync' && syncProgress ? : null} - - {activeTab === 'sync' ? ( -
- {showConflictsPanel ? ( - - ) : null} - -
- ) : ( - - )} - - )} + {tabContent}
) diff --git a/be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx b/be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx new file mode 100644 index 00000000..e92ace33 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx @@ -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 + 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 = ( + + ) + break + } + case 'library': { + actionContent = ( + + ) + break + } + default: { + actionContent = null + } + } + + if (!actionContent) { + return null + } + + return {actionContent} +} diff --git a/be/apps/dashboard/tenant-missing.html b/be/apps/dashboard/tenant-missing.html index 044a34de..da0a4a4b 100644 --- a/be/apps/dashboard/tenant-missing.html +++ b/be/apps/dashboard/tenant-missing.html @@ -12,6 +12,7 @@ rel="stylesheet" referrerpolicy="no-referrer" /> +