Files
roll-call/components/logic.tsx
T

346 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 | string
pickCount: number | string
rounds: number | string
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 | string) => void
setPickCount: (v: number | string) => void
setRounds: (v: number | string) => 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
getBallDimmed: (n: number) => boolean
}
export const COLORS = [
"#d92d20",
"#0070f3",
"#6d6df6",
"#00b8d9",
"#36b37e",
"#ff991f",
"#8754ad",
"#e63946",
"#457b9d",
"#2a9d8f",
"#f4a261",
"#264653",
"#9d4edd",
"#fb5607",
"#06d6a0",
"#118ab2",
"#ffd166",
]
export function useRollCallLogic(): [RollCallState, RollCallActions] {
const [mode, setMode] = useState<Mode>("single")
const [totalTasks, setTotalTasks] = useState<number | string>(35)
const [pickCount, setPickCount] = useState<number | string>(1)
const [rounds, setRounds] = useState<number | string>(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 | string) => {
setTotalTasks(v)
setHighlighted(new Set())
setUsedAll(new Set())
setGroups([])
setHasResult(false)
setSelectedRecords([])
setSingleBatches([])
setSingleHighlighted([])
setIsSingleComplete(false)
setErrorMsg("")
setPlaceholder(undefined)
}, [])
const handleRoundsChange = useCallback((v: number | string) => {
setRounds(v)
if (typeof v === 'number' && v < 2) setAllowRepeat(false)
setHighlighted(new Set())
setGroups([])
setHasResult(false)
setErrorMsg("")
}, [])
const handleRoll = useCallback(async () => {
setErrorMsg("")
// 检查空值
if (totalTasks === "" || pickCount === "" || rounds === "") {
setErrorMsg("请填写所有参数")
return
}
const total = totalTasks as number
const pick = pickCount as number
const roundCount = rounds as number
setIsRolling(true)
await new Promise((r) => setTimeout(r, 240))
const effectiveRepeat = roundCount < 2 ? false : allowRepeat
const result = generateGroups(total, pick, roundCount, 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("")
// 检查空值
if (totalTasks === "" || pickCount === "") {
setErrorMsg("请填写所有参数")
return
}
const total = totalTasks as number
const pick = pickCount as number
setIsRolling(true)
setIsSingleComplete(false)
await new Promise((r) => setTimeout(r, 240))
const usedNumbers = new Set(selectedRecords.map(r => r.number))
// 如果不允许重复,检查剩余人数
if (!singleAllowRepeat && pick > total - usedNumbers.size) {
setErrorMsg(`剩余可选人数不足 ${pick}`)
setIsRolling(false)
return
}
// 允许重复时,不需要检查人数
const forbidSet = singleAllowRepeat ? new Set<number>() : usedNumbers
const nums = getRandomDistinct(total, pick, 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") {
// Only highlight current round's selections
const isCurrentRound = singleHighlighted.includes(n)
if (isCurrentRound) return "highlighted"
return "idle"
}
if (highlighted.has(n)) return "highlighted"
if (hasResult && usedAll.size > 0 && !usedAll.has(n)) return "used"
return "idle"
}, [mode, singleHighlighted, 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 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])
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 | string) => { setPickCount(v); setErrorMsg("") },
setRounds: handleRoundsChange,
setAllowRepeat,
setSingleAllowRepeat,
handleRoll,
handleSingleRoll,
resetSingle,
clearResult,
getBallState,
getBallColorIndex,
getBallDimmed,
}
return [state, actions]
}