feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates
parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
310
src/modules/files/actions.ts
Normal file
310
src/modules/files/actions.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
"use server"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import {
|
||||
requirePermission,
|
||||
checkPermission,
|
||||
PermissionDeniedError,
|
||||
} from "@/shared/lib/auth-guard"
|
||||
import { trackEvent } from "@/shared/lib/track-event"
|
||||
import { logAudit } from "@/shared/lib/audit-logger"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { storageProvider } from "@/shared/lib/storage-provider"
|
||||
import {
|
||||
generateStoragePath,
|
||||
isAllowedMimeType,
|
||||
MAX_FILE_SIZE,
|
||||
} from "@/shared/lib/file-storage"
|
||||
|
||||
import { UploadMetadataSchema, BatchDeleteSchema, FileListQuerySchema } from "./schema"
|
||||
import {
|
||||
createFileAttachment,
|
||||
getFileAttachment,
|
||||
getFileAttachmentsWithFilters,
|
||||
getFileStats,
|
||||
getFileAttachmentsByIds,
|
||||
deleteFileAttachment,
|
||||
deleteFileAttachments,
|
||||
} from "./data-access"
|
||||
import type {
|
||||
FileAttachment,
|
||||
FileUploadResult,
|
||||
FileStats as FileStatsType,
|
||||
FileAttachmentQueryParams,
|
||||
BatchDeleteResult,
|
||||
} from "./types"
|
||||
|
||||
function handleActionError(e: unknown): ActionState<never> {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file: persist to storage + create DB record.
|
||||
*
|
||||
* Requires `FILE_UPLOAD` permission. Performs Zod-validated metadata,
|
||||
* MIME/size checks, and writes to disk via the storageProvider abstraction.
|
||||
* Records `file.uploaded` track event + audit log entry.
|
||||
*
|
||||
* @returns ActionState<FileUploadResult>
|
||||
*/
|
||||
export async function uploadFileAction(
|
||||
file: File,
|
||||
rawMetadata: { targetType?: string | null; targetId?: string | null }
|
||||
): Promise<ActionState<FileUploadResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_UPLOAD)
|
||||
|
||||
const meta = UploadMetadataSchema.parse(rawMetadata)
|
||||
|
||||
if (file.size === 0) {
|
||||
return { success: false, message: "File is empty" }
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { success: false, message: "File size exceeds 10MB limit" }
|
||||
}
|
||||
const mimeType = file.type || "application/octet-stream"
|
||||
if (!isAllowedMimeType(mimeType)) {
|
||||
return { success: false, message: `File type ${mimeType} is not allowed` }
|
||||
}
|
||||
|
||||
const originalName = file.name || "unnamed"
|
||||
const storagePath = generateStoragePath(originalName)
|
||||
const bytes = Buffer.from(await file.arrayBuffer())
|
||||
const url = await storageProvider.save(bytes, storagePath)
|
||||
|
||||
const id = createId()
|
||||
const filename = storagePath.split("/").pop() ?? id
|
||||
const created = await createFileAttachment({
|
||||
id,
|
||||
filename,
|
||||
originalName,
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storagePath,
|
||||
url,
|
||||
uploaderId: ctx.userId,
|
||||
targetType: meta.targetType ?? null,
|
||||
targetId: meta.targetId,
|
||||
})
|
||||
|
||||
if (!created) {
|
||||
return { success: false, message: "Failed to persist file record" }
|
||||
}
|
||||
|
||||
await trackEvent({
|
||||
event: "file.uploaded",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
properties: {
|
||||
filename: originalName,
|
||||
mimeType,
|
||||
size: file.size,
|
||||
targetType: meta.targetType ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
action: "upload",
|
||||
module: "files",
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
detail: { filename: originalName, mimeType, size: file.size },
|
||||
})
|
||||
|
||||
const result: FileUploadResult = {
|
||||
id: created.id,
|
||||
url: created.url ?? url,
|
||||
filename: created.filename,
|
||||
originalName: created.originalName,
|
||||
size: created.size,
|
||||
mimeType: created.mimeType,
|
||||
}
|
||||
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
await trackEvent({
|
||||
event: "file.upload_failed",
|
||||
targetType: "file",
|
||||
properties: { reason: e instanceof Error ? e.message : "unknown" },
|
||||
}).catch(() => undefined)
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single file by ID.
|
||||
*
|
||||
* Requires `FILE_READ` permission. Non-admin users (those without
|
||||
* `FILE_DELETE`) can only read files they uploaded themselves,
|
||||
* preventing horizontal privilege escalation.
|
||||
*
|
||||
* Records `file.viewed` track event.
|
||||
*/
|
||||
export async function getFileAction(
|
||||
id: string
|
||||
): Promise<ActionState<FileAttachment>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_READ)
|
||||
const file = await getFileAttachment(id)
|
||||
if (!file) {
|
||||
return { success: false, message: "File not found" }
|
||||
}
|
||||
|
||||
// Data-level permission: non-admins can only read their own uploads.
|
||||
const { allowed: canManage } = await checkPermission(Permissions.FILE_DELETE)
|
||||
if (!canManage && file.uploaderId !== ctx.userId) {
|
||||
return { success: false, message: "Permission denied" }
|
||||
}
|
||||
|
||||
await trackEvent({
|
||||
event: "file.viewed",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
})
|
||||
|
||||
return { success: true, data: file }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single file by ID.
|
||||
*
|
||||
* Requires `FILE_DELETE` permission. Persists removal via storageProvider
|
||||
* abstraction (no direct fs/promises calls). Records `file.deleted` track
|
||||
* event + audit log entry.
|
||||
*/
|
||||
export async function deleteFileAction(
|
||||
id: string
|
||||
): Promise<ActionState<{ id: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_DELETE)
|
||||
const file = await getFileAttachment(id)
|
||||
if (!file) {
|
||||
return { success: false, message: "File not found" }
|
||||
}
|
||||
|
||||
await storageProvider.delete(file.storagePath)
|
||||
|
||||
const ok = await deleteFileAttachment(id)
|
||||
if (!ok) {
|
||||
return { success: false, message: "Failed to delete file record" }
|
||||
}
|
||||
|
||||
await trackEvent({
|
||||
event: "file.deleted",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
properties: { filename: file.originalName, size: file.size },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
action: "delete",
|
||||
module: "files",
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
detail: { filename: file.originalName, mimeType: file.mimeType },
|
||||
})
|
||||
|
||||
return { success: true, data: { id } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete files by IDs.
|
||||
*
|
||||
* Requires `FILE_DELETE` permission. Input is Zod-validated (max 100 ids
|
||||
* per call). Persists storage removal via storageProvider abstraction.
|
||||
* Records `file.batch_deleted` track event + audit log entry.
|
||||
*/
|
||||
export async function batchDeleteFilesAction(
|
||||
rawIds: unknown
|
||||
): Promise<ActionState<BatchDeleteResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_DELETE)
|
||||
|
||||
const { ids } = BatchDeleteSchema.parse({ ids: rawIds })
|
||||
|
||||
const files = await getFileAttachmentsByIds(ids)
|
||||
|
||||
await Promise.all(
|
||||
files.map((f) =>
|
||||
storageProvider.delete(f.storagePath).catch(() => undefined)
|
||||
)
|
||||
)
|
||||
|
||||
const result = await deleteFileAttachments(ids)
|
||||
|
||||
await trackEvent({
|
||||
event: "file.batch_deleted",
|
||||
userId: ctx.userId,
|
||||
targetType: "file",
|
||||
properties: {
|
||||
requestedCount: ids.length,
|
||||
deletedCount: result.deletedCount,
|
||||
failedCount: result.failedIds.length,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
action: "batch_delete",
|
||||
module: "files",
|
||||
targetType: "file",
|
||||
detail: {
|
||||
requestedCount: ids.length,
|
||||
deletedCount: result.deletedCount,
|
||||
failedIds: result.failedIds,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file list with filters (admin).
|
||||
*
|
||||
* Requires `FILE_READ` permission. Input is Zod-validated to enforce
|
||||
* limit (1..200) and offset (>=0) bounds.
|
||||
*/
|
||||
export async function getFileListAction(
|
||||
params?: Partial<FileAttachmentQueryParams>
|
||||
): Promise<ActionState<{ files: FileAttachment[] }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.FILE_READ)
|
||||
const query = FileListQuerySchema.parse(params ?? {})
|
||||
const files = await getFileAttachmentsWithFilters(query)
|
||||
return { success: true, data: { files } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file statistics (admin dashboard).
|
||||
*
|
||||
* Requires `FILE_READ` permission.
|
||||
*/
|
||||
export async function getFileStatsAction(): Promise<ActionState<FileStatsType>> {
|
||||
try {
|
||||
await requirePermission(Permissions.FILE_READ)
|
||||
const stats = await getFileStats()
|
||||
return { success: true, data: stats }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
@@ -15,13 +16,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { FileUpload } from "./file-upload"
|
||||
import { FilePreviewDialog } from "./file-preview-dialog"
|
||||
import { FileUpload } from "./file-upload"
|
||||
import { useFileBatchOperations } from "../hooks/use-file-batch-operations"
|
||||
import type { FileAttachment, FileStats } from "../types"
|
||||
|
||||
interface AdminFilesViewProps {
|
||||
@@ -29,174 +31,159 @@ interface AdminFilesViewProps {
|
||||
stats: FileStats
|
||||
}
|
||||
|
||||
// 文件类型分组选项
|
||||
const TYPE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "all", label: "All Types" },
|
||||
{ value: "image/", label: "Images" },
|
||||
{ value: "application/pdf", label: "PDF" },
|
||||
{ value: "application/msword", label: "Word" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", label: "Word (docx)" },
|
||||
{ value: "application/vnd.ms-excel", label: "Excel" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", label: "Excel (xlsx)" },
|
||||
{ value: "application/vnd.ms-powerpoint", label: "PowerPoint" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", label: "PowerPoint (pptx)" },
|
||||
{ value: "text/", label: "Text" },
|
||||
{ value: "application/zip", label: "ZIP" },
|
||||
]
|
||||
interface TypeOption {
|
||||
value: string
|
||||
labelKey:
|
||||
| "allTypes"
|
||||
| "images"
|
||||
| "pdf"
|
||||
| "word"
|
||||
| "wordDocx"
|
||||
| "excel"
|
||||
| "excelXlsx"
|
||||
| "powerpoint"
|
||||
| "powerpointPptx"
|
||||
| "text"
|
||||
| "zip"
|
||||
}
|
||||
|
||||
export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "all", labelKey: "allTypes" },
|
||||
{ value: "image/", labelKey: "images" },
|
||||
{ value: "application/pdf", labelKey: "pdf" },
|
||||
{ value: "application/msword", labelKey: "word" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", labelKey: "wordDocx" },
|
||||
{ value: "application/vnd.ms-excel", labelKey: "excel" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", labelKey: "excelXlsx" },
|
||||
{ value: "application/vnd.ms-powerpoint", labelKey: "powerpoint" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", labelKey: "powerpointPptx" },
|
||||
{ value: "text/", labelKey: "text" },
|
||||
{ value: "application/zip", labelKey: "zip" },
|
||||
] as const satisfies TypeOption[]
|
||||
|
||||
type TypeLabelKey = TypeOption["labelKey"]
|
||||
|
||||
export function AdminFilesView({
|
||||
files,
|
||||
stats,
|
||||
}: AdminFilesViewProps): React.ReactElement {
|
||||
const t = useTranslations("files.admin")
|
||||
const router = useRouter()
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||
const [search, setSearch] = useState<string>("")
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
// 客户端二次筛选(与 server 端筛选互补,提升交互即时性)
|
||||
const filteredFiles = useMemo(() => {
|
||||
return files.filter((f) => {
|
||||
if (typeFilter !== "all") {
|
||||
if (typeFilter.endsWith("/")) {
|
||||
if (!f.mimeType.startsWith(typeFilter)) return false
|
||||
} else if (f.mimeType !== typeFilter) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (search.trim()) {
|
||||
const kw = search.trim().toLowerCase()
|
||||
if (
|
||||
!f.originalName.toLowerCase().includes(kw) &&
|
||||
!f.filename.toLowerCase().includes(kw)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [files, typeFilter, search])
|
||||
|
||||
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
|
||||
const someSelected = selectedIds.size > 0 && !allSelected
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
|
||||
const renderTypeLabel = (key: TypeLabelKey): string => {
|
||||
switch (key) {
|
||||
case "allTypes": return t("filter.allTypes")
|
||||
case "images": return t("filter.images")
|
||||
case "pdf": return t("filter.pdf")
|
||||
case "word": return t("filter.word")
|
||||
case "wordDocx": return t("filter.wordDocx")
|
||||
case "excel": return t("filter.excel")
|
||||
case "excelXlsx": return t("filter.excelXlsx")
|
||||
case "powerpoint": return t("filter.powerpoint")
|
||||
case "powerpointPptx": return t("filter.powerpointPptx")
|
||||
case "text": return t("filter.text")
|
||||
case "zip": return t("filter.zip")
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleUploaded = () => {
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const handleDeleted = () => {
|
||||
router.refresh()
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
const ids = Array.from(selectedIds)
|
||||
setDeleting(true)
|
||||
try {
|
||||
const res = await fetch("/api/files/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok || !body?.success) {
|
||||
toast.error(body?.message || "Failed to delete files")
|
||||
return
|
||||
}
|
||||
toast.success(`Deleted ${body.deletedCount} file(s)`)
|
||||
handleDeleted()
|
||||
} catch {
|
||||
toast.error("Failed to delete files")
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
const {
|
||||
selectedIds,
|
||||
typeFilter,
|
||||
search,
|
||||
deleting,
|
||||
setTypeFilter,
|
||||
setSearch,
|
||||
filteredFiles,
|
||||
allSelected,
|
||||
someSelected,
|
||||
toggleAll,
|
||||
toggleOne,
|
||||
handleBatchDelete,
|
||||
} = useFileBatchOperations({
|
||||
files,
|
||||
onAfterDelete: () => router.refresh(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Files className="h-6 w-6" />
|
||||
Files
|
||||
<Files className="h-6 w-6" aria-hidden="true" />
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Upload and manage all files in the system.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Files className="h-3.5 w-3.5" />
|
||||
Total Files
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
Total Size
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
|
||||
</div>
|
||||
{stats.byType.slice(0, 2).map((t) => (
|
||||
<div key={t.mimeType} className="rounded-md border p-4">
|
||||
<WidgetBoundary title={t("stats.totalFiles")} skeletonHeight={120}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileIcon mimeType={t.mimeType} className="h-3.5 w-3.5" />
|
||||
<span className="truncate" title={t.mimeType}>{t.mimeType}</span>
|
||||
<Files className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("stats.totalFiles")}
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{t.count}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
|
||||
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("stats.totalSize")}
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
|
||||
</div>
|
||||
{stats.byType.slice(0, 2).map((typeStat) => (
|
||||
<div key={typeStat.mimeType} className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileIcon mimeType={typeStat.mimeType} className="h-3.5 w-3.5" />
|
||||
<span className="truncate" title={typeStat.mimeType}>
|
||||
{typeStat.mimeType}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{typeStat.count}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(typeStat.size)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</WidgetBoundary>
|
||||
|
||||
<FileUpload onUploaded={handleUploaded} />
|
||||
<WidgetBoundary title={t("title")} skeletonHeight={160}>
|
||||
<FileUpload onUploaded={() => router.refresh()} />
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 筛选与批量操作工具栏 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[200px]">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
<SelectValue placeholder={t("filter.byType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{renderTypeLabel(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Search
|
||||
className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search by file name..."
|
||||
placeholder={t("filter.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
aria-label={t("filter.search")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedIds.size > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{selectedIds.size} selected</Badge>
|
||||
<Badge variant="secondary">
|
||||
{t("selection.selected", { count: selectedIds.size })}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
@@ -204,86 +191,91 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
disabled={deleting}
|
||||
onClick={() => void handleBatchDelete()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{deleting ? "Deleting..." : "Delete Selected"}
|
||||
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{deleting ? t("selection.deleting") : t("selection.deleteSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 文件列表 */}
|
||||
{filteredFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No files found"
|
||||
description="Try adjusting your filters or upload a new file."
|
||||
icon={FileWarning}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
<span className="flex-1">File</span>
|
||||
<span className="hidden w-24 sm:block">Size</span>
|
||||
<span className="hidden w-32 md:block">Type</span>
|
||||
<span className="hidden w-32 md:block">Uploaded</span>
|
||||
<span className="w-24 text-right">Actions</span>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{filteredFiles.map((file) => {
|
||||
const checked = selectedIds.has(file.id)
|
||||
return (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleOne(file.id)}
|
||||
aria-label={`Select ${file.originalName}`}
|
||||
/>
|
||||
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={file.url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium hover:underline"
|
||||
title={file.originalName}
|
||||
>
|
||||
{file.originalName}
|
||||
</a>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
|
||||
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block" title={file.mimeType}>
|
||||
{file.mimeType}
|
||||
</span>
|
||||
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
|
||||
{formatDate(file.createdAt, "zh-CN")}
|
||||
</span>
|
||||
<div className="flex w-24 shrink-0 justify-end gap-1">
|
||||
<FilePreviewDialog
|
||||
file={file}
|
||||
triggerLabel=""
|
||||
triggerVariant="ghost"
|
||||
triggerSize="icon"
|
||||
<WidgetBoundary title={t("title")} skeletonHeight={400}>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
icon={FileWarning}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label={t("columns.file")}
|
||||
/>
|
||||
<span className="flex-1">{t("columns.file")}</span>
|
||||
<span className="hidden w-24 sm:block">{t("columns.size")}</span>
|
||||
<span className="hidden w-32 md:block">{t("columns.type")}</span>
|
||||
<span className="hidden w-32 md:block">{t("columns.uploaded")}</span>
|
||||
<span className="w-24 text-right">{t("columns.actions")}</span>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{filteredFiles.map((file) => {
|
||||
const checked = selectedIds.has(file.id)
|
||||
return (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleOne(file.id)}
|
||||
aria-label={`Select ${file.originalName}`}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={file.url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium hover:underline"
|
||||
title={file.originalName}
|
||||
aria-label={`${t("columns.file")}: ${file.originalName}`}
|
||||
>
|
||||
{file.originalName}
|
||||
</a>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
|
||||
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span
|
||||
className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block"
|
||||
title={file.mimeType}
|
||||
>
|
||||
{file.mimeType}
|
||||
</span>
|
||||
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
|
||||
{formatDate(file.createdAt, "zh-CN")}
|
||||
</span>
|
||||
<div className="flex w-24 shrink-0 justify-end gap-1">
|
||||
<FilePreviewDialog
|
||||
file={file}
|
||||
triggerLabel=""
|
||||
triggerVariant="ghost"
|
||||
triggerSize="icon"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Download, Trash2, FileWarning } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import type { FileAttachment } from "../types"
|
||||
|
||||
interface FileListProps {
|
||||
files: FileAttachment[]
|
||||
canDelete?: boolean
|
||||
onDeleted?: (id: string) => void
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
export function FileList({
|
||||
files,
|
||||
canDelete = false,
|
||||
onDeleted,
|
||||
emptyTitle = "No files",
|
||||
emptyDescription = "There are no files yet.",
|
||||
}: FileListProps) {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (file: FileAttachment) => {
|
||||
setDeletingId(file.id)
|
||||
try {
|
||||
const res = await fetch(`/api/files/${file.id}`, { method: "DELETE" })
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok || !body?.success) {
|
||||
toast.error(body?.message || "Failed to delete file")
|
||||
return
|
||||
}
|
||||
toast.success("File deleted")
|
||||
onDeleted?.(file.id)
|
||||
} catch {
|
||||
toast.error("Failed to delete file")
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
icon={FileWarning}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{files.map((file) => (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={file.url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium hover:underline"
|
||||
title={file.originalName}
|
||||
>
|
||||
{file.originalName}
|
||||
</a>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{file.mimeType}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(file.createdAt, "zh-CN")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title="Download"
|
||||
>
|
||||
<a
|
||||
href={file.url ?? "#"}
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="sr-only">Download</span>
|
||||
</a>
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="Delete"
|
||||
disabled={deletingId === file.id}
|
||||
onClick={() => void handleDelete(file)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
import {
|
||||
@@ -26,17 +27,26 @@ interface FilePreviewDialogProps {
|
||||
export function FilePreviewDialog({
|
||||
file,
|
||||
trigger,
|
||||
triggerLabel = "Preview",
|
||||
triggerLabel,
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "sm",
|
||||
}: FilePreviewDialogProps) {
|
||||
}: FilePreviewDialogProps): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button type="button" variant={triggerVariant} size={triggerSize}>
|
||||
<Eye className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"} />
|
||||
{triggerLabel ? <span>{triggerLabel}</span> : <span className="sr-only">Preview</span>}
|
||||
<Eye
|
||||
className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{triggerLabel ? (
|
||||
<span>{triggerLabel}</span>
|
||||
) : (
|
||||
<span className="sr-only">{t("trigger")}</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
@@ -44,7 +54,7 @@ export function FilePreviewDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
File preview · {file.mimeType}
|
||||
{t("title")} · {file.mimeType}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[75vh] overflow-auto">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||
import { useFilePreview, useImageZoom } from "../hooks/use-file-preview"
|
||||
import type { FileAttachment } from "../types"
|
||||
|
||||
interface FilePreviewProps {
|
||||
@@ -42,7 +43,8 @@ function classify(mimeType: string): PreviewKind {
|
||||
return "other"
|
||||
}
|
||||
|
||||
export function FilePreview({ file, className }: FilePreviewProps) {
|
||||
export function FilePreview({ file, className }: FilePreviewProps): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
const kind = classify(file.mimeType)
|
||||
const url = file.url ?? "#"
|
||||
|
||||
@@ -66,9 +68,10 @@ export function FilePreview({ file, className }: FilePreviewProps) {
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${t("download")} ${file.originalName}`}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("download")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -86,7 +89,7 @@ function PreviewBody({
|
||||
kind: PreviewKind
|
||||
file: FileAttachment
|
||||
url: string
|
||||
}) {
|
||||
}): React.ReactElement {
|
||||
if (kind === "image") {
|
||||
return <ImagePreview url={url} alt={file.originalName} />
|
||||
}
|
||||
@@ -96,6 +99,7 @@ function PreviewBody({
|
||||
<iframe
|
||||
src={url}
|
||||
title={file.originalName}
|
||||
aria-label={`PDF preview: ${file.originalName}`}
|
||||
className="h-[70vh] w-full rounded-md border"
|
||||
/>
|
||||
)
|
||||
@@ -105,35 +109,12 @@ function PreviewBody({
|
||||
return <TextPreview url={url} />
|
||||
}
|
||||
|
||||
// Office / other: show info card + download button
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{kind === "office" ? "Office file preview not available" : "Preview not available"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{kind === "office"
|
||||
? "Download the file to view its contents in your Office application."
|
||||
: "Download the file to view its contents."}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||
<a
|
||||
href={url}
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
return <OtherPreview kind={kind} file={file} url={url} />
|
||||
}
|
||||
|
||||
function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
function ImagePreview({ url, alt }: { url: string; alt: string }): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useImageZoom()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -143,13 +124,13 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))}
|
||||
disabled={zoom <= 0.25}
|
||||
onClick={zoomOut}
|
||||
disabled={!canZoomOut}
|
||||
aria-label={t("zoomOut")}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
<span className="sr-only">Zoom out</span>
|
||||
<ZoomOut className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground w-12 text-center">
|
||||
<span className="w-12 text-center text-xs text-muted-foreground" aria-live="polite">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
@@ -157,14 +138,17 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom((z) => Math.min(4, z + 0.25))}
|
||||
disabled={zoom >= 4}
|
||||
onClick={zoomIn}
|
||||
disabled={!canZoomIn}
|
||||
aria-label={t("zoomIn")}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
<span className="sr-only">Zoom in</span>
|
||||
<ZoomIn className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto rounded-md border bg-muted/30 p-2" style={{ maxHeight: "70vh" }}>
|
||||
<div
|
||||
className="overflow-auto rounded-md border bg-muted/30 p-2"
|
||||
style={{ maxHeight: "70vh" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
@@ -177,34 +161,23 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TextPreview({ url }: { url: string }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const text = await res.text()
|
||||
setContent(text)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load text")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
function TextPreview({ url }: { url: string }): React.ReactElement {
|
||||
const t = useTranslations("files.preview.text")
|
||||
const { content, error, loading, load } = useFilePreview()
|
||||
|
||||
if (content === null && !error && !loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">Text file</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Click below to load the content.</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => void load()}>
|
||||
Load preview
|
||||
<FileText className="h-12 w-12 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="mt-3 text-sm font-medium">{t("title")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("hint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => void load(url)}
|
||||
>
|
||||
{t("load")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -212,16 +185,29 @@ function TextPreview({ url }: { url: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{t("loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Failed to load text: {error}
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center justify-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
>
|
||||
<span>{t("error", { message: error })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load(url)}
|
||||
>
|
||||
{t("load")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -232,3 +218,38 @@ function TextPreview({ url }: { url: string }) {
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function OtherPreview({
|
||||
kind,
|
||||
file,
|
||||
url,
|
||||
}: {
|
||||
kind: PreviewKind
|
||||
file: FileAttachment
|
||||
url: string
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
const isOffice = kind === "office"
|
||||
const title = isOffice ? t("office.title") : t("other.title")
|
||||
const hint = isOffice ? t("office.hint") : t("other.hint")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
|
||||
<p className="mt-3 text-sm font-medium">{title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||
<a
|
||||
href={url}
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${t("download")} ${file.originalName}`}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("download")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { UploadCloud, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
formatFileSize,
|
||||
MAX_FILE_SIZE,
|
||||
} from "@/shared/lib/file-storage"
|
||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { useFileUpload } from "../hooks/use-file-upload"
|
||||
import type { FileTargetType, FileUploadResult } from "../types"
|
||||
|
||||
interface FileUploadProps {
|
||||
@@ -23,146 +20,31 @@ interface FileUploadProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface UploadTask {
|
||||
file: File
|
||||
progress: number
|
||||
status: "uploading" | "success" | "error"
|
||||
message?: string
|
||||
result?: FileUploadResult
|
||||
}
|
||||
|
||||
const ACCEPT_ATTR = (ALLOWED_MIME_TYPES as readonly string[]).join(",")
|
||||
|
||||
export function FileUpload({
|
||||
targetType,
|
||||
targetId,
|
||||
onUploaded,
|
||||
multiple = true,
|
||||
className,
|
||||
}: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [tasks, setTasks] = useState<UploadTask[]>([])
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (file.size === 0) return "File is empty"
|
||||
if (file.size > MAX_FILE_SIZE) return "File size exceeds 10MB limit"
|
||||
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
|
||||
return `File type ${file.type || "unknown"} is not allowed`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const uploadOne = useCallback(
|
||||
async (file: File): Promise<void> => {
|
||||
const taskId = `${file.name}-${file.size}-${Date.now()}`
|
||||
setTasks((prev) => [
|
||||
...prev,
|
||||
{ file, progress: 0, status: "uploading" },
|
||||
])
|
||||
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file
|
||||
? { ...t, status: "error", message: validationError, progress: 100 }
|
||||
: t
|
||||
)
|
||||
)
|
||||
toast.error(`${file.name}: ${validationError}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
if (targetType) formData.append("targetType", targetType)
|
||||
if (targetId) formData.append("targetId", targetId)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const result = await new Promise<FileUploadResult>((resolve, reject) => {
|
||||
xhr.open("POST", "/api/upload")
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100)
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file ? { ...t, progress: pct } : t
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText)
|
||||
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
|
||||
resolve(body as FileUploadResult)
|
||||
} else {
|
||||
reject(new Error(body.message || "Upload failed"))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error("Invalid response"))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new Error("Network error"))
|
||||
xhr.send(formData)
|
||||
})
|
||||
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file
|
||||
? { ...t, status: "success", progress: 100, result }
|
||||
: t
|
||||
)
|
||||
)
|
||||
onUploaded?.(result)
|
||||
toast.success(`${file.name} uploaded`)
|
||||
void taskId
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Upload failed"
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file ? { ...t, status: "error", message } : t
|
||||
)
|
||||
)
|
||||
toast.error(`${file.name}: ${message}`)
|
||||
}
|
||||
},
|
||||
[targetType, targetId, onUploaded]
|
||||
)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return
|
||||
const files = Array.from(fileList)
|
||||
if (!multiple) {
|
||||
void uploadOne(files[0])
|
||||
} else {
|
||||
files.forEach((f) => void uploadOne(f))
|
||||
}
|
||||
},
|
||||
[uploadOne, multiple]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
},
|
||||
[handleFiles]
|
||||
)
|
||||
|
||||
const removeTask = (task: UploadTask) => {
|
||||
setTasks((prev) => prev.filter((t) => t !== task))
|
||||
}
|
||||
}: FileUploadProps): React.ReactElement {
|
||||
const t = useTranslations("files.upload")
|
||||
const {
|
||||
tasks,
|
||||
isDragging,
|
||||
inputRef,
|
||||
setIsDragging,
|
||||
handleFiles,
|
||||
removeTask,
|
||||
acceptAttr,
|
||||
maxFileSize,
|
||||
} = useFileUpload({ targetType, targetId, multiple, onUploaded })
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-describedby="file-upload-hint"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -175,7 +57,11 @@ export function FileUpload({
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||
isDragging
|
||||
@@ -183,19 +69,18 @@ export function FileUpload({
|
||||
: "border-input hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<UploadCloud className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
Click to upload or drag and drop
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {formatFileSize(MAX_FILE_SIZE)}
|
||||
<UploadCloud className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="mt-2 text-sm font-medium">{t("title")}</p>
|
||||
<p id="file-upload-hint" className="mt-1 text-xs text-muted-foreground">
|
||||
{t("hint", { size: formatFileSize(maxFileSize) })}
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={ACCEPT_ATTR}
|
||||
accept={acceptAttr}
|
||||
multiple={multiple}
|
||||
aria-label={t("title")}
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files)
|
||||
e.target.value = ""
|
||||
@@ -204,7 +89,7 @@ export function FileUpload({
|
||||
</div>
|
||||
|
||||
{tasks.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-2" aria-live="polite">
|
||||
{tasks.map((task, idx) => (
|
||||
<li
|
||||
key={`${task.file.name}-${idx}`}
|
||||
@@ -221,13 +106,17 @@ export function FileUpload({
|
||||
</span>
|
||||
</div>
|
||||
{task.status === "uploading" ? (
|
||||
<Progress value={task.progress} className="h-1.5" />
|
||||
<Progress
|
||||
value={task.progress}
|
||||
className="h-1.5"
|
||||
aria-label={`Uploading ${task.file.name}`}
|
||||
/>
|
||||
) : null}
|
||||
{task.status === "error" ? (
|
||||
<p className="text-xs text-destructive">{task.message}</p>
|
||||
) : null}
|
||||
{task.status === "success" ? (
|
||||
<p className="text-xs text-green-600">Uploaded</p>
|
||||
<p className="text-xs text-green-600">{t("uploaded")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
@@ -236,9 +125,9 @@ export function FileUpload({
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => removeTask(task)}
|
||||
aria-label="Remove"
|
||||
aria-label={t("remove")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
132
src/modules/files/hooks/use-file-batch-operations.ts
Normal file
132
src/modules/files/hooks/use-file-batch-operations.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { FileAttachment } from "../types"
|
||||
|
||||
export interface UseFileBatchOperationsOptions {
|
||||
files: FileAttachment[]
|
||||
onAfterDelete?: () => void
|
||||
}
|
||||
|
||||
export interface UseFileBatchOperationsReturn {
|
||||
selectedIds: Set<string>
|
||||
typeFilter: string
|
||||
search: string
|
||||
deleting: boolean
|
||||
setTypeFilter: (v: string) => void
|
||||
setSearch: (v: string) => void
|
||||
filteredFiles: FileAttachment[]
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
toggleAll: () => void
|
||||
toggleOne: (id: string) => void
|
||||
handleBatchDelete: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件批量操作 hook:封装筛选、选择、批量删除逻辑。
|
||||
*
|
||||
* 与 UI 解耦,便于在 AdminFilesView 或其他列表场景复用。
|
||||
*/
|
||||
export function useFileBatchOperations({
|
||||
files,
|
||||
onAfterDelete,
|
||||
}: UseFileBatchOperationsOptions): UseFileBatchOperationsReturn {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("files.admin")
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||
const [search, setSearch] = useState<string>("")
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
return files.filter((f) => {
|
||||
if (typeFilter !== "all") {
|
||||
if (typeFilter.endsWith("/")) {
|
||||
if (!f.mimeType.startsWith(typeFilter)) return false
|
||||
} else if (f.mimeType !== typeFilter) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (search.trim()) {
|
||||
const kw = search.trim().toLowerCase()
|
||||
if (
|
||||
!f.originalName.toLowerCase().includes(kw) &&
|
||||
!f.filename.toLowerCase().includes(kw)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [files, typeFilter, search])
|
||||
|
||||
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
|
||||
const someSelected = selectedIds.size > 0 && !allSelected
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
|
||||
}
|
||||
}, [allSelected, filteredFiles])
|
||||
|
||||
const toggleOne = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
const ids = Array.from(selectedIds)
|
||||
setDeleting(true)
|
||||
try {
|
||||
const res = await fetch("/api/files/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
success?: boolean
|
||||
message?: string
|
||||
deletedCount?: number
|
||||
} | null
|
||||
if (!res.ok || !body?.success) {
|
||||
toast.error(body?.message || t("selection.deleteFailed"))
|
||||
return
|
||||
}
|
||||
toast.success(t("selection.deleted", { count: body.deletedCount ?? 0 }))
|
||||
setSelectedIds(new Set())
|
||||
onAfterDelete?.()
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error(t("selection.deleteFailed"))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [selectedIds, onAfterDelete, router, t])
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
typeFilter,
|
||||
search,
|
||||
deleting,
|
||||
setTypeFilter,
|
||||
setSearch,
|
||||
filteredFiles,
|
||||
allSelected,
|
||||
someSelected,
|
||||
toggleAll,
|
||||
toggleOne,
|
||||
handleBatchDelete,
|
||||
}
|
||||
}
|
||||
64
src/modules/files/hooks/use-file-preview.ts
Normal file
64
src/modules/files/hooks/use-file-preview.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
/**
|
||||
* 文本文件预览 hook:封装 fetch + 错误处理 + 状态机。
|
||||
*
|
||||
* 与 TextPreview 组件解耦,便于复用与测试。
|
||||
* 错误消息保留原始字符串,由组件层用 i18n 翻译。
|
||||
*/
|
||||
export interface UseFilePreviewReturn {
|
||||
content: string | null
|
||||
error: string | null
|
||||
loading: boolean
|
||||
load: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useFilePreview(): UseFilePreviewReturn {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (url: string): Promise<void> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const text = await res.text()
|
||||
setContent(text)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load text")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { content, error, loading, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览缩放控制 hook
|
||||
*/
|
||||
export function useImageZoom(initial = 1, min = 0.25, max = 4): {
|
||||
zoom: number
|
||||
zoomIn: () => void
|
||||
zoomOut: () => void
|
||||
canZoomIn: boolean
|
||||
canZoomOut: boolean
|
||||
} {
|
||||
const [zoom, setZoom] = useState(initial)
|
||||
const zoomIn = useCallback(() => setZoom((z) => Math.min(max, z + 0.25)), [max])
|
||||
const zoomOut = useCallback(() => setZoom((z) => Math.max(min, z - 0.25)), [min])
|
||||
return {
|
||||
zoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
canZoomIn: zoom < max,
|
||||
canZoomOut: zoom > min,
|
||||
}
|
||||
}
|
||||
181
src/modules/files/hooks/use-file-upload.ts
Normal file
181
src/modules/files/hooks/use-file-upload.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
formatFileSize,
|
||||
MAX_FILE_SIZE,
|
||||
} from "@/shared/lib/file-storage"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { FileTargetType, FileUploadResult } from "../types"
|
||||
|
||||
export interface UploadTask {
|
||||
file: File
|
||||
progress: number
|
||||
status: "uploading" | "success" | "error"
|
||||
message?: string
|
||||
result?: FileUploadResult
|
||||
}
|
||||
|
||||
export interface UseFileUploadOptions {
|
||||
targetType?: FileTargetType
|
||||
targetId?: string
|
||||
multiple?: boolean
|
||||
onUploaded?: (result: FileUploadResult) => void
|
||||
}
|
||||
|
||||
export interface UseFileUploadReturn {
|
||||
tasks: UploadTask[]
|
||||
isDragging: boolean
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
setIsDragging: (v: boolean) => void
|
||||
handleFiles: (fileList: FileList | null) => void
|
||||
removeTask: (task: UploadTask) => void
|
||||
acceptAttr: string
|
||||
maxFileSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传 hook:封装 XHR 上传、进度、状态机与校验。
|
||||
*
|
||||
* 与 UI 解耦,便于在 `FileUpload`、`AvatarUpload` 等组件中复用,
|
||||
* 也可独立测试(无 DOM 依赖的逻辑部分)。
|
||||
*/
|
||||
export function useFileUpload({
|
||||
targetType,
|
||||
targetId,
|
||||
multiple = true,
|
||||
onUploaded,
|
||||
}: UseFileUploadOptions): UseFileUploadReturn {
|
||||
const t = useTranslations("files.upload")
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [tasks, setTasks] = useState<UploadTask[]>([])
|
||||
|
||||
const validateFile = useCallback(
|
||||
(file: File): string | null => {
|
||||
if (file.size === 0) return t("empty")
|
||||
if (file.size > MAX_FILE_SIZE) return t("tooLarge", { limit: formatFileSize(MAX_FILE_SIZE) })
|
||||
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
|
||||
return t("invalidType", { type: file.type || "unknown" })
|
||||
}
|
||||
return null
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const uploadOne = useCallback(
|
||||
async (file: File): Promise<void> => {
|
||||
setTasks((prev) => [
|
||||
...prev,
|
||||
{ file, progress: 0, status: "uploading" },
|
||||
])
|
||||
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
setTasks((prev) =>
|
||||
prev.map((tk) =>
|
||||
tk.file === file
|
||||
? { ...tk, status: "error", message: validationError, progress: 100 }
|
||||
: tk
|
||||
)
|
||||
)
|
||||
toast.error(t("error", { name: file.name, message: validationError }))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
if (targetType) formData.append("targetType", targetType)
|
||||
if (targetId) formData.append("targetId", targetId)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const result = await new Promise<FileUploadResult>((resolve, reject) => {
|
||||
xhr.open("POST", "/api/upload")
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100)
|
||||
setTasks((prev) =>
|
||||
prev.map((tk) =>
|
||||
tk.file === file ? { ...tk, progress: pct } : tk
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText) as { success?: boolean; message?: string } & Partial<FileUploadResult>
|
||||
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
|
||||
resolve({
|
||||
id: body.id ?? "",
|
||||
url: body.url ?? "",
|
||||
filename: body.filename ?? file.name,
|
||||
originalName: body.originalName ?? file.name,
|
||||
size: body.size ?? file.size,
|
||||
mimeType: body.mimeType ?? file.type,
|
||||
})
|
||||
} else {
|
||||
reject(new Error(body.message || "Upload failed"))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error(t("invalidResponse")))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new Error(t("networkError")))
|
||||
xhr.send(formData)
|
||||
})
|
||||
|
||||
setTasks((prev) =>
|
||||
prev.map((tk) =>
|
||||
tk.file === file
|
||||
? { ...tk, status: "success", progress: 100, result }
|
||||
: tk
|
||||
)
|
||||
)
|
||||
onUploaded?.(result)
|
||||
toast.success(t("success", { name: file.name }))
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : t("networkError")
|
||||
setTasks((prev) =>
|
||||
prev.map((tk) =>
|
||||
tk.file === file ? { ...tk, status: "error", message } : tk
|
||||
)
|
||||
)
|
||||
toast.error(t("error", { name: file.name, message }))
|
||||
}
|
||||
},
|
||||
[targetType, targetId, onUploaded, t, validateFile]
|
||||
)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return
|
||||
const files = Array.from(fileList)
|
||||
if (!multiple) {
|
||||
void uploadOne(files[0])
|
||||
} else {
|
||||
files.forEach((f) => void uploadOne(f))
|
||||
}
|
||||
},
|
||||
[uploadOne, multiple]
|
||||
)
|
||||
|
||||
const removeTask = useCallback((task: UploadTask) => {
|
||||
setTasks((prev) => prev.filter((tk) => tk !== task))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
tasks,
|
||||
isDragging,
|
||||
inputRef,
|
||||
setIsDragging,
|
||||
handleFiles,
|
||||
removeTask,
|
||||
acceptAttr: (ALLOWED_MIME_TYPES as readonly string[]).join(","),
|
||||
maxFileSize: MAX_FILE_SIZE,
|
||||
}
|
||||
}
|
||||
84
src/modules/files/schema.ts
Normal file
84
src/modules/files/schema.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { MAX_FILE_SIZE } from "@/shared/lib/file-storage"
|
||||
|
||||
import type { FileTargetType } from "./types"
|
||||
|
||||
/**
|
||||
* files 模块 Zod 校验 schema
|
||||
*
|
||||
* 用于 Server Action 与 API 路由的输入校验,替代手写 typeof 检查与 as 断言。
|
||||
*/
|
||||
|
||||
// FileTargetType 枚举值同步到 Zod(保持单一来源:types.ts)
|
||||
const FILE_TARGET_TYPES: readonly FileTargetType[] = [
|
||||
"exam",
|
||||
"textbook",
|
||||
"question",
|
||||
"announcement",
|
||||
"homework",
|
||||
"user_avatar",
|
||||
"message",
|
||||
]
|
||||
|
||||
export const FileTargetTypeSchema = z.enum(
|
||||
FILE_TARGET_TYPES as unknown as [FileTargetType, ...FileTargetType[]]
|
||||
)
|
||||
|
||||
/**
|
||||
* 文件上传元数据校验(targetType / targetId 来自 FormData)
|
||||
*
|
||||
* targetType 可选;targetId 仅在 targetType 提供时才校验长度。
|
||||
*/
|
||||
export const UploadMetadataSchema = z.object({
|
||||
targetType: FileTargetTypeSchema.optional().nullable(),
|
||||
targetId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(128)
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => (v && v.length > 0 ? v : null)),
|
||||
})
|
||||
|
||||
export type UploadMetadata = z.infer<typeof UploadMetadataSchema>
|
||||
|
||||
/**
|
||||
* 批量删除请求体校验
|
||||
*
|
||||
* - ids 必须为非空字符串数组
|
||||
* - 单次最多 100 条,防止超长 SQL
|
||||
* - 每条 id 长度上限 128(与 schema.id varchar(128) 一致)
|
||||
*/
|
||||
export const BatchDeleteSchema = z.object({
|
||||
ids: z
|
||||
.array(z.string().min(1).max(128))
|
||||
.min(1, "No file ids provided")
|
||||
.max(100, "Cannot delete more than 100 files at once"),
|
||||
})
|
||||
|
||||
export type BatchDeleteInput = z.infer<typeof BatchDeleteSchema>
|
||||
|
||||
/**
|
||||
* 管理员文件列表筛选参数校验
|
||||
*
|
||||
* - mimeType:精确或前缀匹配("image/")
|
||||
* - search:文件名模糊匹配
|
||||
* - limit:1..200,默认 100
|
||||
* - offset:>=0,默认 0
|
||||
*/
|
||||
export const FileListQuerySchema = z.object({
|
||||
mimeType: z.string().trim().max(128).optional().nullable(),
|
||||
search: z.string().trim().max(255).optional().nullable(),
|
||||
limit: z.number().int().min(1).max(200).default(100),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
export type FileListQuery = z.infer<typeof FileListQuerySchema>
|
||||
|
||||
/**
|
||||
* 文件大小校验(用于客户端/服务端一致校验)
|
||||
*/
|
||||
export function validateFileSize(size: number): boolean {
|
||||
return size > 0 && size <= MAX_FILE_SIZE
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
// 文件关联的目标资源类型(多态关联)
|
||||
export type FileTargetType = "exam" | "textbook" | "question" | "announcement" | "homework"
|
||||
// P1-5 新增 "user_avatar":用于用户头像上传场景的 targetType 字段对齐
|
||||
// P2-3 新增 "message":用于私信附件上传场景的 targetType 字段对齐
|
||||
export type FileTargetType =
|
||||
| "exam"
|
||||
| "textbook"
|
||||
| "question"
|
||||
| "announcement"
|
||||
| "homework"
|
||||
| "user_avatar"
|
||||
| "message"
|
||||
|
||||
// 文件附件记录(DB 行的 TypeScript 表示)
|
||||
export interface FileAttachment {
|
||||
|
||||
Reference in New Issue
Block a user