Files
roll-call/components/logic.tsx
T
2026-06-16 19:15:56 +08:00

318 lines
9.6 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, useMemo } from "react"
export type Mode = "single" | "multi"
function getRandomDistinct(total: number, count: number, forbidSet: Set<number> = new Set()): number[] | null {
// 构建可用数字数组
const available: number[] = []
for (let i = 1; i <= total; i++) {
if (!forbidSet.has(i)) available.push(i)
}
if (count > available.length) return null
// Fisher-Yates 洗牌算法
for (let i = available.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[available[i], available[j]] = [available[j], available[i]]
}
return available.slice(0, count)
}
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
}
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
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>(35)
const [pickCount, setPickCount] = useState<number>(1)
const [rounds, setRounds] = useState<number>(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 [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("")
// 从 singleBatches 计算 selectedRecords,消除数据冗余
const selectedRecords = useMemo(() => {
return singleBatches.flatMap(batch =>
batch.numbers.map(n => ({ number: n, colorIndex: batch.colorIndex }))
)
}, [singleBatches])
const resetAllState = useCallback(() => {
setHighlighted(new Set())
setUsedAll(new Set())
setGroups([])
setHasResult(false)
setSingleBatches([])
setSingleHighlighted([])
setIsSingleComplete(false)
setErrorMsg("")
}, [])
const handleModeChange = useCallback((newMode: Mode) => {
setMode(newMode)
resetAllState()
}, [resetAllState])
const handleTotalChange = useCallback((v: number) => {
setTotalTasks(v)
resetAllState()
}, [resetAllState])
const handleRoundsChange = useCallback((v: number) => {
setRounds(v)
if (v < 2) setAllowRepeat(false)
setHighlighted(new Set())
setGroups([])
setHasResult(false)
setErrorMsg("")
}, [])
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)
}, [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 newBatch = { numbers: nums, colorIndex: newColorIndex, batchNumber: newBatchNumber }
setSingleBatches([...singleBatches, newBatch])
setSingleHighlighted(nums)
setIsSingleComplete(true)
setIsRolling(false)
}, [totalTasks, pickCount, selectedRecords, singleBatches, singleAllowRepeat])
const resetSingle = useCallback(() => {
setSingleBatches([])
setSingleHighlighted([])
setIsSingleComplete(false)
setErrorMsg("")
}, [])
const clearResult = useCallback(() => {
setGroups([])
setHighlighted(new Set())
setUsedAll(new Set())
setHasResult(false)
}, [])
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,
}
const actions: RollCallActions = {
setMode: handleModeChange,
setTotalTasks: handleTotalChange,
setPickCount: (v: number) => { setPickCount(v); setErrorMsg("") },
setRounds: handleRoundsChange,
setAllowRepeat,
setSingleAllowRepeat,
handleRoll,
handleSingleRoll,
resetSingle,
clearResult,
getBallState,
getBallColorIndex,
getBallDimmed,
}
return [state, actions]
}