feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
289
src/modules/files/components/admin-files-view.tsx
Normal file
289
src/modules/files/components/admin-files-view.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
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 { FileUpload } from "./file-upload"
|
||||
import { FilePreviewDialog } from "./file-preview-dialog"
|
||||
import type { FileAttachment, FileStats } from "../types"
|
||||
|
||||
interface AdminFilesViewProps {
|
||||
files: FileAttachment[]
|
||||
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" },
|
||||
]
|
||||
|
||||
export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Upload and manage all files in the system.
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{t.count}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FileUpload onUploaded={handleUploaded} />
|
||||
|
||||
{/* 筛选与批量操作工具栏 */}
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</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" />
|
||||
<Input
|
||||
placeholder="Search by file name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedIds.size > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{selectedIds.size} selected</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
onClick={() => void handleBatchDelete()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{deleting ? "Deleting..." : "Delete Selected"}
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
src/modules/files/components/file-icon.tsx
Normal file
86
src/modules/files/components/file-icon.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
File as FileLucide,
|
||||
FileText,
|
||||
FileImage,
|
||||
FileArchive,
|
||||
FileSpreadsheet,
|
||||
Presentation,
|
||||
FileType,
|
||||
} from "lucide-react"
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
type FileCategory =
|
||||
| "image"
|
||||
| "pdf"
|
||||
| "word"
|
||||
| "excel"
|
||||
| "powerpoint"
|
||||
| "text"
|
||||
| "archive"
|
||||
| "other"
|
||||
|
||||
const ICON_MAP: Record<FileCategory, ComponentType<{ className?: string }>> = {
|
||||
image: FileImage,
|
||||
pdf: FileText,
|
||||
word: FileText,
|
||||
excel: FileSpreadsheet,
|
||||
powerpoint: Presentation,
|
||||
text: FileType,
|
||||
archive: FileArchive,
|
||||
other: FileLucide,
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<FileCategory, string> = {
|
||||
image: "text-pink-600",
|
||||
pdf: "text-red-600",
|
||||
word: "text-blue-600",
|
||||
excel: "text-green-600",
|
||||
powerpoint: "text-orange-600",
|
||||
text: "text-gray-600",
|
||||
archive: "text-yellow-600",
|
||||
other: "text-muted-foreground",
|
||||
}
|
||||
|
||||
function resolveCategory(mimeType: string): FileCategory {
|
||||
if (mimeType.startsWith("image/")) return "image"
|
||||
if (mimeType === "application/pdf") return "pdf"
|
||||
if (
|
||||
mimeType === "application/msword" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
) {
|
||||
return "word"
|
||||
}
|
||||
if (
|
||||
mimeType === "application/vnd.ms-excel" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
) {
|
||||
return "excel"
|
||||
}
|
||||
if (
|
||||
mimeType === "application/vnd.ms-powerpoint" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
) {
|
||||
return "powerpoint"
|
||||
}
|
||||
if (mimeType === "text/plain" || mimeType === "text/markdown") return "text"
|
||||
if (mimeType === "application/zip" || mimeType === "application/x-rar-compressed") {
|
||||
return "archive"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
export function FileIcon({
|
||||
mimeType,
|
||||
className,
|
||||
}: {
|
||||
mimeType: string
|
||||
className?: string
|
||||
}) {
|
||||
const category = resolveCategory(mimeType)
|
||||
const Icon = ICON_MAP[category]
|
||||
return (
|
||||
<Icon className={cn("h-5 w-5", COLOR_MAP[category], className)} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
126
src/modules/files/components/file-list.tsx
Normal file
126
src/modules/files/components/file-list.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
56
src/modules/files/components/file-preview-dialog.tsx
Normal file
56
src/modules/files/components/file-preview-dialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FilePreview } from "./file-preview"
|
||||
import type { FileAttachment } from "../types"
|
||||
|
||||
interface FilePreviewDialogProps {
|
||||
file: FileAttachment
|
||||
trigger?: React.ReactNode
|
||||
triggerLabel?: string
|
||||
triggerVariant?: "default" | "outline" | "secondary" | "ghost" | "destructive"
|
||||
triggerSize?: "default" | "sm" | "lg" | "icon"
|
||||
}
|
||||
|
||||
export function FilePreviewDialog({
|
||||
file,
|
||||
trigger,
|
||||
triggerLabel = "Preview",
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "sm",
|
||||
}: FilePreviewDialogProps) {
|
||||
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>}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] max-w-5xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
File preview · {file.mimeType}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[75vh] overflow-auto">
|
||||
<FilePreview file={file} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
234
src/modules/files/components/file-preview.tsx
Normal file
234
src/modules/files/components/file-preview.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
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 type { FileAttachment } from "../types"
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: FileAttachment
|
||||
className?: string
|
||||
}
|
||||
|
||||
type PreviewKind = "image" | "pdf" | "text" | "office" | "other"
|
||||
|
||||
const TEXT_MIME_TYPES = new Set([
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
"text/html",
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
])
|
||||
|
||||
const OFFICE_MIME_TYPES = new Set([
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
])
|
||||
|
||||
function classify(mimeType: string): PreviewKind {
|
||||
if (mimeType.startsWith("image/")) return "image"
|
||||
if (mimeType === "application/pdf") return "pdf"
|
||||
if (TEXT_MIME_TYPES.has(mimeType)) return "text"
|
||||
if (OFFICE_MIME_TYPES.has(mimeType)) return "office"
|
||||
return "other"
|
||||
}
|
||||
|
||||
export function FilePreview({ file, className }: FilePreviewProps) {
|
||||
const kind = classify(file.mimeType)
|
||||
const url = file.url ?? "#"
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileIcon mimeType={file.mimeType} className="h-5 w-5" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium" title={file.originalName}>
|
||||
{file.originalName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)} · {file.mimeType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={url}
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PreviewBody kind={kind} file={file} url={url} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewBody({
|
||||
kind,
|
||||
file,
|
||||
url,
|
||||
}: {
|
||||
kind: PreviewKind
|
||||
file: FileAttachment
|
||||
url: string
|
||||
}) {
|
||||
if (kind === "image") {
|
||||
return <ImagePreview url={url} alt={file.originalName} />
|
||||
}
|
||||
|
||||
if (kind === "pdf") {
|
||||
return (
|
||||
<iframe
|
||||
src={url}
|
||||
title={file.originalName}
|
||||
className="h-[70vh] w-full rounded-md border"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === "text") {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))}
|
||||
disabled={zoom <= 0.25}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
<span className="sr-only">Zoom out</span>
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom((z) => Math.min(4, z + 0.25))}
|
||||
disabled={zoom >= 4}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
<span className="sr-only">Zoom in</span>
|
||||
</Button>
|
||||
</div>
|
||||
<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}
|
||||
alt={alt}
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: "top left" }}
|
||||
className="mx-auto max-w-full transition-transform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="max-h-[70vh] overflow-auto rounded-md border bg-background p-4 text-xs leading-relaxed">
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
249
src/modules/files/components/file-upload.tsx
Normal file
249
src/modules/files/components/file-upload.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
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 { FileIcon } from "./file-icon"
|
||||
import type { FileTargetType, FileUploadResult } from "../types"
|
||||
|
||||
interface FileUploadProps {
|
||||
targetType?: FileTargetType
|
||||
targetId?: string
|
||||
onUploaded?: (result: FileUploadResult) => void
|
||||
multiple?: boolean
|
||||
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))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "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)}
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={ACCEPT_ATTR}
|
||||
multiple={multiple}
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tasks.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{tasks.map((task, idx) => (
|
||||
<li
|
||||
key={`${task.file.name}-${idx}`}
|
||||
className="flex items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<FileIcon mimeType={task.file.type} />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{task.file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatFileSize(task.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
{task.status === "uploading" ? (
|
||||
<Progress value={task.progress} className="h-1.5" />
|
||||
) : 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>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => removeTask(task)}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
src/modules/files/data-access.ts
Normal file
267
src/modules/files/data-access.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, count, desc, eq, inArray, like, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { fileAttachments } from "@/shared/db/schema"
|
||||
import type {
|
||||
BatchDeleteResult,
|
||||
CreateFileAttachmentInput,
|
||||
FileAttachment,
|
||||
FileAttachmentQueryParams,
|
||||
FileStats,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({
|
||||
id: row.id,
|
||||
filename: row.filename,
|
||||
originalName: row.originalName,
|
||||
mimeType: row.mimeType,
|
||||
size: row.size,
|
||||
storagePath: row.storagePath,
|
||||
url: row.url,
|
||||
uploaderId: row.uploaderId,
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
createdAt: toIso(row.createdAt),
|
||||
})
|
||||
|
||||
/**
|
||||
* 插入文件附件记录
|
||||
*/
|
||||
export async function createFileAttachment(
|
||||
data: CreateFileAttachmentInput
|
||||
): Promise<FileAttachment | null> {
|
||||
try {
|
||||
await db.insert(fileAttachments).values({
|
||||
id: data.id,
|
||||
filename: data.filename,
|
||||
originalName: data.originalName,
|
||||
mimeType: data.mimeType,
|
||||
size: data.size,
|
||||
storagePath: data.storagePath,
|
||||
url: data.url,
|
||||
uploaderId: data.uploaderId,
|
||||
targetType: data.targetType ?? null,
|
||||
targetId: data.targetId ?? null,
|
||||
})
|
||||
|
||||
const created = await getFileAttachment(data.id)
|
||||
return created
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询文件附件
|
||||
*/
|
||||
export async function getFileAttachment(id: string): Promise<FileAttachment | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(eq(fileAttachments.id, id))
|
||||
.limit(1)
|
||||
|
||||
return row ? mapRow(row) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关联资源查询文件列表
|
||||
*/
|
||||
export async function getFileAttachmentsByTarget(
|
||||
targetType: string,
|
||||
targetId: string
|
||||
): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(fileAttachments.targetType, targetType),
|
||||
eq(fileAttachments.targetId, targetId)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按上传者查询文件列表
|
||||
*/
|
||||
export async function getFileAttachmentsByUploader(
|
||||
uploaderId: string
|
||||
): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(eq(fileAttachments.uploaderId, uploaderId))
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有文件(用于管理员文件管理页面)
|
||||
*/
|
||||
export async function getAllFileAttachments(limit = 100): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件附件记录
|
||||
*/
|
||||
export async function deleteFileAttachment(id: string): Promise<boolean> {
|
||||
try {
|
||||
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除文件附件记录
|
||||
* 仅删除数据库记录,磁盘文件由调用方处理
|
||||
*/
|
||||
export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteResult> {
|
||||
if (ids.length === 0) {
|
||||
return { success: true, deletedCount: 0, failedIds: [] }
|
||||
}
|
||||
try {
|
||||
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
|
||||
return { success: true, deletedCount: ids.length, failedIds: [] }
|
||||
} catch {
|
||||
// 失败时回退到逐条删除,尽量多删
|
||||
const failedIds: string[] = []
|
||||
let deletedCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
|
||||
deletedCount += 1
|
||||
} catch {
|
||||
failedIds.push(id)
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: failedIds.length === 0,
|
||||
deletedCount,
|
||||
failedIds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按条件筛选文件列表(管理员页面)
|
||||
* - mimeType: 精确匹配或前缀匹配(如 "image/")
|
||||
* - search: 在 originalName / filename 中模糊匹配
|
||||
*/
|
||||
export async function getFileAttachmentsWithFilters(
|
||||
params: FileAttachmentQueryParams
|
||||
): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const { mimeType, search, limit = 100, offset = 0 } = params
|
||||
|
||||
const conditions = []
|
||||
if (mimeType) {
|
||||
if (mimeType.endsWith("/")) {
|
||||
conditions.push(like(fileAttachments.mimeType, `${mimeType}%`))
|
||||
} else {
|
||||
conditions.push(eq(fileAttachments.mimeType, mimeType))
|
||||
}
|
||||
}
|
||||
if (search) {
|
||||
const kw = `%${search}%`
|
||||
conditions.push(
|
||||
or(
|
||||
like(fileAttachments.originalName, kw),
|
||||
like(fileAttachments.filename, kw)
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(where)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件统计信息(总数、总大小、按类型分组)
|
||||
*/
|
||||
export async function getFileStats(): Promise<FileStats> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
mimeType: fileAttachments.mimeType,
|
||||
count: count(),
|
||||
size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`,
|
||||
})
|
||||
.from(fileAttachments)
|
||||
.groupBy(fileAttachments.mimeType)
|
||||
|
||||
const byType = rows.map((r) => ({
|
||||
mimeType: r.mimeType,
|
||||
count: Number(r.count),
|
||||
size: Number(r.size),
|
||||
}))
|
||||
|
||||
const totalCount = byType.reduce((sum, r) => sum + r.count, 0)
|
||||
const totalSize = byType.reduce((sum, r) => sum + r.size, 0)
|
||||
|
||||
return { totalCount, totalSize, byType }
|
||||
} catch {
|
||||
return { totalCount: 0, totalSize: 0, byType: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
|
||||
*/
|
||||
export async function getFileAttachmentsByIds(ids: string[]): Promise<FileAttachment[]> {
|
||||
if (ids.length === 0) return []
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(inArray(fileAttachments.id, ids))
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
63
src/modules/files/types.ts
Normal file
63
src/modules/files/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// 文件关联的目标资源类型(多态关联)
|
||||
export type FileTargetType = "exam" | "textbook" | "question" | "announcement"
|
||||
|
||||
// 文件附件记录(DB 行的 TypeScript 表示)
|
||||
export interface FileAttachment {
|
||||
id: string
|
||||
filename: string
|
||||
originalName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
storagePath: string
|
||||
url: string | null
|
||||
uploaderId: string
|
||||
targetType: string | null
|
||||
targetId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 上传成功后返回给前端的结果
|
||||
export interface FileUploadResult {
|
||||
id: string
|
||||
url: string
|
||||
filename: string
|
||||
originalName: string
|
||||
size: number
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
// 创建文件附件记录的输入
|
||||
export interface CreateFileAttachmentInput {
|
||||
id: string
|
||||
filename: string
|
||||
originalName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
storagePath: string
|
||||
url: string | null
|
||||
uploaderId: string
|
||||
targetType?: string | null
|
||||
targetId?: string | null
|
||||
}
|
||||
|
||||
// 文件查询参数(管理员页面筛选)
|
||||
export interface FileAttachmentQueryParams {
|
||||
mimeType?: string | null
|
||||
search?: string | null
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
// 文件统计信息
|
||||
export interface FileStats {
|
||||
totalCount: number
|
||||
totalSize: number
|
||||
byType: Array<{ mimeType: string; count: number; size: number }>
|
||||
}
|
||||
|
||||
// 批量删除结果
|
||||
export interface BatchDeleteResult {
|
||||
success: boolean
|
||||
deletedCount: number
|
||||
failedIds: string[]
|
||||
}
|
||||
Reference in New Issue
Block a user