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:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View 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>
)
}

View 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" />
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 []
}
}

View 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[]
}