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:
SpecialX
2026-07-03 10:26:12 +08:00
parent f3c223d914
commit e9a5264fe7
84 changed files with 6060 additions and 2530 deletions

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