diff --git a/app/globals.css b/app/globals.css index fe56f67..da6b472 100644 --- a/app/globals.css +++ b/app/globals.css @@ -86,9 +86,15 @@ } body { @apply bg-background text-foreground font-mono antialiased; + /* 禁用右键菜单 */ + -webkit-user-select: none; + user-select: none; } html { @apply bg-background; + /* 禁用触摸移动 */ + overscroll-behavior: none; + touch-action: pan-x pan-y; } } diff --git a/app/page.tsx b/app/page.tsx index bdbbd98..c99346b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,11 +6,23 @@ import { ResultPanel } from "@/components/result" import { SingleModePanel } from "@/components/single-panel" import { TitleBar } from "@/components/title-bar" import { useRollCallLogic } from "@/components/logic" +import { useEffect } from "react" export default function Page() { const [state, actions] = useRollCallLogic() const isSingleMode = state.mode === "single" + // 禁用右键菜单 + useEffect(() => { + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault() + } + document.addEventListener('contextmenu', handleContextMenu) + return () => { + document.removeEventListener('contextmenu', handleContextMenu) + } + }, []) + return (
{/* Custom title bar */} @@ -61,16 +73,16 @@ export default function Page() { )}
-
-
- {Array.from({ length: state.totalTasks }, (_, i) => i + 1).map((n) => ( +
+
+ {Array.from({ length: typeof state.totalTasks === 'number' ? state.totalTasks : 0 }, (_, i) => i + 1).map((n) => ( ))}
@@ -95,8 +107,8 @@ export default function Page() { )}
-
- {Array.from({ length: state.totalTasks }, (_, i) => i + 1).map((n) => ( +
+ {Array.from({ length: typeof state.totalTasks === 'number' ? state.totalTasks : 0 }, (_, i) => i + 1).map((n) => ( -
+
OUTPUT {state.hasResult && state.groups.length > 0 && ( diff --git a/components/logic.tsx b/components/logic.tsx index 0a1b072..38c6e76 100644 --- a/components/logic.tsx +++ b/components/logic.tsx @@ -54,9 +54,9 @@ function generateGroups( export interface RollCallState { mode: Mode - totalTasks: number - pickCount: number - rounds: number + totalTasks: number | string + pickCount: number | string + rounds: number | string allowRepeat: boolean // Multi mode groups: { numbers: number[]; colorIndex: number }[] @@ -77,9 +77,9 @@ export interface RollCallState { export interface RollCallActions { setMode: (v: Mode) => void - setTotalTasks: (v: number) => void - setPickCount: (v: number) => void - setRounds: (v: number) => 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 @@ -113,9 +113,9 @@ export const COLORS = [ export function useRollCallLogic(): [RollCallState, RollCallActions] { const [mode, setMode] = useState("single") - const [totalTasks, setTotalTasks] = useState(35) - const [pickCount, setPickCount] = useState(1) - const [rounds, setRounds] = useState(1) + const [totalTasks, setTotalTasks] = useState(35) + 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 }[]>([]) @@ -147,7 +147,7 @@ export function useRollCallLogic(): [RollCallState, RollCallActions] { setPlaceholder(undefined) }, []) - const handleTotalChange = useCallback((v: number) => { + const handleTotalChange = useCallback((v: number | string) => { setTotalTasks(v) setHighlighted(new Set()) setUsedAll(new Set()) @@ -161,23 +161,33 @@ export function useRollCallLogic(): [RollCallState, RollCallActions] { setPlaceholder(undefined) }, []) - const handleRoundsChange = useCallback((v: number) => { + const handleRoundsChange = useCallback((v: number | string) => { setRounds(v) - if (v < 2) setAllowRepeat(false) + if (typeof v === 'number' && v < 2) setAllowRepeat(false) setHighlighted(new Set()) setGroups([]) setHasResult(false) setErrorMsg("") - setPlaceholder("参数已修改,请重新执行") }, []) 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 = rounds < 2 ? false : allowRepeat - const result = generateGroups(totalTasks, pickCount, rounds, effectiveRepeat) + const effectiveRepeat = roundCount < 2 ? false : allowRepeat + const result = generateGroups(total, pick, roundCount, effectiveRepeat) if (!result.success) { setErrorMsg(result.errorMsg) @@ -200,6 +210,16 @@ export function useRollCallLogic(): [RollCallState, RollCallActions] { 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)) @@ -207,15 +227,15 @@ export function useRollCallLogic(): [RollCallState, RollCallActions] { const usedNumbers = new Set(selectedRecords.map(r => r.number)) // 如果不允许重复,检查剩余人数 - if (!singleAllowRepeat && pickCount > totalTasks - usedNumbers.size) { - setErrorMsg(`剩余可选人数不足 ${pickCount} 人`) + if (!singleAllowRepeat && pick > total - usedNumbers.size) { + setErrorMsg(`剩余可选人数不足 ${pick} 人`) setIsRolling(false) return } // 允许重复时,不需要检查人数 const forbidSet = singleAllowRepeat ? new Set() : usedNumbers - const nums = getRandomDistinct(totalTasks, pickCount, forbidSet) + const nums = getRandomDistinct(total, pick, forbidSet) if (!nums) { setErrorMsg("抽取失败,人数不足") setIsRolling(false) @@ -308,7 +328,7 @@ export function useRollCallLogic(): [RollCallState, RollCallActions] { const actions: RollCallActions = { setMode: handleModeChange, setTotalTasks: handleTotalChange, - setPickCount: (v: number) => { setPickCount(v); setErrorMsg("") }, + setPickCount: (v: number | string) => { setPickCount(v); setErrorMsg("") }, setRounds: handleRoundsChange, setAllowRepeat, setSingleAllowRepeat, diff --git a/components/number-ball.tsx b/components/number-ball.tsx index 8e9c9a0..6d91783 100644 --- a/components/number-ball.tsx +++ b/components/number-ball.tsx @@ -7,7 +7,7 @@ interface NumberBallProps { number: number state: "idle" | "highlighted" | "used" colorIndex?: number - size?: "normal" | "large" + size?: "normal" | "large" | "xlarge" dimmed?: boolean } @@ -34,6 +34,7 @@ export function NumberBall({ number, state, colorIndex, size = "normal", dimmed "flex items-center justify-center font-mono select-none transition-all duration-150", size === "normal" && "w-8 h-8 text-sm", size === "large" && "w-14 h-14 text-lg", + size === "xlarge" && "w-16 h-16 text-xl", dimmed && !isHighlighted && "opacity-30", )} style={{ diff --git a/components/result.tsx b/components/result.tsx index 044c6c9..a64061e 100644 --- a/components/result.tsx +++ b/components/result.tsx @@ -22,51 +22,84 @@ export function ResultPanel({ groups, hasResult, placeholder }: ResultPanelProps ) } + // 将groups分成pairs,每组人数>=15时单独一行 + const rows: { numbers: number[]; colorIndex: number }[][] = [] + let currentRow: { numbers: number[]; colorIndex: number }[] = [] + + for (const group of groups) { + if (group.numbers.length >= 15) { + // 单独一行 + if (currentRow.length > 0) { + rows.push(currentRow) + currentRow = [] + } + rows.push([group]) + } else { + // 尝试凑成一行两组 + currentRow.push(group) + if (currentRow.length === 2) { + rows.push(currentRow) + currentRow = [] + } + } + } + + // 处理剩余的组 + if (currentRow.length > 0) { + rows.push(currentRow) + } + return ( -
- {groups.map((group, idx) => { - const sorted = [...group.numbers].sort((a, b) => a - b) - const color = COLORS[group.colorIndex] - return ( -
- {/* Group label with color */} -
-
-
- - G{String(idx + 1).padStart(2, "0")} - +
+ {rows.map((row, rowIdx) => ( +
+ {row.map((group, idx) => { + const sorted = [...group.numbers].sort((a, b) => a - b) + const color = COLORS[group.colorIndex] + const globalIdx = groups.indexOf(group) + return ( +
+ {/* Group label with color */} +
+
+ + G{String(globalIdx + 1).padStart(2, "0")} + + + {String(group.numbers.length).padStart(2, "0")} P + +
+ {/* Numbers with color */} +
+ {sorted.map((num) => ( + + {String(num).padStart(2, "0")} + + ))} +
- - {String(group.numbers.length).padStart(2, "0")} P - -
- {/* Numbers with color */} -
- {sorted.map((num) => ( - - {String(num).padStart(2, "0")} - - ))} -
-
- ) - })} + ) + })} +
+ ))}
) } diff --git a/components/settings.tsx b/components/settings.tsx index 4e14d4a..df979dd 100644 --- a/components/settings.tsx +++ b/components/settings.tsx @@ -5,16 +5,16 @@ import type { Mode } from "@/components/logic" interface SettingsPanelProps { mode: Mode - totalTasks: number - pickCount: number - rounds: number + totalTasks: number | string + pickCount: number | string + rounds: number | string allowRepeat: boolean singleAllowRepeat: boolean selectedRecords: { number: number; colorIndex: number }[] onModeChange: (v: Mode) => void - onTotalTasksChange: (v: number) => void - onPickCountChange: (v: number) => void - onRoundsChange: (v: number) => void + onTotalTasksChange: (v: number | string) => void + onPickCountChange: (v: number | string) => void + onRoundsChange: (v: number | string) => void onAllowRepeatChange: (v: boolean) => void onSingleAllowRepeatChange: (v: boolean) => void onRoll: () => void @@ -29,13 +29,18 @@ function FieldRow({ value, onChange, min = 1, + error, }: { label: string hint: string - value: number - onChange: (v: number) => void + value: number | string + onChange: (v: number | string) => void min?: number + error?: string }) { + const displayValue = value === "" ? "" : value + const hasError = error && value === "" + return (
@@ -44,7 +49,13 @@ function FieldRow({
{ - const v = parseInt(e.target.value, 10) - if (!isNaN(v) && v >= min) onChange(v) + const inputValue = e.target.value + if (inputValue === "") { + onChange("") + } else { + const v = parseInt(inputValue, 10) + if (!isNaN(v)) onChange(v) + } }} className={cn( - "w-12 h-7 text-center text-sm font-mono text-[#16191f] bg-[#f7f8fa]", + "w-12 h-7 text-center text-sm font-mono bg-[#f7f8fa]", "border-x border-[#e2e6ea]", "focus:outline-none focus:bg-[#ffffff]", - "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none", + hasError && "text-[#d92d20] border-[#d92d20]" )} />