feat: 支持批量上传关卡
This commit is contained in:
563
components/levels/batch-import-dialog.tsx
Normal file
563
components/levels/batch-import-dialog.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { FolderOpen, Trash2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { uploadToCos } from '@/lib/cos-client'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface BatchImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
type ItemStatus = 'pending' | 'uploading' | 'creating' | 'done' | 'error'
|
||||
|
||||
interface ParsedItem {
|
||||
id: string // riddle_id 或文件夹名,仅用于 React key
|
||||
folderName: string
|
||||
// 本地文件引用
|
||||
referenceFile?: File
|
||||
riddleFile?: File
|
||||
// 预览 URL(本地 blob URL)
|
||||
referencePreview?: string
|
||||
riddlePreview?: string
|
||||
// 从 metadata.json 提取
|
||||
anchorText: string
|
||||
answer: string
|
||||
punchline: string
|
||||
hint1: string
|
||||
hint2: string
|
||||
hint3: string
|
||||
// 解析警告/错误信息
|
||||
parseError?: string
|
||||
// 上传/创建过程状态
|
||||
status: ItemStatus
|
||||
statusMessage?: string
|
||||
}
|
||||
|
||||
interface FileWithPath extends File {
|
||||
webkitRelativePath: string
|
||||
}
|
||||
|
||||
interface RiddleMetadata {
|
||||
plan?: {
|
||||
riddle_id?: string
|
||||
anchor_text?: string
|
||||
answer?: string
|
||||
hints?: string[]
|
||||
riddle?: {
|
||||
homophone_explanation?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(s: string | undefined, max: number): string {
|
||||
if (!s) return ''
|
||||
return s.length > max ? s.slice(0, max) : s
|
||||
}
|
||||
|
||||
async function parseFolders(files: FileList): Promise<ParsedItem[]> {
|
||||
// 按第一层目录名分组
|
||||
const groups = new Map<string, FileWithPath[]>()
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const f = files[i] as FileWithPath
|
||||
const rel = f.webkitRelativePath
|
||||
if (!rel) continue
|
||||
const parts = rel.split('/')
|
||||
if (parts.length < 2) continue
|
||||
// 用倒数第二层作为关卡目录名:e.g. riddles/68f1.../metadata.json
|
||||
// 但通常用户选的根目录就是 riddles,第一层就是关卡目录
|
||||
// 我们取「包含 metadata.json 的那个目录」作为分组 key
|
||||
const folderKey = parts.slice(0, -1).join('/')
|
||||
if (!groups.has(folderKey)) groups.set(folderKey, [])
|
||||
groups.get(folderKey)!.push(f)
|
||||
}
|
||||
|
||||
const items: ParsedItem[] = []
|
||||
|
||||
for (const [folderKey, groupFiles] of Array.from(groups.entries())) {
|
||||
const metadata = groupFiles.find((f) => f.name === 'metadata.json')
|
||||
const reference = groupFiles.find((f) => f.name === 'reference.webp')
|
||||
const riddle = groupFiles.find((f) => f.name === 'riddle.webp')
|
||||
// 只有同时存在 metadata.json + reference.webp + riddle.webp 的目录才视为关卡
|
||||
if (!metadata || !reference || !riddle) continue
|
||||
|
||||
const folderName = folderKey.split('/').pop() || folderKey
|
||||
|
||||
const item: ParsedItem = {
|
||||
id: folderKey,
|
||||
folderName,
|
||||
referenceFile: reference,
|
||||
riddleFile: riddle,
|
||||
referencePreview: URL.createObjectURL(reference),
|
||||
riddlePreview: URL.createObjectURL(riddle),
|
||||
anchorText: '',
|
||||
answer: '',
|
||||
punchline: '',
|
||||
hint1: '',
|
||||
hint2: '',
|
||||
hint3: '',
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await metadata.text()
|
||||
const json = JSON.parse(text) as RiddleMetadata
|
||||
const plan = json.plan || {}
|
||||
item.anchorText = truncate(plan.anchor_text, 500)
|
||||
item.answer = (plan.answer || '').trim()
|
||||
item.punchline = (plan.riddle?.homophone_explanation || '').trim()
|
||||
const hints = Array.isArray(plan.hints) ? plan.hints : []
|
||||
item.hint1 = (hints[0] || '').trim()
|
||||
item.hint2 = (hints[1] || '').trim()
|
||||
item.hint3 = (hints[2] || '').trim()
|
||||
|
||||
if (!item.answer) {
|
||||
item.parseError = '未解析到 answer'
|
||||
} else if (item.answer.length > 4) {
|
||||
item.parseError = `答案超过 4 字(${item.answer.length})`
|
||||
}
|
||||
} catch (e) {
|
||||
item.parseError = `解析 metadata.json 失败: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`
|
||||
}
|
||||
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
// 稳定排序:按文件夹名
|
||||
items.sort((a, b) => a.folderName.localeCompare(b.folderName))
|
||||
return items
|
||||
}
|
||||
|
||||
export function BatchImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: BatchImportDialogProps) {
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
const [items, setItems] = useState<ParsedItem[]>([])
|
||||
const [isParsing, setIsParsing] = useState(false)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [globalError, setGlobalError] = useState('')
|
||||
|
||||
// 关闭后清理预览 URL 并重置
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
items.forEach((it) => {
|
||||
if (it.referencePreview) URL.revokeObjectURL(it.referencePreview)
|
||||
if (it.riddlePreview) URL.revokeObjectURL(it.riddlePreview)
|
||||
})
|
||||
setItems([])
|
||||
setGlobalError('')
|
||||
setIsRunning(false)
|
||||
setIsParsing(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = items.length
|
||||
const done = items.filter((i) => i.status === 'done').length
|
||||
const error = items.filter((i) => i.status === 'error').length
|
||||
const invalid = items.filter((i) => i.parseError).length
|
||||
return { total, done, error, invalid }
|
||||
}, [items])
|
||||
|
||||
const canSubmit =
|
||||
items.length > 0 &&
|
||||
!isRunning &&
|
||||
items.some((i) => i.status !== 'done' && !i.parseError)
|
||||
|
||||
const handleSelectFolder = () => {
|
||||
folderInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFolderChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
setIsParsing(true)
|
||||
setGlobalError('')
|
||||
try {
|
||||
const parsed = await parseFolders(files)
|
||||
if (parsed.length === 0) {
|
||||
setGlobalError(
|
||||
'未在所选目录中找到任何有效关卡。请确保每个子目录同时包含 metadata.json、reference.webp、riddle.webp'
|
||||
)
|
||||
}
|
||||
setItems(parsed)
|
||||
} catch (err) {
|
||||
setGlobalError(
|
||||
err instanceof Error ? err.message : '解析失败'
|
||||
)
|
||||
} finally {
|
||||
setIsParsing(false)
|
||||
// 允许重新选择同一目录
|
||||
if (folderInputRef.current) folderInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const updateItem = (id: string, patch: Partial<ParsedItem>) => {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => {
|
||||
if (it.id !== id) return it
|
||||
const next = { ...it, ...patch }
|
||||
// 每次编辑后重新评估答案的校验
|
||||
if (patch.answer !== undefined) {
|
||||
if (!next.answer) next.parseError = '未填写答案'
|
||||
else if (next.answer.length > 4)
|
||||
next.parseError = `答案超过 4 字(${next.answer.length})`
|
||||
else next.parseError = undefined
|
||||
}
|
||||
return next
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setItems((prev) => {
|
||||
const target = prev.find((i) => i.id === id)
|
||||
if (target) {
|
||||
if (target.referencePreview) URL.revokeObjectURL(target.referencePreview)
|
||||
if (target.riddlePreview) URL.revokeObjectURL(target.riddlePreview)
|
||||
}
|
||||
return prev.filter((i) => i.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleRun = async () => {
|
||||
setIsRunning(true)
|
||||
setGlobalError('')
|
||||
|
||||
// 使用函数式 setItems 取到最新列表
|
||||
const snapshot = items
|
||||
for (const it of snapshot) {
|
||||
if (it.status === 'done') continue
|
||||
if (it.parseError) continue
|
||||
if (!it.referenceFile || !it.riddleFile) continue
|
||||
|
||||
try {
|
||||
updateItem(it.id, { status: 'uploading', statusMessage: '上传图片…' })
|
||||
|
||||
const [image1Url, image2Url] = await Promise.all([
|
||||
uploadToCos(it.referenceFile, it.referenceFile.name),
|
||||
uploadToCos(it.riddleFile, it.riddleFile.name),
|
||||
])
|
||||
|
||||
updateItem(it.id, { status: 'creating', statusMessage: '创建关卡…' })
|
||||
|
||||
const payload = {
|
||||
image1Url,
|
||||
image1Description: it.anchorText || '',
|
||||
image2Url,
|
||||
image2Description: '',
|
||||
answer: it.answer,
|
||||
punchline: it.punchline || '',
|
||||
hint1: it.hint1 || '',
|
||||
hint2: it.hint2 || '',
|
||||
hint3: it.hint3 || '',
|
||||
}
|
||||
|
||||
const res = await apiFetch('/api/levels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.error || `HTTP ${res.status}`)
|
||||
}
|
||||
updateItem(it.id, { status: 'done', statusMessage: '已创建' })
|
||||
} catch (e) {
|
||||
updateItem(it.id, {
|
||||
status: 'error',
|
||||
statusMessage: e instanceof Error ? e.message : '失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setIsRunning(false)
|
||||
// 触发列表刷新
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入关卡</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择包含多个关卡子文件夹的目录。每个子文件夹须含 metadata.json、
|
||||
reference.webp、riddle.webp。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{globalError && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSelectFolder}
|
||||
disabled={isParsing || isRunning}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
{items.length > 0 ? '重新选择目录' : '选择目录'}
|
||||
</Button>
|
||||
{isParsing && (
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" /> 解析中…
|
||||
</span>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
共 {stats.total} 个关卡 · 成功 {stats.done} · 失败 {stats.error}
|
||||
{stats.invalid > 0 && ` · 待修正 ${stats.invalid}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 隐藏的文件夹选择 input:webkitdirectory 让浏览器选择目录 */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFolderChange}
|
||||
{...({
|
||||
webkitdirectory: '',
|
||||
directory: '',
|
||||
} as Record<string, string>)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground text-sm">
|
||||
{isParsing ? '解析中…' : '尚未选择目录'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((it, idx) => (
|
||||
<ItemCard
|
||||
key={it.id}
|
||||
index={idx + 1}
|
||||
item={it}
|
||||
disabled={isRunning}
|
||||
onChange={(patch) => updateItem(it.id, patch)}
|
||||
onRemove={() => removeItem(it.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{stats.done > 0 ? '关闭' : '取消'}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleRun} disabled={!canSubmit}>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
创建中…
|
||||
</>
|
||||
) : (
|
||||
`确认创建 (${items.filter((i) => i.status !== 'done' && !i.parseError).length})`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface ItemCardProps {
|
||||
index: number
|
||||
item: ParsedItem
|
||||
disabled: boolean
|
||||
onChange: (patch: Partial<ParsedItem>) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) {
|
||||
const statusNode = (() => {
|
||||
switch (item.status) {
|
||||
case 'uploading':
|
||||
case 'creating':
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-blue-600 text-xs">
|
||||
<Spinner size="sm" /> {item.statusMessage}
|
||||
</span>
|
||||
)
|
||||
case 'done':
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-green-600 text-xs">
|
||||
<CheckCircle2 className="h-4 w-4" /> 已创建
|
||||
</span>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-red-600 text-xs">
|
||||
<XCircle className="h-4 w-4" /> {item.statusMessage}
|
||||
</span>
|
||||
)
|
||||
default:
|
||||
if (item.parseError) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-amber-600 text-xs">
|
||||
<AlertTriangle className="h-4 w-4" /> {item.parseError}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">待创建</span>
|
||||
)
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-3 bg-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
#{index}
|
||||
</span>
|
||||
<span className="text-sm truncate" title={item.folderName}>
|
||||
{item.folderName}
|
||||
</span>
|
||||
{statusNode}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||
onClick={onRemove}
|
||||
disabled={disabled || item.status === 'done'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto,1fr] gap-4">
|
||||
{/* 左侧:图片预览 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="relative w-32 h-32 rounded border bg-gray-50 overflow-hidden">
|
||||
{item.referencePreview && (
|
||||
<Image
|
||||
src={item.referencePreview}
|
||||
alt="图片1"
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="128px"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-center text-muted-foreground">图1 · reference</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="relative w-32 h-32 rounded border bg-gray-50 overflow-hidden">
|
||||
{item.riddlePreview && (
|
||||
<Image
|
||||
src={item.riddlePreview}
|
||||
alt="图片2"
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="128px"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-center text-muted-foreground">图2 · riddle</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:可编辑字段 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">图片1 描述(anchor_text)</Label>
|
||||
<Textarea
|
||||
value={item.anchorText}
|
||||
onChange={(e) => onChange({ anchorText: e.target.value })}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">答案 *</Label>
|
||||
<Input
|
||||
value={item.answer}
|
||||
onChange={(e) => onChange({ answer: e.target.value })}
|
||||
maxLength={4}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">谐音梗说明</Label>
|
||||
<Input
|
||||
value={item.punchline}
|
||||
onChange={(e) => onChange({ punchline: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid grid-cols-3 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">提示 1</Label>
|
||||
<Input
|
||||
value={item.hint1}
|
||||
onChange={(e) => onChange({ hint1: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">提示 2</Label>
|
||||
<Input
|
||||
value={item.hint2}
|
||||
onChange={(e) => onChange({ hint2: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">提示 3</Label>
|
||||
<Input
|
||||
value={item.hint3}
|
||||
onChange={(e) => onChange({ hint3: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
components/levels/delete-confirm-dialog.tsx
Normal file
156
components/levels/delete-confirm-dialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Level } from '@/types'
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
level: Level | null
|
||||
/** 列表里的行号(0-based),用于展示 #序号 */
|
||||
index?: number
|
||||
isLoading?: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 关卡删除二次确认弹窗。
|
||||
* 展示关卡预览(双图 + 答案)让用户确认删除对象,而不是盲点。
|
||||
* - 确认按钮 autoFocus:Enter 直接确认,Esc 由 Radix Dialog 默认关闭
|
||||
* - 删除中禁用所有交互并显示 Spinner
|
||||
*/
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
level,
|
||||
index,
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// 删除进行中不允许关闭,避免中途点取消造成状态不一致
|
||||
if (isLoading && !next) return
|
||||
onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center sm:items-center sm:text-center">
|
||||
{/* 红色警告图标 + 软光晕,视觉焦点 */}
|
||||
<div className="relative mx-auto mb-2 flex h-14 w-14 items-center justify-center">
|
||||
<div className="absolute inset-0 rounded-full bg-red-500/10" />
|
||||
<div className="absolute inset-1.5 rounded-full bg-red-500/15" />
|
||||
<AlertTriangle
|
||||
className="relative h-7 w-7 text-red-600"
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
</div>
|
||||
<DialogTitle className="text-base">确认删除此关卡?</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除后将立即从小程序端下线,且
|
||||
<span className="font-medium text-red-600"> 无法恢复</span>
|
||||
。请确认无误。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 关卡预览卡片 */}
|
||||
{level && (
|
||||
<div className="rounded-md border border-dashed bg-muted/40 p-3">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>待删除关卡</span>
|
||||
{typeof index === 'number' && index >= 0 && (
|
||||
<span className="rounded bg-background px-1.5 py-0.5 font-mono text-[11px] tabular-nums">
|
||||
#{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-shrink-0 gap-1.5">
|
||||
<div className="relative h-14 w-14 overflow-hidden rounded-md bg-background ring-1 ring-border">
|
||||
{level.image1Url ? (
|
||||
<Image
|
||||
src={level.image1Url}
|
||||
alt="图片1"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative h-14 w-14 overflow-hidden rounded-md bg-background ring-1 ring-border">
|
||||
{level.image2Url ? (
|
||||
<Image
|
||||
src={level.image2Url}
|
||||
alt="图片2"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-base font-semibold leading-tight">
|
||||
{level.answer}
|
||||
</div>
|
||||
{level.punchline ? (
|
||||
<div className="mt-1 truncate text-xs text-orange-600">
|
||||
{level.punchline}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
(无谐音梗)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
删除中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
确认删除
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { X, Image as ImageIcon } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import COS from 'cos-js-sdk-v5'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { uploadToCos } from '@/lib/cos-client'
|
||||
|
||||
interface ImageUploaderProps {
|
||||
value: string
|
||||
@@ -38,52 +37,7 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
|
||||
setIsUploading(true)
|
||||
|
||||
try {
|
||||
// Get temp key
|
||||
const keyRes = await apiFetch('/api/cos/temp-key')
|
||||
if (!keyRes.ok) {
|
||||
throw new Error('获取上传凭证失败')
|
||||
}
|
||||
const keyData = await keyRes.json()
|
||||
|
||||
// Generate unique filename
|
||||
const ext = file.name.split('.').pop() || 'jpg'
|
||||
const timestamp = Date.now()
|
||||
const randomStr = Math.random().toString(36).substring(2, 8)
|
||||
const filename = `mini_game/images/${timestamp}_${randomStr}.${ext}`
|
||||
|
||||
// Initialize COS with temp credentials
|
||||
const cos = new COS({
|
||||
getAuthorization: (_options, callback) => {
|
||||
callback({
|
||||
TmpSecretId: keyData.credentials.tmpSecretId,
|
||||
TmpSecretKey: keyData.credentials.tmpSecretKey,
|
||||
SecurityToken: keyData.credentials.sessionToken,
|
||||
StartTime: keyData.startTime,
|
||||
ExpiredTime: keyData.expiredTime,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Upload file
|
||||
const uploadUrl = await new Promise<string>((resolve, reject) => {
|
||||
cos.putObject(
|
||||
{
|
||||
Bucket: keyData.bucket,
|
||||
Region: keyData.region,
|
||||
Key: filename,
|
||||
Body: file,
|
||||
},
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
reject(new Error(err.message || '上传失败'))
|
||||
return
|
||||
}
|
||||
const url = `https://${keyData.bucket}.cos.${keyData.region}.myqcloud.com/${filename}`
|
||||
resolve(url)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const uploadUrl = await uploadToCos(file, file.name)
|
||||
onChange(uploadUrl)
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err)
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Level } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface ColumnCallbacks {
|
||||
onEdit: (level: Level) => void
|
||||
onDelete: (id: string) => void
|
||||
deleteConfirmId: string | null
|
||||
}
|
||||
|
||||
export function createColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirmId,
|
||||
}: ColumnCallbacks): ColumnDef<Level>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'sortOrder',
|
||||
header: '序号',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.original.sortOrder + 1}</span>
|
||||
),
|
||||
size: 60,
|
||||
},
|
||||
{
|
||||
id: 'images',
|
||||
header: '图片',
|
||||
cell: ({ row }) => {
|
||||
const { image1Url, image2Url } = row.original
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
|
||||
{image1Url ? (
|
||||
<Image
|
||||
src={image1Url}
|
||||
alt="图片1"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
|
||||
无
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
|
||||
{image2Url ? (
|
||||
<Image
|
||||
src={image2Url}
|
||||
alt="图片2"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
|
||||
无
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: 'answer',
|
||||
header: '答案',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.answer}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'punchline',
|
||||
header: '谐音梗',
|
||||
cell: ({ row }) => {
|
||||
const punchline = row.original.punchline
|
||||
return punchline ? (
|
||||
<span className="text-orange-600">{punchline}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hints',
|
||||
header: '提示',
|
||||
cell: ({ row }) => {
|
||||
const { hint1, hint2, hint3 } = row.original
|
||||
const hints = [hint1, hint2, hint3].filter(Boolean)
|
||||
return hints.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground truncate max-w-[200px] block">
|
||||
{hints.join('、')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: '创建时间',
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.createdAt)
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '操作',
|
||||
cell: ({ row }) => {
|
||||
const level = row.original
|
||||
const isConfirming = deleteConfirmId === level.id
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onEdit(level)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${
|
||||
isConfirming
|
||||
? 'bg-red-600 text-white hover:bg-red-700 border-red-600'
|
||||
: 'text-red-600 hover:text-red-700 hover:bg-red-50'
|
||||
}`}
|
||||
onClick={() => onDelete(level.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 100,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -20,10 +20,19 @@ interface LevelDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
level?: Level | null
|
||||
/** 当前关卡总数(含正在编辑的这条) */
|
||||
totalCount: number
|
||||
/** 编辑时当前行的 0-based index;创建时传 null */
|
||||
currentIndex: number | null
|
||||
onSubmit: (data: LevelFormData) => Promise<void>
|
||||
}
|
||||
|
||||
const defaultFormData: LevelFormData = {
|
||||
type FormState = Omit<LevelFormData, 'position'> & {
|
||||
/** 位置字段以字符串保存,便于处理"空输入"状态;提交时解析为数字 */
|
||||
position: string
|
||||
}
|
||||
|
||||
const defaultFormState: FormState = {
|
||||
image1Url: '',
|
||||
image1Description: '',
|
||||
image2Url: '',
|
||||
@@ -33,13 +42,35 @@ const defaultFormData: LevelFormData = {
|
||||
hint1: '',
|
||||
hint2: '',
|
||||
hint3: '',
|
||||
position: '',
|
||||
}
|
||||
|
||||
export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialogProps) {
|
||||
const [formData, setFormData] = useState<LevelFormData>(defaultFormData)
|
||||
export function LevelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
level,
|
||||
totalCount,
|
||||
currentIndex,
|
||||
onSubmit,
|
||||
}: LevelDialogProps) {
|
||||
const [formData, setFormData] = useState<FormState>(defaultFormState)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const isEdit = !!level
|
||||
|
||||
// 位置的合法范围与默认值
|
||||
const { minPos, maxPos, defaultPos } = useMemo(() => {
|
||||
if (isEdit) {
|
||||
const min = 1
|
||||
const max = Math.max(totalCount, 1)
|
||||
const def = typeof currentIndex === 'number' ? currentIndex + 1 : max
|
||||
return { minPos: min, maxPos: max, defaultPos: def }
|
||||
}
|
||||
const max = totalCount + 1
|
||||
return { minPos: 1, maxPos: max, defaultPos: max }
|
||||
}, [isEdit, totalCount, currentIndex])
|
||||
|
||||
// Reset form when dialog opens/closes or level changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -54,13 +85,14 @@ export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialog
|
||||
hint1: level.hint1 || '',
|
||||
hint2: level.hint2 || '',
|
||||
hint3: level.hint3 || '',
|
||||
position: String(defaultPos),
|
||||
})
|
||||
} else {
|
||||
setFormData(defaultFormData)
|
||||
setFormData({ ...defaultFormState, position: String(defaultPos) })
|
||||
}
|
||||
setError('')
|
||||
}
|
||||
}, [open, level])
|
||||
}, [open, level, defaultPos])
|
||||
|
||||
const handleImage1Upload = (url: string) => {
|
||||
setFormData((prev) => ({ ...prev, image1Url: url }))
|
||||
@@ -94,9 +126,39 @@ export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialog
|
||||
return
|
||||
}
|
||||
|
||||
// 位置:留空视为"不改位置"(编辑)/ "追加末尾"(创建)
|
||||
let position: number | undefined
|
||||
const raw = formData.position.trim()
|
||||
if (raw !== '') {
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < minPos || n > maxPos) {
|
||||
setError(`位置必须是 ${minPos}-${maxPos} 之间的整数`)
|
||||
return
|
||||
}
|
||||
// 编辑场景:值没变则不上传 position,避免后端无谓重算 sortKey
|
||||
if (isEdit && typeof currentIndex === 'number' && n === currentIndex + 1) {
|
||||
position = undefined
|
||||
} else {
|
||||
position = n
|
||||
}
|
||||
}
|
||||
|
||||
const payload: LevelFormData = {
|
||||
image1Url: formData.image1Url,
|
||||
image1Description: formData.image1Description,
|
||||
image2Url: formData.image2Url,
|
||||
image2Description: formData.image2Description,
|
||||
answer: formData.answer,
|
||||
punchline: formData.punchline,
|
||||
hint1: formData.hint1,
|
||||
hint2: formData.hint2,
|
||||
hint3: formData.hint3,
|
||||
...(position !== undefined ? { position } : {}),
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
await onSubmit(payload)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败,请稍后重试')
|
||||
@@ -173,6 +235,39 @@ export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialog
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position">
|
||||
位置 <span className="text-muted-foreground font-normal">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="position"
|
||||
type="number"
|
||||
min={minPos}
|
||||
max={maxPos}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={formData.position}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, position: e.target.value }))
|
||||
}
|
||||
className="w-32 font-mono tabular-nums"
|
||||
placeholder={String(defaultPos)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
可填 <span className="font-mono">{minPos}</span> 到{' '}
|
||||
<span className="font-mono">{maxPos}</span>
|
||||
{isEdit && typeof currentIndex === 'number' && (
|
||||
<>
|
||||
{' · 当前第 '}
|
||||
<span className="font-mono">{currentIndex + 1}</span> 位
|
||||
</>
|
||||
)}
|
||||
{!isEdit && ' · 留空则追加到末尾'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="punchline">谐音梗说明 (可选)</Label>
|
||||
<Input
|
||||
|
||||
119
components/levels/level-row.tsx
Normal file
119
components/levels/level-row.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { Level } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
// 列宽定义,header 和 row 必须保持一致。用 grid-template-columns 统一控制。
|
||||
// 序号 | 图片 | 答案 | 谐音梗 | 提示 | 创建时间 | 操作
|
||||
export const GRID_TEMPLATE =
|
||||
'minmax(60px,60px) minmax(120px,120px) minmax(80px,1fr) minmax(100px,1fr) minmax(160px,2fr) minmax(100px,100px) minmax(100px,100px)'
|
||||
|
||||
interface LevelRowProps {
|
||||
level: Level
|
||||
index: number
|
||||
onEdit: (level: Level) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function LevelRow({ level, index, onEdit, onDelete }: LevelRowProps) {
|
||||
return (
|
||||
<div
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
className="grid items-center gap-3 px-3 border-b bg-background text-sm min-h-[64px] hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
{/* 序号:用数组 index + 1,而不是 DB 里已弃用的 sortOrder */}
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* 图片 */}
|
||||
<div className="flex gap-1.5">
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
|
||||
{level.image1Url ? (
|
||||
<Image
|
||||
src={level.image1Url}
|
||||
alt="图片1"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
|
||||
无
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
|
||||
{level.image2Url ? (
|
||||
<Image
|
||||
src={level.image2Url}
|
||||
alt="图片2"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
|
||||
无
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 答案 */}
|
||||
<span className="font-medium truncate">{level.answer}</span>
|
||||
|
||||
{/* 谐音梗 */}
|
||||
<span className="truncate">
|
||||
{level.punchline ? (
|
||||
<span className="text-orange-600">{level.punchline}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 提示 */}
|
||||
<span className="truncate text-muted-foreground">
|
||||
{(() => {
|
||||
const hints = [level.hint1, level.hint2, level.hint3].filter(Boolean)
|
||||
return hints.length > 0 ? hints.join('、') : '—'
|
||||
})()}
|
||||
</span>
|
||||
|
||||
{/* 创建时间 */}
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{new Date(level.createdAt).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onEdit(level)}
|
||||
aria-label="编辑关卡"
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-red-200 text-red-600 hover:border-red-600 hover:bg-red-600 hover:text-white focus-visible:ring-red-500"
|
||||
onClick={() => onDelete(level.id)}
|
||||
aria-label="删除关卡"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
} from '@tanstack/react-table'
|
||||
import { useRef } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
|
||||
import { Level } from '@/types'
|
||||
import { createColumns } from './level-columns'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react'
|
||||
import { GRID_TEMPLATE, LevelRow } from './level-row'
|
||||
|
||||
interface LevelTableProps {
|
||||
levels: Level[]
|
||||
onEdit: (level: Level) => void
|
||||
onDelete: (id: string) => void
|
||||
deleteConfirmId: string | null
|
||||
}
|
||||
|
||||
export function LevelTable({
|
||||
levels,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirmId,
|
||||
}: LevelTableProps) {
|
||||
const columns = useMemo(
|
||||
() => createColumns({ onEdit, onDelete, deleteConfirmId }),
|
||||
[onEdit, onDelete, deleteConfirmId]
|
||||
)
|
||||
const HEADER_COLUMNS = [
|
||||
'序号',
|
||||
'图片',
|
||||
'答案',
|
||||
'谐音梗',
|
||||
'提示',
|
||||
'创建时间',
|
||||
'操作',
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: levels,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
export function LevelTable({ levels, onEdit, onDelete }: LevelTableProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const items = levels
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 64,
|
||||
overscan: 10,
|
||||
})
|
||||
|
||||
if (levels.length === 0) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>暂无关卡数据</p>
|
||||
@@ -67,105 +46,60 @@ export function LevelTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border overflow-hidden bg-background">
|
||||
{/* 表头 */}
|
||||
<div
|
||||
className="grid items-center gap-3 px-3 py-2 bg-muted/40 text-xs font-medium text-muted-foreground border-b"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
>
|
||||
{HEADER_COLUMNS.map((h, i) => (
|
||||
<span key={i}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页控制栏 */}
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>每页显示</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={(e) => {
|
||||
table.setPageSize(Number(e.target.value))
|
||||
{/* 虚拟滚动 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto"
|
||||
style={{ height: 'calc(100vh - 260px)' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{[10, 20, 50].map((pageSize) => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
{pageSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
第 {table.getState().pagination.pageIndex + 1} /{' '}
|
||||
{table.getPageCount()} 页,共 {levels.length} 条
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
{rowVirtualizer.getVirtualItems().map((v) => {
|
||||
const level = items[v.index]
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${v.start}px)`,
|
||||
}}
|
||||
>
|
||||
<LevelRow
|
||||
level={level}
|
||||
index={v.index}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 text-xs text-muted-foreground">
|
||||
共 {items.length} 条 · 在编辑 / 创建弹窗中指定位置可调整顺序
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user