Initialization

This commit is contained in:
He
2026-06-15 19:01:02 +08:00
commit 325c267f0c
28 changed files with 12087 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
"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
}
export const COLORS = [
"#0f141a", // 黑色
"#d92d20", // 红色
"#0070f3", // 蓝色
"#6d6df6", // 紫色
"#00b8d9", // 青色
"#36b37e", // 绿色
"#ff991f", // 橙色
"#8754ad", // 深紫
]
export function useRollCallLogic(): [RollCallState, RollCallActions] {
const [mode, setMode] = useState<Mode>("single")
const [totalTasks, setTotalTasks] = useState(20)
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") {
const isInRecords = selectedRecords.some(r => r.number === n)
if (isInRecords) return "highlighted"
return "idle"
}
if (highlighted.has(n)) return "highlighted"
if (hasResult && usedAll.size > 0 && !usedAll.has(n)) return "used"
return "idle"
}, [mode, selectedRecords, 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 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,
}
return [state, actions]
}
+43
View File
@@ -0,0 +1,43 @@
"use client"
import { cn } from "@/lib/utils"
import { COLORS } from "./logic"
interface NumberBallProps {
number: number
state: "idle" | "highlighted" | "used"
colorIndex?: number
size?: "normal" | "large"
}
export function NumberBall({ number, state, colorIndex, size = "normal" }: NumberBallProps) {
const isHighlighted = state === "highlighted"
const borderColor = isHighlighted && colorIndex !== undefined
? COLORS[colorIndex]
: state === "idle" ? "#e2e6ea"
: "#eef0f2"
const bgColor = isHighlighted && colorIndex !== undefined
? COLORS[colorIndex]
: "transparent"
const textColor = isHighlighted && colorIndex !== undefined
? "#ffffff"
: state === "idle" ? "#8a95a1"
: "#cdd2d8"
return (
<div
className={cn(
"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",
)}
style={{
border: `1px solid ${borderColor}`,
backgroundColor: bgColor,
color: textColor,
}}
>
{String(number).padStart(2, "0")}
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
"use client"
import { COLORS } from "./logic"
interface ResultPanelProps {
groups: { numbers: number[]; colorIndex: number }[]
hasResult: boolean
placeholder?: string
}
export function ResultPanel({ groups, hasResult, placeholder }: ResultPanelProps) {
if (!hasResult || groups.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full min-h-40 gap-3">
<div className="text-[10px] tracking-[0.2em] text-[#cdd2d8] font-mono uppercase">
AWAITING INPUT
</div>
{placeholder && (
<div className="text-xs text-[#8a95a1] font-mono">{placeholder}</div>
)}
</div>
)
}
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>
<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>
)
}
+302
View File
@@ -0,0 +1,302 @@
"use client"
import { cn } from "@/lib/utils"
import type { Mode } from "@/components/logic"
interface SettingsPanelProps {
mode: Mode
totalTasks: number
pickCount: number
rounds: number
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
onAllowRepeatChange: (v: boolean) => void
onSingleAllowRepeatChange: (v: boolean) => void
onRoll: () => void
onReset: () => void
isRolling: boolean
errorMsg: string
}
function FieldRow({
label,
hint,
value,
onChange,
min = 1,
}: {
label: string
hint: string
value: number
onChange: (v: number) => void
min?: number
}) {
return (
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-xs text-[#16191f] leading-none mb-0.5">{label}</div>
<div className="text-[10px] text-[#8a95a1] leading-none">{hint}</div>
</div>
<div className="flex items-center gap-0 border border-[#e2e6ea] flex-shrink-0">
<button
onClick={() => onChange(Math.max(min, value - 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}`}
>
</button>
<input
type="number"
value={value}
min={min}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v) && v >= min) onChange(v)
}}
className={cn(
"w-12 h-7 text-center text-sm font-mono text-[#16191f] bg-[#f7f8fa]",
"border-x border-[#e2e6ea]",
"focus:outline-none focus:bg-[#ffffff]",
"[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
)}
/>
<button
onClick={() => onChange(value + 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}`}
>
+
</button>
</div>
</div>
)
}
export function SettingsPanel({
mode,
totalTasks,
pickCount,
rounds,
allowRepeat,
singleAllowRepeat,
selectedRecords,
onModeChange,
onTotalTasksChange,
onPickCountChange,
onRoundsChange,
onAllowRepeatChange,
onSingleAllowRepeatChange,
onRoll,
onReset,
isRolling,
errorMsg,
}: SettingsPanelProps) {
const repeatDisabled = rounds < 2
const isSingleMode = mode === "single"
return (
<div className="flex flex-col gap-0">
{/* Title */}
<div className="flex items-center gap-2 mb-6">
<span className="w-2 h-2 bg-[#0f141a] flex-shrink-0" />
<span className="text-sm tracking-[0.2em] uppercase text-[#16191f] leading-none">ROLL CALL</span>
</div>
{/* Mode selector */}
<div className="flex gap-2 mb-6">
<button
onClick={() => onModeChange("single")}
className={cn(
"flex-1 h-9 border text-xs tracking-wider uppercase transition-colors",
isSingleMode
? "border-[#0f141a] bg-[#0f141a] text-[#ffffff]"
: "border-[#e2e6ea] text-[#8a95a1] hover:border-[#cdd2d8] hover:text-[#16191f]"
)}
>
</button>
<button
onClick={() => onModeChange("multi")}
className={cn(
"flex-1 h-9 border text-xs tracking-wider uppercase transition-colors",
!isSingleMode
? "border-[#0f141a] bg-[#0f141a] text-[#ffffff]"
: "border-[#e2e6ea] text-[#8a95a1] hover:border-[#cdd2d8] hover:text-[#16191f]"
)}
>
</button>
</div>
{/* Section label */}
<div className="flex items-center gap-2 mb-4">
<span className="text-[10px] tracking-[0.2em] uppercase text-[#8a95a1] font-mono">CONFIG</span>
<span className="flex-1 h-px bg-[#e2e6ea]" />
</div>
{/* Fields */}
<div className="flex flex-col gap-4 mb-6">
<FieldRow label="总人数" hint="参与点名的序号上限" value={totalTasks} onChange={onTotalTasksChange} />
<div className="h-px bg-[#e2e6ea]" />
<FieldRow
label="每次人数"
hint={isSingleMode ? "每次抽取的人数" : "每组抽取的人数"}
value={pickCount}
onChange={onPickCountChange}
/>
{!isSingleMode && (
<>
<div className="h-px bg-[#e2e6ea]" />
<FieldRow label="抽取组数" hint="生成分组的数量" value={rounds} onChange={onRoundsChange} min={1} />
</>
)}
</div>
{/* Single mode: Allow repeat toggle */}
{isSingleMode && (
<button
type="button"
onClick={() => onSingleAllowRepeatChange(!singleAllowRepeat)}
className={cn(
"flex items-center justify-between w-full px-3 py-2.5 border mb-6 transition-colors",
singleAllowRepeat && "border-[#0f141a] bg-[#f1f3f5]",
!singleAllowRepeat && "border-[#e2e6ea] hover:border-[#cdd2d8]"
)}
>
<div className="text-left">
<div className={cn("text-xs", singleAllowRepeat ? "text-[#0f141a]" : "text-[#4a5563]")}>
</div>
<div className="text-[10px] text-[#8a95a1] mt-0.5"></div>
</div>
{/* Toggle pill */}
<div className={cn(
"w-9 h-5 border relative flex-shrink-0 transition-colors",
singleAllowRepeat ? "border-[#0f141a] bg-[#0f141a]/10" : "border-[#cdd2d8]"
)}>
<span className={cn(
"absolute top-0.5 w-3 h-3 transition-all duration-150 rounded-full",
singleAllowRepeat
? "left-[calc(100%-15px)] bg-[#0f141a]"
: "left-0.5 bg-[#cdd2d8]"
)} />
</div>
</button>
)}
{/* Group mode only: Toggle */}
{!isSingleMode && (
<>
{/* Toggle */}
<button
type="button"
disabled={repeatDisabled}
onClick={() => !repeatDisabled && onAllowRepeatChange(!allowRepeat)}
className={cn(
"flex items-center justify-between w-full px-3 py-2.5 border mb-6 transition-colors",
repeatDisabled && "opacity-40 cursor-not-allowed border-[#e2e6ea]",
!repeatDisabled && allowRepeat && "border-[#0f141a] bg-[#f1f3f5]",
!repeatDisabled && !allowRepeat && "border-[#e2e6ea] hover:border-[#cdd2d8]"
)}
>
<div className="text-left">
<div className={cn("text-xs", allowRepeat && !repeatDisabled ? "text-[#0f141a]" : "text-[#4a5563]")}>
</div>
<div className="text-[10px] text-[#8a95a1] mt-0.5"></div>
</div>
{/* Toggle pill */}
<div className={cn(
"w-9 h-5 border relative flex-shrink-0 transition-colors",
allowRepeat && !repeatDisabled ? "border-[#0f141a] bg-[#0f141a]/10" : "border-[#cdd2d8]"
)}>
<span className={cn(
"absolute top-0.5 w-3 h-3 transition-all duration-150 rounded-full",
allowRepeat && !repeatDisabled
? "left-[calc(100%-15px)] bg-[#0f141a]"
: "left-0.5 bg-[#cdd2d8]"
)} />
</div>
</button>
</>
)}
{/* Error */}
{errorMsg && (
<div className="border border-[#d92d20]/40 bg-[#d92d20]/5 px-3 py-2 mb-4 text-xs text-[#d92d20] leading-relaxed fui-fadein">
ERR: {errorMsg}
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
{/* Single mode: Roll button */}
{isSingleMode && (
<button
onClick={onRoll}
disabled={isRolling}
className={cn(
"flex-1 h-10 border text-xs tracking-[0.15em] uppercase font-mono transition-all",
"disabled:opacity-40 disabled:cursor-not-allowed",
isRolling
? "border-[#e2e6ea] text-[#8a95a1]"
: "border-[#0f141a] bg-[#0f141a] text-[#ffffff] hover:bg-[#ffffff] hover:text-[#0f141a]"
)}
>
{isRolling ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-3 h-3 border border-[#cdd2d8] border-t-[#16191f] rounded-full animate-spin" />
PROCESSING
</span>
) : (
"SELECT"
)}
</button>
)}
{/* Single mode: Reset button */}
{isSingleMode && selectedRecords.length > 0 && (
<button
onClick={onReset}
className={cn(
"h-10 px-4 border border-[#e2e6ea] text-xs tracking-wider uppercase font-mono transition-colors",
"text-[#8a95a1] hover:border-[#d92d20] hover:text-[#d92d20]"
)}
>
RESET
</button>
)}
{/* Multi mode: Execute button */}
{!isSingleMode && (
<button
onClick={onRoll}
disabled={isRolling}
className={cn(
"w-full h-10 border text-xs tracking-[0.15em] uppercase font-mono transition-all",
"disabled:opacity-40 disabled:cursor-not-allowed",
isRolling
? "border-[#e2e6ea] text-[#8a95a1]"
: "border-[#0f141a] bg-[#0f141a] text-[#ffffff] hover:bg-[#ffffff] hover:text-[#0f141a]"
)}
>
{isRolling ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-3 h-3 border border-[#cdd2d8] border-t-[#16191f] rounded-full animate-spin" />
PROCESSING
</span>
) : (
"EXECUTE"
)}
</button>
)}
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
"use client"
import { COLORS } from "@/components/logic"
interface SingleModePanelProps {
singleBatches: { numbers: number[]; colorIndex: number; batchNumber: number }[]
}
export function SingleModePanel({ singleBatches }: SingleModePanelProps) {
if (singleBatches.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-12 gap-3">
<div className="text-[10px] tracking-[0.2em] text-[#cdd2d8] font-mono uppercase">
AWAITING INPUT
</div>
</div>
)
}
// Reverse to show newest first
const reversedBatches = [...singleBatches].reverse()
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Stats */}
<div className="flex items-center gap-4 px-5 py-2 border-b border-[#e2e6ea] flex-shrink-0">
<span className="text-[10px] tracking-[0.2em] uppercase text-[#8a95a1]">RECORDS</span>
<span className="flex-1 h-px bg-[#e2e6ea]" />
<span className="text-[10px] text-[#0f141a]">
{singleBatches.length} BATCHES
</span>
</div>
{/* Records grid - newest first, wrapping */}
<div className="flex-1 overflow-y-auto scrollbar-thin px-5 py-3">
<div className="flex flex-wrap gap-2">
{reversedBatches.map((batch, displayIdx) => {
const actualIdx = singleBatches.length - 1 - displayIdx
return (
<div
key={`batch-${batch.batchNumber}`}
className="flex items-center gap-2 px-3 py-2 border border-[#e2e6ea] fui-slide-up"
style={{ animationDelay: `${displayIdx * 20}ms` }}
>
{/* Color indicator */}
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: COLORS[batch.colorIndex] }}
/>
{/* Numbers - sorted */}
<span
className="text-sm font-mono"
style={{
color: COLORS[batch.colorIndex],
}}
>
{[...batch.numbers].sort((a, b) => a - b).map(n => String(n).padStart(2, "0")).join(" ")}
</span>
{/* Batch index */}
<span className="text-[9px] text-[#8a95a1] font-mono ml-1">
#{String(batch.batchNumber).padStart(3, "0")}
</span>
</div>
)
})}
</div>
</div>
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }