[feat] Support empty numeric inputs & UI improvements for real app

This commit is contained in:
He
2026-06-16 19:00:03 +08:00
parent b926a6e30d
commit b86716e952
7 changed files with 183 additions and 87 deletions
+6
View File
@@ -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;
}
}
+19 -7
View File
@@ -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 (
<div className="h-screen w-full bg-[#0f141a] text-[#16191f] font-mono flex flex-col overflow-hidden">
{/* Custom title bar */}
@@ -61,16 +73,16 @@ export default function Page() {
</span>
)}
</div>
<div className="flex-1 flex items-center justify-center overflow-auto scrollbar-thin p-8">
<div className="flex flex-wrap gap-2 justify-center max-w-2xl">
{Array.from({ length: state.totalTasks }, (_, i) => i + 1).map((n) => (
<div className="flex-1 flex items-center justify-center overflow-auto scrollbar-thin p-3">
<div className="flex flex-wrap gap-2 justify-center">
{Array.from({ length: typeof state.totalTasks === 'number' ? state.totalTasks : 0 }, (_, i) => i + 1).map((n) => (
<NumberBall
key={n}
number={n}
state={actions.getBallState(n)}
colorIndex={actions.getBallColorIndex(n)}
dimmed={actions.getBallDimmed(n)}
size="large"
size="xlarge"
/>
))}
</div>
@@ -95,8 +107,8 @@ export default function Page() {
</span>
)}
</div>
<div className="flex flex-wrap gap-2 p-4 max-h-[40vh] overflow-y-auto scrollbar-thin">
{Array.from({ length: state.totalTasks }, (_, i) => i + 1).map((n) => (
<div className="flex flex-wrap gap-2 p-3 max-h-[40vh] overflow-y-auto scrollbar-thin">
{Array.from({ length: typeof state.totalTasks === 'number' ? state.totalTasks : 0 }, (_, i) => i + 1).map((n) => (
<NumberBall
key={n}
number={n}
@@ -110,7 +122,7 @@ export default function Page() {
{/* Group mode result panel */}
<section className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-3 px-5 py-2.5 border-b border-[#e2e6ea] flex-shrink-0">
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-[#e2e6ea] flex-shrink-0">
<span className="text-[10px] tracking-[0.2em] uppercase text-[#8a95a1]">OUTPUT</span>
<span className="flex-1 h-px bg-[#e2e6ea]" />
{state.hasResult && state.groups.length > 0 && (
+39 -19
View File
@@ -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<Mode>("single")
const [totalTasks, setTotalTasks] = useState(35)
const [pickCount, setPickCount] = useState(1)
const [rounds, setRounds] = useState(1)
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 }[]>([])
@@ -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<number>() : 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,
+2 -1
View File
@@ -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={{
+76 -43
View File
@@ -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 (
<div className="flex flex-col">
{groups.map((group, idx) => {
const sorted = [...group.numbers].sort((a, b) => a - b)
const color = COLORS[group.colorIndex]
return (
<div
key={idx}
className="flex items-start gap-4 py-2.5 border-b border-[#e2e6ea] last:border-b-0 fui-slide-up"
style={{ animationDelay: `${idx * 40}ms` }}
>
{/* Group label with color */}
<div className="flex flex-col gap-0.5 w-14 flex-shrink-0 pt-0.5">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-xs font-mono uppercase tracking-[0.15em]" style={{ color }}>
G{String(idx + 1).padStart(2, "0")}
</span>
<div className="flex flex-col gap-3">
{rows.map((row, rowIdx) => (
<div
key={rowIdx}
className="flex gap-6 fui-slide-up"
style={{ animationDelay: `${rowIdx * 40}ms` }}
>
{row.map((group, idx) => {
const sorted = [...group.numbers].sort((a, b) => a - b)
const color = COLORS[group.colorIndex]
const globalIdx = groups.indexOf(group)
return (
<div
key={globalIdx}
className="flex-1 min-w-0"
>
{/* Group label with color */}
<div className="flex items-center gap-2 mb-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-xs font-mono uppercase tracking-[0.15em]" style={{ color }}>
G{String(globalIdx + 1).padStart(2, "0")}
</span>
<span className="text-[10px] font-mono text-[#8a95a1]">
{String(group.numbers.length).padStart(2, "0")} P
</span>
</div>
{/* Numbers with color */}
<div className="flex flex-wrap gap-1.5">
{sorted.map((num) => (
<span
key={num}
className="min-w-8 h-8 px-1.5 flex items-center justify-center text-sm font-mono border fui-fadein"
style={{
borderColor: color + "40",
backgroundColor: color + "15",
color: color,
}}
>
{String(num).padStart(2, "0")}
</span>
))}
</div>
</div>
<span className="text-[10px] font-mono text-[#8a95a1]">
{String(group.numbers.length).padStart(2, "0")} P
</span>
</div>
{/* Numbers with color */}
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{sorted.map((num) => (
<span
key={num}
className="min-w-8 h-8 px-1.5 flex items-center justify-center text-sm font-mono border fui-fadein"
style={{
borderColor: color + "40",
backgroundColor: color + "15",
color: color,
}}
>
{String(num).padStart(2, "0")}
</span>
))}
</div>
</div>
)
})}
)
})}
</div>
))}
</div>
)
}
+40 -16
View File
@@ -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 (
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
@@ -44,7 +49,13 @@ function FieldRow({
</div>
<div className="flex items-center gap-0 border border-[#e2e6ea] flex-shrink-0">
<button
onClick={() => onChange(Math.max(min, value - 1))}
onClick={() => {
if (displayValue === "") {
onChange(min)
} else {
onChange(Math.max(min, (value as number) - 1))
}
}}
className="w-7 h-7 flex items-center justify-center text-[#8a95a1] hover:text-[#16191f] hover:bg-[#f1f3f5] transition-colors text-base leading-none"
aria-label={`减少${label}`}
>
@@ -52,21 +63,33 @@ function FieldRow({
</button>
<input
type="number"
value={value}
value={displayValue}
min={min}
onChange={(e) => {
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]"
)}
/>
<button
onClick={() => onChange(value + 1)}
onClick={() => {
if (displayValue === "") {
onChange(min)
} else {
onChange((value as number) + 1)
}
}}
className="w-7 h-7 flex items-center justify-center text-[#8a95a1] hover:text-[#16191f] hover:bg-[#f1f3f5] transition-colors text-base leading-none"
aria-label={`增加${label}`}
>
@@ -96,7 +119,8 @@ export function SettingsPanel({
isRolling,
errorMsg,
}: SettingsPanelProps) {
const repeatDisabled = rounds < 2
// 只有当 rounds 是数字且小于 2 时才禁用重复选项
const repeatDisabled = typeof rounds === 'number' && rounds < 2
const isSingleMode = mode === "single"
return (
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.