feat: 支持批量上传关卡

This commit is contained in:
richarjiang
2026-05-01 08:44:56 +08:00
parent f3f27def2b
commit 66a9ee2950
27 changed files with 5262 additions and 515 deletions

144
lib/sort-key.ts Normal file
View File

@@ -0,0 +1,144 @@
import { generateKeyBetween } from 'fractional-indexing'
import { prisma } from './prisma'
/**
* fractional-indexing 要求按字节序比较 sortKey。
* MySQL 默认 collationutf8mb4_0900_ai_ci / utf8mb4_unicode_ci大小写不敏感
* 一旦出现大写字母的 key例如 `Zz`,它在 fractional-indexing 里 < `a0`
* MySQL ORDER BY 就会给出和 JS 不一致的顺序。
*
* 结论:**sortKey 的排序永远在 JS 里做**,不要依赖 DB 的 ORDER BY。
* 这个比较函数就是 JS 原生的字符串 `<` / `>`,对 ASCII 等价于字节序。
*/
export function compareSortKey(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
/**
* 按 sortKey 顺序返回所有关卡(仅取指定字段),排序在 JS 里完成。
* tiebreakercreatedAt 升序(仅用于 sortKey 意外重复时的确定性)。
*/
export async function listLevelsOrderedBySortKey<
T extends Record<string, unknown>
>(args: {
select: Record<string, boolean>
excludeId?: string | null
}): Promise<Array<T & { sortKey: string; createdAt: Date }>> {
const rows = await prisma.level.findMany({
where: args.excludeId ? { id: { not: args.excludeId } } : undefined,
select: {
...args.select,
sortKey: true,
createdAt: true,
},
})
return (rows as Array<T & { sortKey: string; createdAt: Date }>).sort(
(a, b) => {
const c = compareSortKey(a.sortKey, b.sortKey)
if (c !== 0) return c
return a.createdAt.getTime() - b.createdAt.getTime()
}
)
}
/**
* 计算追加到末尾的 sortKey取当前最大 sortKey生成一个比它更大的。
* 空表场景返回 fractional-indexing 库定义的初始 key。
*
* 注意:不能用 `orderBy: { sortKey: 'desc' } findFirst`,因为 MySQL 大小写不敏感
* collation 会错把 `a0` 当成最大,实际 `z...` 才是最大。这里只能拉所有 key 后在 JS 排。
*/
export async function keyAfterLast(): Promise<string> {
const rows = await prisma.level.findMany({ select: { sortKey: true } })
if (rows.length === 0) {
return generateKeyBetween(null, null)
}
let maxKey = rows[0].sortKey
for (let i = 1; i < rows.length; i += 1) {
if (compareSortKey(rows[i].sortKey, maxKey) > 0) maxKey = rows[i].sortKey
}
return generateKeyBetween(maxKey, null)
}
/**
* 用 prevKey / nextKey 计算中间 key两端任一可为 null表示头/尾)。
*/
export function keyBetween(
prevKey: string | null,
nextKey: string | null
): string {
return generateKeyBetween(prevKey, nextKey)
}
/**
* 全量重平衡:按当前 JS 侧排序给所有行重新分配递增 key。
*
* 触发场景:
* - 目标位置两侧 key 相等或倒置
* - 手动修复历史 Z 系列 key 带来的 DB/JS 排序分歧
*/
export async function rebalanceAllKeys(): Promise<number> {
const rows = await prisma.level.findMany({
select: { id: true, sortKey: true, sortOrder: true, createdAt: true },
})
// JS 侧排序sortKey 字节序 > sortOrder > createdAt
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()
})
let prev: string | null = null
for (const r of rows) {
const key = generateKeyBetween(prev, null)
await prisma.level.update({ where: { id: r.id }, data: { sortKey: key } })
prev = key
}
return rows.length
}
/**
* position 是 1-based。
* - 创建场景position 合法范围 [1, total + 1]
* - 编辑场景position 合法范围 [1, total]total 已含当前行,调用方需传 excludeId 排除自己)
*
* 排序全部在 JS 里做,避免 MySQL collation 导致的 sortKey 顺序错乱。
*/
export async function keyForPosition(
position: number,
excludeId?: string | null
): Promise<string> {
const rows = await listLevelsOrderedBySortKey<{ id: string }>({
select: { id: true },
excludeId,
})
const prevRow = rows[position - 2] // position=1 时 prev 为 undefined
const nextRow = rows[position - 1] // position=rows.length+1 时 next 为 undefined
const prevKey = prevRow?.sortKey ?? null
const nextKey = nextRow?.sortKey ?? null
if (canComputeBetween(prevKey, nextKey)) {
return generateKeyBetween(prevKey, nextKey)
}
// 兜底:重平衡后再试一次
await rebalanceAllKeys()
const retryRows = await listLevelsOrderedBySortKey<{ id: string }>({
select: { id: true },
excludeId,
})
const retryPrev = retryRows[position - 2]?.sortKey ?? null
const retryNext = retryRows[position - 1]?.sortKey ?? null
return generateKeyBetween(retryPrev, retryNext)
}
function canComputeBetween(
prevKey: string | null,
nextKey: string | null
): boolean {
if (prevKey === null && nextKey === null) return true
if (prevKey === null || nextKey === null) return true
return compareSortKey(prevKey, nextKey) < 0
}