Initialization
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,8 @@
|
||||
# Environment variables
|
||||
.env*.local
|
||||
|
||||
# Common ignores
|
||||
node_modules
|
||||
.next/
|
||||
out/
|
||||
.DS_Store
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans), 'Geist Fallback';
|
||||
--font-mono: var(--font-geist-mono), 'Geist Mono Fallback';
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 2px;
|
||||
--radius-lg: 2px;
|
||||
--radius-xl: 2px;
|
||||
--radius-2xl: 2px;
|
||||
--radius-3xl: 2px;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* FUI 极简浅色系 */
|
||||
--background: #ffffff;
|
||||
--foreground: #16191f;
|
||||
--card: #f7f8fa;
|
||||
--card-foreground: #16191f;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #16191f;
|
||||
/* 主色:近黑 */
|
||||
--primary: #16191f;
|
||||
--primary-foreground: #ffffff;
|
||||
/* 强调色:近黑 — 唯一的高亮 */
|
||||
--accent: #0f141a;
|
||||
--accent-foreground: #ffffff;
|
||||
--secondary: #f1f3f5;
|
||||
--secondary-foreground: #4a5563;
|
||||
--muted: #f1f3f5;
|
||||
--muted-foreground: #8a95a1;
|
||||
--destructive: #d92d20;
|
||||
--border: #e2e6ea;
|
||||
--input: #f7f8fa;
|
||||
--ring: #16191f00;
|
||||
--radius: 2px;
|
||||
|
||||
--sidebar: #f7f8fa;
|
||||
--sidebar-foreground: #16191f;
|
||||
--sidebar-primary: #16191f;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #f1f3f5;
|
||||
--sidebar-accent-foreground: #4a5563;
|
||||
--sidebar-border: #e2e6ea;
|
||||
--sidebar-ring: #16191f00;
|
||||
|
||||
/* FUI 专用色标 */
|
||||
--fui-accent: #0f141a;
|
||||
--fui-dim: #e2e6ea;
|
||||
--fui-panel: #f7f8fa;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-mono antialiased;
|
||||
}
|
||||
html {
|
||||
@apply bg-background;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d0d5da transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #d0d5da;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* FUI 动画 — 只保留淡入,无弹跳、无光晕 */
|
||||
@keyframes fui-fadein {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fui-slide-up {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fui-fadein {
|
||||
animation: fui-fadein 0.2s ease both;
|
||||
}
|
||||
|
||||
.fui-slide-up {
|
||||
animation: fui-slide-up 0.2s ease both;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Geist, Geist_Mono } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RollCall',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} bg-background`}>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { NumberBall } from "@/components/number-ball"
|
||||
import { SettingsPanel } from "@/components/settings"
|
||||
import { ResultPanel } from "@/components/result"
|
||||
import { SingleModePanel } from "@/components/single-panel"
|
||||
import { useRollCallLogic } from "@/components/logic"
|
||||
|
||||
export default function Page() {
|
||||
const [state, actions] = useRollCallLogic()
|
||||
const isSingleMode = state.mode === "single"
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-[#ffffff] text-[#16191f] font-mono flex flex-col lg:flex-row overflow-hidden">
|
||||
|
||||
{/* Left sidebar — config (fixed width, full height) */}
|
||||
<aside className="w-full lg:w-72 flex-shrink-0 border-b lg:border-b-0 lg:border-r border-[#e2e6ea] bg-[#f7f8fa] overflow-y-auto scrollbar-thin">
|
||||
<div className="p-5">
|
||||
<SettingsPanel
|
||||
mode={state.mode}
|
||||
totalTasks={state.totalTasks}
|
||||
pickCount={state.pickCount}
|
||||
rounds={state.rounds}
|
||||
allowRepeat={state.allowRepeat}
|
||||
singleAllowRepeat={state.singleAllowRepeat}
|
||||
selectedRecords={state.selectedRecords}
|
||||
onModeChange={actions.setMode}
|
||||
onTotalTasksChange={actions.setTotalTasks}
|
||||
onPickCountChange={actions.setPickCount}
|
||||
onRoundsChange={actions.setRounds}
|
||||
onAllowRepeatChange={actions.setAllowRepeat}
|
||||
onSingleAllowRepeatChange={actions.setSingleAllowRepeat}
|
||||
onRoll={isSingleMode ? actions.handleSingleRoll : actions.handleRoll}
|
||||
onReset={actions.resetSingle}
|
||||
isRolling={state.isRolling}
|
||||
errorMsg={state.errorMsg}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right content — fills remaining space */}
|
||||
<main className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
|
||||
{/* Single mode: Full height matrix with records */}
|
||||
{isSingleMode ? (
|
||||
<>
|
||||
{/* Matrix area - full width, dynamic centering */}
|
||||
<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">
|
||||
<span className="text-[10px] tracking-[0.2em] uppercase text-[#8a95a1]">MATRIX</span>
|
||||
<span className="flex-1 h-px bg-[#e2e6ea]" />
|
||||
{state.selectedRecords.length > 0 && (
|
||||
<span className="text-[10px] text-[#0f141a]">
|
||||
{state.selectedRecords.length} SELECTED
|
||||
</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) => (
|
||||
<NumberBall
|
||||
key={n}
|
||||
number={n}
|
||||
state={actions.getBallState(n)}
|
||||
colorIndex={actions.getBallColorIndex(n)}
|
||||
size="large"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Single mode records panel */}
|
||||
<section className="flex flex-col border-t border-[#e2e6ea] max-h-[35vh]">
|
||||
<SingleModePanel singleBatches={state.singleBatches} />
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Group mode: Matrix grid */}
|
||||
<section className="flex flex-col flex-shrink-0 border-b border-[#e2e6ea]">
|
||||
<div className="flex items-center gap-3 px-5 py-2.5 border-b border-[#e2e6ea]">
|
||||
<span className="text-[10px] tracking-[0.2em] uppercase text-[#8a95a1]">MATRIX</span>
|
||||
<span className="flex-1 h-px bg-[#e2e6ea]" />
|
||||
{state.hasResult && (
|
||||
<span className="text-[10px] text-[#0f141a]">
|
||||
{state.highlighted.size} SELECTED
|
||||
</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) => (
|
||||
<NumberBall
|
||||
key={n}
|
||||
number={n}
|
||||
state={actions.getBallState(n)}
|
||||
colorIndex={actions.getBallColorIndex(n)}
|
||||
size="large"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<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 && (
|
||||
<button
|
||||
onClick={actions.clearResult}
|
||||
className="text-[10px] text-[#8a95a1] hover:text-[#16191f] transition-colors tracking-widest uppercase"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin px-4 py-3">
|
||||
<ResultPanel groups={state.groups} hasResult={state.hasResult} placeholder={state.placeholder} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
output: 'export',
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
Generated
+5767
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "my-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "16.2.6",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"shadcn": "^4.8.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
Generated
+4938
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,16 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Roll Call",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.zhhe.rollcall",
|
||||
"build": {
|
||||
"frontendDist": "../out",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Roll Call",
|
||||
"width": 1204,
|
||||
"height": 685,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "fixedRuntime",
|
||||
"path": "./webview2/"
|
||||
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"debugApplicationIdSuffix": ".debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"src-tauri"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user