新增 PWA 更新提示並整理 README

This commit is contained in:
2026-04-16 19:57:08 +08:00
parent 0cfcdc3b0a
commit 975732017f
11 changed files with 349 additions and 67 deletions

View File

@@ -75,6 +75,59 @@
background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92));
}
.pwa-update-toast {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 1200;
display: flex;
align-items: center;
gap: 14px;
width: min(560px, calc(100vw - 24px));
padding: 14px 16px;
border: 1px solid rgba(10, 51, 45, 0.16);
border-radius: 20px;
background: rgba(255, 249, 236, 0.96);
box-shadow:
0 22px 46px rgba(10, 51, 45, 0.22),
0 8px 18px rgba(10, 51, 45, 0.12);
transform: translateX(-50%);
backdrop-filter: blur(14px);
}
.pwa-update-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.pwa-update-copy strong {
font-size: 1rem;
color: var(--panel-strong);
}
.pwa-update-copy span {
font-size: 0.88rem;
color: var(--panel-soft);
}
.pwa-update-button {
flex: 0 0 auto;
min-width: 96px;
padding: 10px 14px;
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #0d5d53, #123f49);
color: #f8fff8;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.pwa-update-button:hover {
filter: brightness(1.06);
}
.page-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
@@ -1644,6 +1697,27 @@
}
@media (max-width: 720px) {
.pwa-update-toast {
gap: 10px;
bottom: 12px;
padding: 12px 12px 12px 14px;
border-radius: 16px;
}
.pwa-update-copy strong {
font-size: 0.92rem;
}
.pwa-update-copy span {
font-size: 0.78rem;
}
.pwa-update-button {
min-width: 84px;
padding: 9px 12px;
font-size: 0.84rem;
}
.app-shell {
width: min(100% - 14px, 1240px);
padding: 14px 0 24px;

View File

@@ -79,6 +79,7 @@ const STREAK_TITLES: Record<number, string> = {
7: '像神一般的',
8: '成為傳說',
}
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
function App() {
const location = useLocation()
@@ -115,6 +116,7 @@ function App() {
})
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
@@ -174,6 +176,18 @@ function App() {
return () => window.clearTimeout(timer)
}, [victoryAnnouncement])
useEffect(() => {
const handlePwaUpdateReady = () => {
setPwaUpdateReady(true)
}
window.addEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady)
return () => {
window.removeEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady)
}
}, [])
const resetScoring = (nextState: ScoreState = initialScoreState) => {
setScoreState(nextState)
setScoreHistory([])
@@ -215,6 +229,21 @@ function App() {
})
}
const refreshForPwaUpdate = () => {
const registrationPromise = navigator.serviceWorker?.getRegistration
? navigator.serviceWorker.getRegistration()
: Promise.resolve(undefined)
void registrationPromise.then((registration) => {
if (registration?.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
return
}
window.location.reload()
})
}
const loadGroupsFromDb = async () => {
if (!targetDate) {
setLoadStatus('error')
@@ -601,6 +630,18 @@ function App() {
/>
<Route path="/history" element={<HistoryPage />} />
</Routes>
{pwaUpdateReady ? (
<div className="pwa-update-toast" role="status" aria-live="polite">
<div className="pwa-update-copy">
<strong></strong>
<span></span>
</div>
<button className="pwa-update-button" onClick={refreshForPwaUpdate} type="button">
</button>
</div>
) : null}
</div>
)
}

View File

@@ -4,6 +4,8 @@ import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
@@ -11,3 +13,45 @@ createRoot(document.getElementById('root')!).render(
</BrowserRouter>
</StrictMode>,
)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
let refreshing = false
const notifyUpdateReady = () => {
window.dispatchEvent(new CustomEvent(PWA_UPDATE_EVENT))
}
const trackWorker = (worker: ServiceWorker | null) => {
if (!worker) {
return
}
worker.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
notifyUpdateReady()
}
})
}
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return
}
refreshing = true
window.location.reload()
})
void navigator.serviceWorker.register('/sw.js').then((registration) => {
if (registration.waiting) {
notifyUpdateReady()
}
trackWorker(registration.installing)
registration.addEventListener('updatefound', () => {
trackWorker(registration.installing)
})
})
})
}

View File

@@ -29,6 +29,9 @@ const defaultVoiceSettings: VoiceSettings = {
announceServer: true,
rate: 1,
}
const SPEECH_NAME_MAP: Record<string, string> = {
ruru: '嚕嚕',
}
type ScoreboardPageProps = {
currentSelectionOrder: string[]
@@ -252,7 +255,7 @@ export function ScoreboardPage({
}
if (voiceSettings.announceServer) {
parts.push(`${currentServer.name}發球`)
parts.push(`${getSpeechName(currentServer.name)}發球`)
}
if (parts.length > 0) {
@@ -1061,7 +1064,11 @@ function loadVoiceSettings(): VoiceSettings {
}
function getAnnouncementName(team: GroupTeam | null) {
return team?.playerA ?? '本隊'
return getSpeechName(team?.playerA ?? '本隊')
}
function getSpeechName(name: string) {
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
}
function speakAnnouncement(message: string, rate: number) {