2026-06-15 19:01:02 +08:00
|
|
|
|
"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
|
2026-06-15 19:33:30 +08:00
|
|
|
|
getBallDimmed: (n: number) => boolean
|
2026-06-15 19:01:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const COLORS = [
|
2026-06-15 19:18:28 +08:00
|
|
|
|
"#d92d20",
|
|
|
|
|
|
"#0070f3",
|
|
|
|
|
|
"#6d6df6",
|
|
|
|
|
|
"#00b8d9",
|
|
|
|
|
|
"#36b37e",
|
|
|
|
|
|
"#ff991f",
|
|
|
|
|
|
"#8754ad",
|
|
|
|
|
|
"#e63946",
|
|
|
|
|
|
"#457b9d",
|
|
|
|
|
|
"#2a9d8f",
|
|
|
|
|
|
"#f4a261",
|
|
|
|
|
|
"#264653",
|
|
|
|
|
|
"#9d4edd",
|
|
|
|
|
|
"#fb5607",
|
|
|
|
|
|
"#06d6a0",
|
|
|
|
|
|
"#118ab2",
|
|
|
|
|
|
"#ffd166",
|
2026-06-15 19:01:02 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
export function useRollCallLogic(): [RollCallState, RollCallActions] {
|
|
|
|
|
|
const [mode, setMode] = useState<Mode>("single")
|
2026-06-15 19:33:30 +08:00
|
|
|
|
const [totalTasks, setTotalTasks] = useState(35)
|
2026-06-15 19:01:02 +08:00
|
|
|
|
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") {
|
2026-06-15 19:33:30 +08:00
|
|
|
|
// Only highlight current round's selections
|
|
|
|
|
|
const isCurrentRound = singleHighlighted.includes(n)
|
|
|
|
|
|
if (isCurrentRound) return "highlighted"
|
2026-06-15 19:01:02 +08:00
|
|
|
|
return "idle"
|
|
|
|
|
|
}
|
|
|
|
|
|
if (highlighted.has(n)) return "highlighted"
|
|
|
|
|
|
if (hasResult && usedAll.size > 0 && !usedAll.has(n)) return "used"
|
|
|
|
|
|
return "idle"
|
2026-06-15 19:33:30 +08:00
|
|
|
|
}, [mode, singleHighlighted, highlighted, hasResult, usedAll])
|
2026-06-15 19:01:02 +08:00
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2026-06-15 19:33:30 +08:00
|
|
|
|
const getBallDimmed = useCallback((n: number): boolean => {
|
|
|
|
|
|
// Only dim in single mode when there are highlighted numbers
|
|
|
|
|
|
if (mode === "single" && singleHighlighted.length > 0) {
|
|
|
|
|
|
return !singleHighlighted.includes(n)
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}, [mode, singleHighlighted])
|
|
|
|
|
|
|
2026-06-15 19:01:02 +08:00
|
|
|
|
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,
|
2026-06-15 19:33:30 +08:00
|
|
|
|
getBallDimmed,
|
2026-06-15 19:01:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [state, actions]
|
|
|
|
|
|
}
|