Initial React badminton team matching app
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 羽毛球隊伍配對器
|
||||||
|
|
||||||
|
這是一個使用 `React + Vite + TypeScript` 製作的網頁應用程式,專門用來協助羽毛球活動快速配對隊伍。
|
||||||
|
|
||||||
|
## 功能說明
|
||||||
|
|
||||||
|
- 可分別輸入 A 區與 B 區的成員名單
|
||||||
|
- 系統每次固定產生 3 組隊伍
|
||||||
|
- 每組皆由 `A 區 1 位 + B 區 1 位` 組成
|
||||||
|
- 同一輪內不會重複使用同一位成員
|
||||||
|
- 會顯示本輪未被選中的候補名單
|
||||||
|
- 會自動將輸入內容儲存在瀏覽器本機
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 正式建置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 補充說明
|
||||||
|
|
||||||
|
- 輸入格式支援換行、半形逗號、全形逗號與頓號
|
||||||
|
- 系統會自動去除空白與重複名稱
|
||||||
|
- A 區與 B 區都至少需要 3 位不重複成員才能完成配對
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
17
index.html
Normal file
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="羽毛球隊伍配對器,支援輸入 A 區與 B 區名單並自動產生三組不重複配對。"
|
||||||
|
/>
|
||||||
|
<title>羽毛球隊伍配對器</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2999
package-lock.json
generated
Normal file
2999
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "badminton-match-hub",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
353
src/App.css
Normal file
353
src/App.css
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
.app-shell {
|
||||||
|
width: min(1180px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.panel,
|
||||||
|
.stat-card,
|
||||||
|
.tip-card,
|
||||||
|
.pair-card {
|
||||||
|
border: 1px solid rgba(24, 57, 38, 0.12);
|
||||||
|
box-shadow: 0 20px 45px rgba(26, 47, 35, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.35fr 0.85fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.hero-stats,
|
||||||
|
.panel {
|
||||||
|
border-radius: 28px;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
padding: 36px;
|
||||||
|
background: linear-gradient(145deg, rgba(255, 247, 234, 0.92), rgba(232, 244, 235, 0.84));
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #1d6c46;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h1,
|
||||||
|
.panel-heading h2,
|
||||||
|
.tip-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #183926;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h1 {
|
||||||
|
font-size: clamp(2.6rem, 5vw, 4.7rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
max-width: 8ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text {
|
||||||
|
max-width: 54ch;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #516256;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.ghost-button {
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: linear-gradient(135deg, #1d7b4d, #2b9f66);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 16px 28px rgba(29, 123, 77, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
color: #183926;
|
||||||
|
border: 1px solid rgba(24, 57, 38, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover,
|
||||||
|
.ghost-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.tip-card {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(255, 250, 244, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card span {
|
||||||
|
color: #5c6f61;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card strong {
|
||||||
|
color: #183926;
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
background: linear-gradient(145deg, #183926, #24543a);
|
||||||
|
color: #edf6ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-card h2 {
|
||||||
|
color: #edf6ef;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-card p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid,
|
||||||
|
.bench-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: 1.02fr 0.98fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(255, 253, 249, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: #4b5b50;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-field span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #183926;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 260px;
|
||||||
|
border: 1px solid rgba(24, 57, 38, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
color: #183926;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-field textarea:focus,
|
||||||
|
.primary-button:focus-visible,
|
||||||
|
.ghost-button:focus-visible {
|
||||||
|
outline: 2px solid rgba(29, 123, 77, 0.26);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-text,
|
||||||
|
.empty-inline,
|
||||||
|
.empty-state p {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: #627265;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(186, 61, 47, 0.09);
|
||||||
|
border: 1px solid rgba(186, 61, 47, 0.18);
|
||||||
|
color: #8d2d22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-card {
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 18px;
|
||||||
|
background: linear-gradient(145deg, rgba(255, 247, 236, 0.95), rgba(244, 251, 246, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-index {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #617265;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-names {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
min-height: 98px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip strong {
|
||||||
|
font-size: clamp(1.15rem, 2vw, 1.45rem);
|
||||||
|
color: #183926;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip-a {
|
||||||
|
background: rgba(244, 157, 55, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip-b {
|
||||||
|
background: rgba(29, 123, 77, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #5f6f63;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-link {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #183926;
|
||||||
|
color: #fff6ea;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
min-height: 240px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px dashed rgba(24, 57, 38, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(29, 123, 77, 0.08);
|
||||||
|
color: #1d6c46;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.hero-card,
|
||||||
|
.content-grid,
|
||||||
|
.bench-grid,
|
||||||
|
.input-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h1 {
|
||||||
|
max-width: 10ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-shell {
|
||||||
|
width: min(100% - 20px, 1180px);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.panel,
|
||||||
|
.stat-card,
|
||||||
|
.tip-card {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-names {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-link {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-field textarea {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
260
src/App.tsx
Normal file
260
src/App.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
type Pair = {
|
||||||
|
id: number
|
||||||
|
playerA: string
|
||||||
|
playerB: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawResult = {
|
||||||
|
pairs: Pair[]
|
||||||
|
benchA: string[]
|
||||||
|
benchB: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
areaA: 'badminton-match-hub::area-a',
|
||||||
|
areaB: 'badminton-match-hub::area-b',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const defaultAreaA = ['小杰', '阿誠', '建宏', '柏宇', 'Eason']
|
||||||
|
const defaultAreaB = ['小安', '佩珊', '阿廷', 'Luna', 'Mina']
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [areaAInput, setAreaAInput] = useState(() =>
|
||||||
|
loadRoster(STORAGE_KEYS.areaA, defaultAreaA),
|
||||||
|
)
|
||||||
|
const [areaBInput, setAreaBInput] = useState(() =>
|
||||||
|
loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
|
||||||
|
)
|
||||||
|
const [result, setResult] = useState<DrawResult | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
|
||||||
|
}, [areaAInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
||||||
|
}, [areaBInput])
|
||||||
|
|
||||||
|
const parsedAreaA = parseRoster(areaAInput)
|
||||||
|
const parsedAreaB = parseRoster(areaBInput)
|
||||||
|
|
||||||
|
function generateMatches() {
|
||||||
|
if (parsedAreaA.length < 3 || parsedAreaB.length < 3) {
|
||||||
|
setResult(null)
|
||||||
|
setError('A 區與 B 區都至少要有 3 位不重複成員,才能產生三組配對。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffledA = shuffleList(parsedAreaA)
|
||||||
|
const shuffledB = shuffleList(parsedAreaB)
|
||||||
|
const selectedA = shuffledA.slice(0, 3)
|
||||||
|
const selectedB = shuffledB.slice(0, 3)
|
||||||
|
|
||||||
|
const pairs = selectedA.map((playerA, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
playerA,
|
||||||
|
playerB: selectedB[index],
|
||||||
|
}))
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
pairs,
|
||||||
|
benchA: shuffledA.slice(3),
|
||||||
|
benchB: shuffledB.slice(3),
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDemo() {
|
||||||
|
setAreaAInput(defaultAreaA.join('\n'))
|
||||||
|
setAreaBInput(defaultAreaB.join('\n'))
|
||||||
|
setResult(null)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<section className="hero-card">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<p className="eyebrow">使用 React、Vite 與 TypeScript 建置</p>
|
||||||
|
<h1>羽毛球隊伍配對器</h1>
|
||||||
|
<p className="hero-text">
|
||||||
|
分別輸入 A 區與 B 區名單後,系統會從兩區各抽出 3 位成員,
|
||||||
|
自動組成 3 組隊伍。每一組都是 A 區 1 位搭配 B 區 1 位,
|
||||||
|
同一輪內三組成員都不會重複。
|
||||||
|
</p>
|
||||||
|
<div className="hero-actions">
|
||||||
|
<button className="primary-button" type="button" onClick={generateMatches}>
|
||||||
|
產生三組配對
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={resetDemo}>
|
||||||
|
載入範例名單
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-stats">
|
||||||
|
<article className="stat-card">
|
||||||
|
<span>A 區可用人數</span>
|
||||||
|
<strong>{parsedAreaA.length}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="stat-card">
|
||||||
|
<span>B 區可用人數</span>
|
||||||
|
<strong>{parsedAreaB.length}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="tip-card">
|
||||||
|
<h2>配對規則</h2>
|
||||||
|
<p>固定產生 3 組,且每組皆為 A 區 1 位加上 B 區 1 位,當輪不重複。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="content-grid">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<p className="eyebrow">名單輸入</p>
|
||||||
|
<h2>輸入 A 區與 B 區成員</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-grid">
|
||||||
|
<label className="roster-field">
|
||||||
|
<span>A 區成員</span>
|
||||||
|
<textarea
|
||||||
|
value={areaAInput}
|
||||||
|
onChange={(event) => setAreaAInput(event.target.value)}
|
||||||
|
placeholder={'每行一位\n例如:小杰'}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="roster-field">
|
||||||
|
<span>B 區成員</span>
|
||||||
|
<textarea
|
||||||
|
value={areaBInput}
|
||||||
|
onChange={(event) => setAreaBInput(event.target.value)}
|
||||||
|
placeholder={'每行一位\n例如:小安'}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="helper-text">
|
||||||
|
支援換行、半形逗號、全形逗號與頓號分隔,系統會自動去除空白與重複名稱。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? <p className="error-banner">{error}</p> : null}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<p className="eyebrow">配對結果</p>
|
||||||
|
<h2>本輪三組隊伍</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="pair-list">
|
||||||
|
{result.pairs.map((pair) => (
|
||||||
|
<article className="pair-card" key={pair.id}>
|
||||||
|
<div className="pair-index">第 {pair.id} 組</div>
|
||||||
|
<div className="pair-names">
|
||||||
|
<div className="player-chip player-chip-a">
|
||||||
|
<span className="chip-label">A 區</span>
|
||||||
|
<strong>{pair.playerA}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="pair-link">+</div>
|
||||||
|
<div className="player-chip player-chip-b">
|
||||||
|
<span className="chip-label">B 區</span>
|
||||||
|
<strong>{pair.playerB}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>輸入名單後按下「產生三組配對」,這裡會顯示本輪的抽籤結果。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bench-grid">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<p className="eyebrow">候補名單</p>
|
||||||
|
<h2>A 區未出場成員</h2>
|
||||||
|
</div>
|
||||||
|
<RosterList
|
||||||
|
names={result?.benchA ?? parsedAreaA.slice(3)}
|
||||||
|
emptyText="目前沒有 A 區候補成員"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<p className="eyebrow">候補名單</p>
|
||||||
|
<h2>B 區未出場成員</h2>
|
||||||
|
</div>
|
||||||
|
<RosterList
|
||||||
|
names={result?.benchB ?? parsedAreaB.slice(3)}
|
||||||
|
emptyText="目前沒有 B 區候補成員"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RosterListProps = {
|
||||||
|
names: string[]
|
||||||
|
emptyText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function RosterList({ names, emptyText }: RosterListProps) {
|
||||||
|
if (names.length === 0) {
|
||||||
|
return <p className="empty-inline">{emptyText}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="name-list">
|
||||||
|
{names.map((name) => (
|
||||||
|
<span className="name-pill" key={name}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRoster(storageKey: string, fallbackList: string[]) {
|
||||||
|
const storedValue = window.localStorage.getItem(storageKey)
|
||||||
|
return storedValue && storedValue.trim() ? storedValue : fallbackList.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoster(input: string) {
|
||||||
|
const uniqueNames = new Set<string>()
|
||||||
|
|
||||||
|
input
|
||||||
|
.split(/[\n,,、]+/g)
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((name) => uniqueNames.add(name))
|
||||||
|
|
||||||
|
return Array.from(uniqueNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleList(list: string[]) {
|
||||||
|
const next = [...list]
|
||||||
|
|
||||||
|
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(Math.random() * (index + 1))
|
||||||
|
;[next[index], next[swapIndex]] = [next[swapIndex], next[index]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
50
src/index.css
Normal file
50
src/index.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
'Segoe UI', 'PingFang TC', 'Microsoft JhengHei', sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #21402e;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(245, 169, 62, 0.3), transparent 24%),
|
||||||
|
radial-gradient(circle at top right, rgba(29, 123, 77, 0.18), transparent 28%),
|
||||||
|
linear-gradient(150deg, #f4efe5 0%, #fbf8f2 48%, #edf7f0 100%);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
button,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user