feat: 支持批量上传关卡
This commit is contained in:
@@ -6,16 +6,19 @@ import { Button } from '@/components/ui/button'
|
||||
import { Header } from '@/components/layout/header'
|
||||
import { LevelTable } from '@/components/levels/level-table'
|
||||
import { LevelDialog } from '@/components/levels/level-dialog'
|
||||
import { BatchImportDialog } from '@/components/levels/batch-import-dialog'
|
||||
import { DeleteConfirmDialog } from '@/components/levels/delete-confirm-dialog'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Level, LevelFormData } from '@/types'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Upload } from 'lucide-react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
export default function LevelsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isBatchOpen, setIsBatchOpen] = useState(false)
|
||||
const [editingLevel, setEditingLevel] = useState<Level | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
const [deletingLevel, setDeletingLevel] = useState<Level | null>(null)
|
||||
|
||||
// Fetch levels
|
||||
const { data: levels, isLoading, error } = useQuery<Level[]>({
|
||||
@@ -79,7 +82,7 @@ export default function LevelsPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['levels'] })
|
||||
setDeleteConfirmId(null)
|
||||
setDeletingLevel(null)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -94,13 +97,8 @@ export default function LevelsPage() {
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (deleteConfirmId === id) {
|
||||
deleteMutation.mutate(id)
|
||||
} else {
|
||||
setDeleteConfirmId(id)
|
||||
// Reset after 3 seconds
|
||||
setTimeout(() => setDeleteConfirmId(null), 3000)
|
||||
}
|
||||
const target = (levels || []).find((l) => l.id === id)
|
||||
if (target) setDeletingLevel(target)
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: LevelFormData) => {
|
||||
@@ -147,17 +145,22 @@ export default function LevelsPage() {
|
||||
共 {levels?.length || 0} 个关卡
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加关卡
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setIsBatchOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
批量导入
|
||||
</Button>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加关卡
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LevelTable
|
||||
levels={levels || []}
|
||||
onEdit={handleOpenEdit}
|
||||
onDelete={handleDelete}
|
||||
deleteConfirmId={deleteConfirmId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,8 +169,39 @@ export default function LevelsPage() {
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
level={editingLevel}
|
||||
totalCount={levels?.length || 0}
|
||||
currentIndex={
|
||||
editingLevel
|
||||
? (levels || []).findIndex((l) => l.id === editingLevel.id)
|
||||
: null
|
||||
}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<BatchImportDialog
|
||||
open={isBatchOpen}
|
||||
onOpenChange={setIsBatchOpen}
|
||||
onSuccess={() =>
|
||||
queryClient.invalidateQueries({ queryKey: ['levels'] })
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deletingLevel}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setDeletingLevel(null)
|
||||
}}
|
||||
level={deletingLevel}
|
||||
index={
|
||||
deletingLevel
|
||||
? (levels || []).findIndex((l) => l.id === deletingLevel.id)
|
||||
: undefined
|
||||
}
|
||||
isLoading={deleteMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (deletingLevel) deleteMutation.mutate(deletingLevel.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
// PUT /api/levels/reorder - Batch update sort order
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { orders } = body as { orders: { id: string; sortOrder: number }[] }
|
||||
|
||||
if (!Array.isArray(orders)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'orders must be an array' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update each level's sort order in a transaction
|
||||
await prisma.$transaction(
|
||||
orders.map((item) =>
|
||||
prisma.level.update({
|
||||
where: { id: item.id },
|
||||
data: { sortOrder: item.sortOrder },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error reordering levels:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reorder levels' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { compareSortKey, keyAfterLast, keyForPosition } from '@/lib/sort-key'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
// GET /api/levels - Get all levels
|
||||
@@ -14,10 +15,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const levels = await prisma.level.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
// 注意:不能交给 MySQL 排序。默认 collation 大小写不敏感,
|
||||
// 会把 fractional-indexing 的 Z 系列 key 错排到 z 系列之后。
|
||||
// 拉全表到应用层按字节序排。
|
||||
const rows = await prisma.level.findMany()
|
||||
rows.sort((a, b) => {
|
||||
const c = compareSortKey(a.sortKey, b.sortKey)
|
||||
if (c !== 0) return c
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
return a.createdAt.getTime() - b.createdAt.getTime()
|
||||
})
|
||||
|
||||
// 响应层回填连续整数 sortOrder,保持前端 / 小程序对这个字段的既有依赖
|
||||
const levels = rows.map((r, i) => ({ ...r, sortOrder: i }))
|
||||
|
||||
return NextResponse.json(levels)
|
||||
} catch (error) {
|
||||
console.error('Error fetching levels:', error)
|
||||
@@ -28,7 +39,25 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 position:
|
||||
* - undefined / null → 返回 undefined,交给调用方走"默认行为"
|
||||
* - 数字字符串 → 解析成整数
|
||||
* - 其它 → 抛错
|
||||
*
|
||||
* 合法范围的校验由调用方根据当前上下文(创建 / 编辑)判断。
|
||||
*/
|
||||
function parsePosition(raw: unknown): number | undefined {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
const n = typeof raw === 'number' ? raw : Number(raw)
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error('position 必须是 >= 1 的整数')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// POST /api/levels - Create a new level
|
||||
// body.position 可选:1..N+1;不传则追加末尾
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -40,7 +69,17 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { image1Url, image1Description, image2Url, image2Description, answer, punchline, hint1, hint2, hint3 } = body
|
||||
const {
|
||||
image1Url,
|
||||
image1Description,
|
||||
image2Url,
|
||||
image2Description,
|
||||
answer,
|
||||
punchline,
|
||||
hint1,
|
||||
hint2,
|
||||
hint3,
|
||||
} = body
|
||||
|
||||
if (!image1Url || !image2Url || !answer) {
|
||||
return NextResponse.json(
|
||||
@@ -49,12 +88,29 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get max sort order
|
||||
const maxSortOrder = await prisma.level.aggregate({
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
let position: number | undefined
|
||||
try {
|
||||
position = parsePosition(body.position)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'invalid position' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const sortOrder = (maxSortOrder._max.sortOrder || 0) + 1
|
||||
let sortKey: string
|
||||
if (position === undefined) {
|
||||
sortKey = await keyAfterLast()
|
||||
} else {
|
||||
const total = await prisma.level.count()
|
||||
if (position > total + 1) {
|
||||
return NextResponse.json(
|
||||
{ error: `position 超出范围,合法区间 [1, ${total + 1}]` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
sortKey = await keyForPosition(position)
|
||||
}
|
||||
|
||||
const level = await prisma.level.create({
|
||||
data: {
|
||||
@@ -68,7 +124,7 @@ export async function POST(request: NextRequest) {
|
||||
hint1: hint1 || null,
|
||||
hint2: hint2 || null,
|
||||
hint3: hint3 || null,
|
||||
sortOrder,
|
||||
sortKey,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -83,6 +139,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// PUT /api/levels - Update a level
|
||||
// body.position 可选:1..N(N 含当前行)。不传表示不移动位置。
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -94,12 +151,46 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { id, image1Url, image1Description, image2Url, image2Description, answer, punchline, hint1, hint2, hint3 } = body
|
||||
const {
|
||||
id,
|
||||
image1Url,
|
||||
image1Description,
|
||||
image2Url,
|
||||
image2Description,
|
||||
answer,
|
||||
punchline,
|
||||
hint1,
|
||||
hint2,
|
||||
hint3,
|
||||
} = body
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
let position: number | undefined
|
||||
try {
|
||||
position = parsePosition(body.position)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'invalid position' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 只有在 position 确实变化时才重算 sortKey,避免无谓写入
|
||||
let newSortKey: string | undefined
|
||||
if (position !== undefined) {
|
||||
const total = await prisma.level.count()
|
||||
if (position > total) {
|
||||
return NextResponse.json(
|
||||
{ error: `position 超出范围,合法区间 [1, ${total}]` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
newSortKey = await keyForPosition(position, id)
|
||||
}
|
||||
|
||||
const level = await prisma.level.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -112,6 +203,7 @@ export async function PUT(request: NextRequest) {
|
||||
hint1: hint1 || null,
|
||||
hint2: hint2 || null,
|
||||
hint3: hint3 || null,
|
||||
...(newSortKey ? { sortKey: newSortKey } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { compareSortKey } from '@/lib/sort-key'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,6 +21,8 @@ export async function GET(
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// levelProgress 这里不按 sortKey 排——MySQL collation 不可靠。
|
||||
// 下面用 progress 的 Map 按需组合,最终顺序由 levels 的 JS 排序决定。
|
||||
const user = await prisma.wxUser.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
@@ -33,23 +36,12 @@ export async function GET(
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
levelProgress: {
|
||||
orderBy: [
|
||||
{ level: { sortOrder: 'asc' } },
|
||||
{ completedAt: 'desc' },
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
levelId: true,
|
||||
completedAt: true,
|
||||
timeSpent: true,
|
||||
level: {
|
||||
select: {
|
||||
id: true,
|
||||
answer: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -59,14 +51,22 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// sortKey 排序必须在 JS 侧做,避免大小写不敏感 collation 带来的顺序错乱
|
||||
const levels = await prisma.level.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
answer: true,
|
||||
sortKey: true,
|
||||
sortOrder: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
levels.sort((a, b) => {
|
||||
const c = compareSortKey(a.sortKey, b.sortKey)
|
||||
if (c !== 0) return c
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
return a.createdAt.getTime() - b.createdAt.getTime()
|
||||
})
|
||||
|
||||
const progressByLevelId = new Map(
|
||||
user.levelProgress.map((progress) => [progress.levelId, progress])
|
||||
@@ -83,13 +83,14 @@ export async function GET(
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
completedLevelCount: user.levelProgress.length,
|
||||
assignedLevels: levels.map((level) => {
|
||||
// 按 sortKey 排序后,数组下标即连续的 sortOrder,小程序端契约不变
|
||||
assignedLevels: levels.map((level, index) => {
|
||||
const progress = progressByLevelId.get(level.id)
|
||||
|
||||
return {
|
||||
id: level.id,
|
||||
answer: level.answer,
|
||||
sortOrder: level.sortOrder,
|
||||
sortOrder: index,
|
||||
completed: Boolean(progress),
|
||||
progressId: progress?.id || null,
|
||||
completedAt: progress?.completedAt || null,
|
||||
|
||||
Reference in New Issue
Block a user