306 lines
9.6 KiB
TypeScript
306 lines
9.6 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useCallback } from "react"
|
||
|
||
export type Mode = "single" | "multi"
|
||
|
||
function getRandomDistinct(total: number, count: number, forbidSet: Set<number> = new Set()): number[] | null {
|
||
if (count > total - forbidSet.size) return null
|
||
const result: number[] = []
|
||
const selected = new Set<number>()
|
||
let attempts = 0
|
||
while (selected.size < count && attempts < 100000) {
|
||
const rand = Math.floor(Math.random() * total) + 1
|
||
if (!forbidSet.has(rand) && !selected.has(rand)) {
|
||
selected.add(rand)
|
||
result.push(rand)
|
||
}
|
||
attempts++
|
||
}
|
||
return selected.size === count ? result : null
|
||
}
|
||
|
||
function generateGroups(
|
||
total: number,
|
||
perPick: number,
|
||
rounds: number,
|
||
allowGroupRepeat: boolean
|
||
): { success: boolean; groups: { numbers: number[]; colorIndex: number }[]; errorMsg: string } {
|
||
if (total <= 0) return { success: false, groups: [], errorMsg: "总人数必须大于 0" }
|
||
if (perPick <= 0) return { success: false, groups: [], errorMsg: "每次人数必须大于 0" }
|
||
if (rounds <= 0) return { success: false, groups: [], errorMsg: "抽取组数必须为正整数" }
|
||
if (perPick > total) return { success: false, groups: [], errorMsg: `每次人数 (${perPick}) 不能大于总人数 (${total})` }
|
||
if (!allowGroupRepeat && rounds * perPick > total) {
|
||
return {
|
||
success: false,
|
||
groups: [],
|
||
errorMsg: `组间不可重复时,总抽取数 (${rounds}×${perPick}=${rounds * perPick}) 超出总人数 (${total})`,
|
||
}
|
||
}
|
||
|
||
const groups: { numbers: number[]; colorIndex: number }[] = []
|
||
const globalUsed = new Set<number>()
|
||
|
||
for (let i = 0; i < rounds; i++) {
|
||
const nums = allowGroupRepeat
|
||
? getRandomDistinct(total, perPick)
|
||
: getRandomDistinct(total, perPick, globalUsed)
|
||
if (!nums) return { success: false, groups: [], errorMsg: `第 ${i + 1} 组抽取失败,剩余人数不足` }
|
||
if (!allowGroupRepeat) nums.forEach((n) => globalUsed.add(n))
|
||
groups.push({ numbers: nums, colorIndex: i % COLORS.length })
|
||
}
|
||
return { success: true, groups, errorMsg: "" }
|
||
}
|
||
|
||
export interface RollCallState {
|
||
mode: Mode
|
||
totalTasks: number
|
||
pickCount: number
|
||
rounds: number
|
||
allowRepeat: boolean
|
||
// Multi mode
|
||
groups: { numbers: number[]; colorIndex: number }[]
|
||
highlighted: Set<number>
|
||
usedAll: Set<number>
|
||
hasResult: boolean
|
||
// Single mode
|
||
selectedRecords: { number: number; colorIndex: number }[]
|
||
singleBatches: { numbers: number[]; colorIndex: number; batchNumber: number }[]
|
||
singleHighlighted: number[]
|
||
isSingleComplete: boolean
|
||
singleAllowRepeat: boolean
|
||
// Common
|
||
isRolling: boolean
|
||
errorMsg: string
|
||
placeholder: string | undefined
|
||
}
|
||
|
||
export interface RollCallActions {
|
||
setMode: (v: Mode) => void
|
||
setTotalTasks: (v: number) => void
|
||
setPickCount: (v: number) => void
|
||
setRounds: (v: number) => void
|
||
setAllowRepeat: (v: boolean) => void
|
||
setSingleAllowRepeat: (v: boolean) => void
|
||
handleRoll: () => void
|
||
handleSingleRoll: () => void
|
||
resetSingle: () => void
|
||
clearResult: () => void
|
||
getBallState: (n: number) => "idle" | "highlighted" | "used"
|
||
getBallColorIndex: (n: number) => number | undefined
|
||
}
|
||
|
||
export const COLORS = [
|
||
"#0f141a", // 黑色
|
||
"#d92d20", // 红色
|
||
"#0070f3", // 蓝色
|
||
"#6d6df6", // 紫色
|
||
"#00b8d9", // 青色
|
||
"#36b37e", // 绿色
|
||
"#ff991f", // 橙色
|
||
"#8754ad", // 深紫
|
||
]
|
||
|
||
export function useRollCallLogic(): [RollCallState, RollCallActions] {
|
||
const [mode, setMode] = useState<Mode>("single")
|
||
const [totalTasks, setTotalTasks] = useState(20)
|
||
const [pickCount, setPickCount] = useState(1)
|
||
const [rounds, setRounds] = useState(1)
|
||
const [allowRepeat, setAllowRepeat] = useState(false)
|
||
// Multi mode state
|
||
const [groups, setGroups] = useState<{ numbers: number[]; colorIndex: number }[]>([])
|
||
const [highlighted, setHighlighted] = useState<Set<number>>(new Set())
|
||
const [usedAll, setUsedAll] = useState<Set<number>>(new Set())
|
||
const [hasResult, setHasResult] = useState(false)
|
||
// Single mode state
|
||
const [selectedRecords, setSelectedRecords] = useState<{ number: number; colorIndex: number }[]>([])
|
||
const [singleBatches, setSingleBatches] = useState<{ numbers: number[]; colorIndex: number; batchNumber: number }[]>([])
|
||
const [singleHighlighted, setSingleHighlighted] = useState<number[]>([])
|
||
const [isSingleComplete, setIsSingleComplete] = useState(false)
|
||
const [singleAllowRepeat, setSingleAllowRepeat] = useState(false)
|
||
// Common state
|
||
const [isRolling, setIsRolling] = useState(false)
|
||
const [errorMsg, setErrorMsg] = useState("")
|
||
const [placeholder, setPlaceholder] = useState<string | undefined>()
|
||
|
||
const handleModeChange = useCallback((newMode: Mode) => {
|
||
setMode(newMode)
|
||
setHighlighted(new Set())
|
||
setUsedAll(new Set())
|
||
setGroups([])
|
||
setHasResult(false)
|
||
setSelectedRecords([])
|
||
setSingleBatches([])
|
||
setSingleHighlighted([])
|
||
setIsSingleComplete(false)
|
||
setErrorMsg("")
|
||
setPlaceholder(undefined)
|
||
}, [])
|
||
|
||
const handleTotalChange = useCallback((v: number) => {
|
||
setTotalTasks(v)
|
||
setHighlighted(new Set())
|
||
setUsedAll(new Set())
|
||
setGroups([])
|
||
setHasResult(false)
|
||
setSelectedRecords([])
|
||
setSingleBatches([])
|
||
setSingleHighlighted([])
|
||
setIsSingleComplete(false)
|
||
setErrorMsg("")
|
||
setPlaceholder(undefined)
|
||
}, [])
|
||
|
||
const handleRoundsChange = useCallback((v: number) => {
|
||
setRounds(v)
|
||
if (v < 2) setAllowRepeat(false)
|
||
setHighlighted(new Set())
|
||
setGroups([])
|
||
setHasResult(false)
|
||
setErrorMsg("")
|
||
setPlaceholder("参数已修改,请重新执行")
|
||
}, [])
|
||
|
||
const handleRoll = useCallback(async () => {
|
||
setErrorMsg("")
|
||
setIsRolling(true)
|
||
await new Promise((r) => setTimeout(r, 240))
|
||
|
||
const effectiveRepeat = rounds < 2 ? false : allowRepeat
|
||
const result = generateGroups(totalTasks, pickCount, rounds, effectiveRepeat)
|
||
|
||
if (!result.success) {
|
||
setErrorMsg(result.errorMsg)
|
||
setIsRolling(false)
|
||
return
|
||
}
|
||
|
||
const allPicked = new Set(result.groups.flatMap(g => g.numbers))
|
||
setGroups(result.groups)
|
||
setHighlighted(allPicked)
|
||
if (!effectiveRepeat) {
|
||
setUsedAll(allPicked)
|
||
} else {
|
||
setUsedAll(new Set())
|
||
}
|
||
setHasResult(true)
|
||
setIsRolling(false)
|
||
setPlaceholder(undefined)
|
||
}, [totalTasks, pickCount, rounds, allowRepeat])
|
||
|
||
const handleSingleRoll = useCallback(async () => {
|
||
setErrorMsg("")
|
||
setIsRolling(true)
|
||
setIsSingleComplete(false)
|
||
await new Promise((r) => setTimeout(r, 240))
|
||
|
||
const usedNumbers = new Set(selectedRecords.map(r => r.number))
|
||
|
||
// 如果不允许重复,检查剩余人数
|
||
if (!singleAllowRepeat && pickCount > totalTasks - usedNumbers.size) {
|
||
setErrorMsg(`剩余可选人数不足 ${pickCount} 人`)
|
||
setIsRolling(false)
|
||
return
|
||
}
|
||
|
||
// 允许重复时,不需要检查人数
|
||
const forbidSet = singleAllowRepeat ? new Set<number>() : usedNumbers
|
||
const nums = getRandomDistinct(totalTasks, pickCount, forbidSet)
|
||
if (!nums) {
|
||
setErrorMsg("抽取失败,人数不足")
|
||
setIsRolling(false)
|
||
return
|
||
}
|
||
|
||
const newColorIndex = singleBatches.length % COLORS.length
|
||
const newBatchNumber = singleBatches.length + 1
|
||
const newRecords = [...selectedRecords, ...nums.map(n => ({ number: n, colorIndex: newColorIndex }))]
|
||
const newBatch = { numbers: nums, colorIndex: newColorIndex, batchNumber: newBatchNumber }
|
||
|
||
setSelectedRecords(newRecords)
|
||
setSingleBatches([...singleBatches, newBatch])
|
||
setSingleHighlighted(nums)
|
||
setIsSingleComplete(true)
|
||
setIsRolling(false)
|
||
}, [totalTasks, pickCount, selectedRecords, singleBatches, singleAllowRepeat])
|
||
|
||
const resetSingle = useCallback(() => {
|
||
setSelectedRecords([])
|
||
setSingleBatches([])
|
||
setSingleHighlighted([])
|
||
setIsSingleComplete(false)
|
||
setErrorMsg("")
|
||
}, [])
|
||
|
||
const clearResult = useCallback(() => {
|
||
setGroups([])
|
||
setHighlighted(new Set())
|
||
setUsedAll(new Set())
|
||
setHasResult(false)
|
||
setPlaceholder(undefined)
|
||
}, [])
|
||
|
||
const getBallState = useCallback((n: number): "idle" | "highlighted" | "used" => {
|
||
if (mode === "single") {
|
||
const isInRecords = selectedRecords.some(r => r.number === n)
|
||
if (isInRecords) return "highlighted"
|
||
return "idle"
|
||
}
|
||
if (highlighted.has(n)) return "highlighted"
|
||
if (hasResult && usedAll.size > 0 && !usedAll.has(n)) return "used"
|
||
return "idle"
|
||
}, [mode, selectedRecords, highlighted, hasResult, usedAll])
|
||
|
||
const getBallColorIndex = useCallback((n: number): number | undefined => {
|
||
if (mode === "single") {
|
||
// Find the LAST occurrence (latest color when repeats are allowed)
|
||
const record = [...selectedRecords].reverse().find(r => r.number === n)
|
||
return record?.colorIndex
|
||
}
|
||
// Group mode: find which group this number belongs to (use last matching group)
|
||
for (let i = groups.length - 1; i >= 0; i--) {
|
||
if (groups[i].numbers.includes(n)) {
|
||
return groups[i].colorIndex
|
||
}
|
||
}
|
||
return undefined
|
||
}, [mode, selectedRecords, groups])
|
||
|
||
const state: RollCallState = {
|
||
mode,
|
||
totalTasks,
|
||
pickCount,
|
||
rounds,
|
||
allowRepeat,
|
||
groups,
|
||
highlighted,
|
||
usedAll,
|
||
hasResult,
|
||
selectedRecords,
|
||
singleBatches,
|
||
singleHighlighted,
|
||
isSingleComplete,
|
||
singleAllowRepeat,
|
||
isRolling,
|
||
errorMsg,
|
||
placeholder,
|
||
}
|
||
|
||
const actions: RollCallActions = {
|
||
setMode: handleModeChange,
|
||
setTotalTasks: handleTotalChange,
|
||
setPickCount: (v: number) => { setPickCount(v); setErrorMsg("") },
|
||
setRounds: handleRoundsChange,
|
||
setAllowRepeat,
|
||
setSingleAllowRepeat,
|
||
handleRoll,
|
||
handleSingleRoll,
|
||
resetSingle,
|
||
clearResult,
|
||
getBallState,
|
||
getBallColorIndex,
|
||
}
|
||
|
||
return [state, actions]
|
||
}
|