From e4e3e6a735c1a1a485d87a979b80c1ebe5803b6f Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 11 Aug 2026 00:58:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A4=A7=E5=B1=8F?= =?UTF-8?q?=20APK=20=E5=8E=9F=E7=94=9F=E6=92=AD=E6=8A=A5=E5=8F=8A=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 具体项目页由 APK 原生轮询并播报三次,总览页不播报。总览项目卡片通过项目编码进入对应大屏。 --- android/README.md | 18 +- android/app/build.gradle | 4 +- android/app/src/main/AndroidManifest.xml | 6 + .../display/CallAnnouncementTracker.java | 26 +++ .../display/DisplayAnnouncementPoller.java | 202 ++++++++++++++++++ .../cn/nianxx/queue/display/MainActivity.java | 36 +++- .../queue/display/NavigationPolicy.java | 9 + .../queue/display/QueueAnnouncement.java | 23 ++ .../display/QueueAnnouncementPlayer.java | 152 +++++++++++++ .../nianxx/queue/display/QueueCallText.java | 43 ++++ android/app/src/main/res/values/strings.xml | 1 + .../display/CallAnnouncementTrackerTest.java | 44 ++++ .../queue/display/NavigationPolicyTest.java | 10 + .../queue/display/QueueAnnouncementTest.java | 27 +++ .../queue/display/QueueCallTextTest.java | 29 +++ server/internal/httpapi/public.go | 1 + server/internal/httpapi/public_test.go | 4 +- web/src/components/ProjectScreenTile.tsx | 29 ++- web/src/hooks/useSpeechAnnouncements.test.ts | 43 ---- web/src/hooks/useSpeechAnnouncements.ts | 33 --- web/src/lib/format.ts | 22 -- web/src/pages/AdminPage.tsx | 16 +- web/src/pages/DisplayPage.test.tsx | 35 +-- web/src/pages/DisplayPage.tsx | 23 +- .../pages/PublicDisplayCenterPage.test.tsx | 57 ++--- web/src/pages/PublicDisplayCenterPage.tsx | 31 +-- web/src/styles.css | 40 ++++ 27 files changed, 728 insertions(+), 236 deletions(-) create mode 100644 android/app/src/main/java/cn/nianxx/queue/display/CallAnnouncementTracker.java create mode 100644 android/app/src/main/java/cn/nianxx/queue/display/DisplayAnnouncementPoller.java create mode 100644 android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java create mode 100644 android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java create mode 100644 android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java create mode 100644 android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java create mode 100644 android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java create mode 100644 android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java delete mode 100644 web/src/hooks/useSpeechAnnouncements.test.ts delete mode 100644 web/src/hooks/useSpeechAnnouncements.ts diff --git a/android/README.md b/android/README.md index eeaccfd..1d0e163 100644 --- a/android/README.md +++ b/android/README.md @@ -2,19 +2,27 @@ 这是景区排队叫号系统的原生 Android 薄壳,只承载两个公开页面: -- 项目列表:`https://queue.nianxx.cn/admin/display` -- 单项目展示:`https://queue.nianxx.cn/display/{项目编码或公示 Token}` +- 总览页面:`https://queue.nianxx.cn/admin/display` +- 单项目页面:`https://queue.nianxx.cn/display/{项目编号或公示 Token}` ## 现场能力 - 无浏览器地址栏的沉浸式展示,并保持屏幕常亮 +- 总览页面只展示项目状态,不进行语音播报 +- 单项目页面由 APK 每 3 秒读取公开快照接口,发现新叫号批次后调用系统中文 TTS +- 每次叫号由 Android 原生层连续播报三次,并使用系统媒体音量通道 - 接管项目卡的网页全屏请求,返回键退出全屏 -- 允许叫号音频无需额外手势播放 - 支持上述两个 HTTPS 页面作为 Android 深链 - 仅允许 `queue.nianxx.cn` 的公开公示路径,阻止后台、员工端、外域和明文 HTTP 导航 -- 网络或证书异常时显示原生重试页 +- 网络、证书或中文语音引擎异常时显示原生提示 -APK 仍依赖线上服务和 Android System WebView,不会把实时队列数据离线复制进安装包。 +APK 仍依赖线上服务和 Android System WebView,不会把实时队列数据离线复制进安装包。叫号检测和播报都由 APK 原生层负责,不依赖线上网页是否包含语音代码;普通浏览器访问单项目页面时不会播报。 + +## 设备要求 + +- 系统已安装并启用支持简体中文的文字转语音引擎 +- 设备媒体音量已打开 +- Android System WebView 可以访问 `https://queue.nianxx.cn` ## 构建 diff --git a/android/app/build.gradle b/android/app/build.gradle index 7098563..6af8941 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId = "cn.nianxx.queue.display" minSdk = 23 targetSdk = 36 - versionCode = 1 - versionName = "1.0.0" + versionCode = 4 + versionName = "1.2.0" } buildTypes { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a6fab64..239727a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,12 @@ + + + + + + new Thread(runnable, "queue-announcement-poller")); + private final CallAnnouncementTracker tracker = new CallAnnouncementTracker(); + private ScheduledFuture pollingTask; + private String activeSnapshotUrl; + private long generation; + private boolean closed; + private boolean initialSnapshotLogged; + + DisplayAnnouncementPoller(Listener listener) { + this.listener = listener; + } + + synchronized void start(String snapshotUrl) { + if (closed || snapshotUrl == null) { + return; + } + if (Objects.equals(activeSnapshotUrl, snapshotUrl) + && pollingTask != null + && !pollingTask.isCancelled()) { + return; + } + stopLocked(); + activeSnapshotUrl = snapshotUrl; + tracker.reset(); + long taskGeneration = ++generation; + pollingTask = executor.scheduleWithFixedDelay( + () -> poll(snapshotUrl, taskGeneration), + 0, + POLL_INTERVAL_SECONDS, + TimeUnit.SECONDS); + Log.i(TAG, "Project announcement polling started"); + } + + synchronized void stop() { + stopLocked(); + } + + synchronized void close() { + if (closed) { + return; + } + closed = true; + stopLocked(); + executor.shutdownNow(); + mainHandler.removeCallbacksAndMessages(null); + } + + private void poll(String snapshotUrl, long taskGeneration) { + try { + CallSnapshot snapshot = fetchSnapshot(snapshotUrl); + String announcement; + boolean firstSnapshot; + synchronized (this) { + if (!isActive(snapshotUrl, taskGeneration)) { + return; + } + firstSnapshot = !initialSnapshotLogged; + initialSnapshotLogged = true; + announcement = tracker.accept(snapshot.callKey, snapshot.announcement); + } + if (firstSnapshot) { + Log.i(TAG, "Initial project snapshot received; current call present=" + + (snapshot.callKey != null)); + } + if (announcement == null) { + return; + } + mainHandler.post(() -> { + synchronized (DisplayAnnouncementPoller.this) { + if (!isActive(snapshotUrl, taskGeneration)) { + return; + } + } + Log.i(TAG, "New call batch detected; sending announcement to Android TTS"); + listener.onAnnouncement(announcement); + }); + } catch (Exception error) { + synchronized (this) { + if (!isActive(snapshotUrl, taskGeneration)) { + return; + } + } + Log.w(TAG, "Snapshot polling failed: " + error.getClass().getSimpleName()); + } + } + + private synchronized boolean isActive(String snapshotUrl, long taskGeneration) { + return !closed + && generation == taskGeneration + && Objects.equals(activeSnapshotUrl, snapshotUrl); + } + + private void stopLocked() { + generation += 1; + activeSnapshotUrl = null; + initialSnapshotLogged = false; + tracker.reset(); + if (pollingTask != null) { + pollingTask.cancel(true); + pollingTask = null; + } + } + + private static CallSnapshot fetchSnapshot(String snapshotUrl) throws IOException, JSONException { + HttpURLConnection connection = (HttpURLConnection) new URL(snapshotUrl).openConnection(); + try { + connection.setConnectTimeout(NETWORK_TIMEOUT_MILLIS); + connection.setReadTimeout(NETWORK_TIMEOUT_MILLIS); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("User-Agent", "XQKQueueDisplay/1.2"); + connection.setUseCaches(false); + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + throw new IOException("Unexpected HTTP status " + status); + } + StringBuilder body = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + connection.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + body.append(line); + } + } + return parseSnapshot(body.toString()); + } finally { + connection.disconnect(); + } + } + + private static CallSnapshot parseSnapshot(String body) throws JSONException { + JSONObject batch = new JSONObject(body).optJSONObject("current_batch"); + if (batch == null) { + return new CallSnapshot(null, null); + } + int batchNumber = batch.optInt("batch_number", batch.optInt("sequence", -1)); + String calledAt = batch.optString("called_at", "").trim(); + String callKey = batchNumber + ":" + calledAt; + JSONArray tickets = batch.optJSONArray("tickets"); + List numbers = new ArrayList<>(); + if (tickets != null) { + for (int index = 0; index < tickets.length(); index += 1) { + JSONObject ticket = tickets.optJSONObject(index); + if (ticket == null) { + continue; + } + String number = ticket.optString("display_number", "").trim(); + if (number.isEmpty()) { + number = ticket.optString("ticket_number", "").trim(); + } + numbers.add(number); + } + } + return new CallSnapshot(callKey, QueueCallText.format(numbers)); + } + + private static final class CallSnapshot { + final String callKey; + final String announcement; + + CallSnapshot(String callKey, String announcement) { + this.callKey = callKey; + this.announcement = announcement; + } + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java b/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java index 66e4405..6ca6318 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/MainActivity.java @@ -34,6 +34,8 @@ import android.widget.Toast; public final class MainActivity extends Activity { private FrameLayout root; private WebView webView; + private QueueAnnouncementPlayer announcementPlayer; + private DisplayAnnouncementPoller announcementPoller; private ProgressBar progressBar; private LinearLayout errorView; private TextView errorDetail; @@ -47,11 +49,20 @@ public final class MainActivity extends Activity { super.onCreate(savedInstanceState); configureWindow(); buildContentView(); + announcementPlayer = new QueueAnnouncementPlayer( + this, + () -> runOnUiThread(() -> Toast.makeText( + this, + R.string.speech_unavailable, + Toast.LENGTH_LONG).show())); + announcementPoller = new DisplayAnnouncementPoller(announcementPlayer::announce); setContentView(root); + enterImmersiveMode(); registerPredictiveBackCallback(); String initialUrl = NavigationPolicy.initialUrlOrStart(getIntent().getDataString()); lastAllowedUrl = initialUrl; + updateAnnouncementPolling(); webView.loadUrl(initialUrl); } @@ -60,6 +71,8 @@ public final class MainActivity extends Activity { super.onNewIntent(intent); setIntent(intent); String target = NavigationPolicy.initialUrlOrStart(intent.getDataString()); + lastAllowedUrl = target; + updateAnnouncementPolling(); exitWebFullscreen(); if (!target.equals(webView.getUrl())) { webView.loadUrl(target); @@ -71,10 +84,12 @@ public final class MainActivity extends Activity { super.onResume(); webView.onResume(); enterImmersiveMode(); + updateAnnouncementPolling(); } @Override protected void onPause() { + announcementPoller.stop(); webView.onPause(); super.onPause(); } @@ -104,6 +119,8 @@ public final class MainActivity extends Activity { return; } if (NavigationPolicy.isProjectDisplay(lastAllowedUrl)) { + lastAllowedUrl = NavigationPolicy.START_URL; + updateAnnouncementPolling(); webView.loadUrl(NavigationPolicy.START_URL); return; } @@ -120,6 +137,8 @@ public final class MainActivity extends Activity { @Override protected void onDestroy() { + announcementPoller.close(); + announcementPlayer.shutdown(); if (customView != null) { root.removeView(customView); customView = null; @@ -139,7 +158,6 @@ public final class MainActivity extends Activity { private void configureWindow() { getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - enterImmersiveMode(); } @SuppressWarnings("deprecation") @@ -205,7 +223,7 @@ public final class MainActivity extends Activity { settings.setGeolocationEnabled(false); settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); settings.setCacheMode(WebSettings.LOAD_DEFAULT); - settings.setUserAgentString(settings.getUserAgentString() + " XQKQueueDisplay/1.0"); + settings.setUserAgentString(settings.getUserAgentString() + " XQKQueueDisplay/1.2.0"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { settings.setSafeBrowsingEnabled(true); } @@ -299,6 +317,15 @@ public final class MainActivity extends Activity { return Math.round(value * getResources().getDisplayMetrics().density); } + private void updateAnnouncementPolling() { + String snapshotUrl = NavigationPolicy.snapshotUrl(lastAllowedUrl); + if (snapshotUrl == null) { + announcementPoller.stop(); + } else { + announcementPoller.start(snapshotUrl); + } + } + private final class DisplayWebViewClient extends WebViewClient { @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { @@ -326,12 +353,17 @@ public final class MainActivity extends Activity { public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { if (NavigationPolicy.isAllowed(url)) { lastAllowedUrl = url; + updateAnnouncementPolling(); } showLoading(); } @Override public void onPageFinished(WebView view, String url) { + if (NavigationPolicy.isAllowed(url)) { + lastAllowedUrl = url; + updateAnnouncementPolling(); + } progressBar.setVisibility(View.GONE); if (!mainFrameFailed) { errorView.setVisibility(View.GONE); diff --git a/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java b/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java index 463dde0..2a4db2e 100644 --- a/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java +++ b/android/app/src/main/java/cn/nianxx/queue/display/NavigationPolicy.java @@ -48,6 +48,15 @@ final class NavigationPolicy { && uri.getRawPath().startsWith(DISPLAY_PREFIX); } + static String snapshotUrl(String candidate) { + URI uri = parse(candidate); + if (!isProjectDisplay(candidate) || uri == null) { + return null; + } + String identifier = uri.getRawPath().substring(DISPLAY_PREFIX.length()); + return "https://" + HOST + "/api/display/" + identifier + "/snapshot"; + } + private static URI parse(String candidate) { if (candidate == null || candidate.isBlank()) { return null; diff --git a/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java new file mode 100644 index 0000000..163cfde --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncement.java @@ -0,0 +1,23 @@ +package cn.nianxx.queue.display; + +import java.util.Collections; +import java.util.List; + +final class QueueAnnouncement { + private static final int REPEAT_COUNT = 3; + private static final int MAX_TEXT_LENGTH = 200; + + private QueueAnnouncement() { + } + + static List utterances(String value) { + if (value == null) { + return Collections.emptyList(); + } + String text = value.trim(); + if (text.isEmpty() || text.length() > MAX_TEXT_LENGTH) { + return Collections.emptyList(); + } + return Collections.nCopies(REPEAT_COUNT, text); + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java new file mode 100644 index 0000000..d055e52 --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/QueueAnnouncementPlayer.java @@ -0,0 +1,152 @@ +package cn.nianxx.queue.display; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; +import android.util.Log; + +import java.util.List; +import java.util.Locale; + +final class QueueAnnouncementPlayer { + private static final String TAG = "XQKQueueDisplay"; + + interface Listener { + void onSpeechUnavailable(); + } + + private final Context context; + private final Listener listener; + private TextToSpeech engine; + private boolean ready; + private String pendingText; + private long utteranceSequence; + + QueueAnnouncementPlayer(Context context, Listener listener) { + this.context = context.getApplicationContext(); + this.listener = listener; + } + + void announce(String value) { + List utterances = QueueAnnouncement.utterances(value); + if (utterances.isEmpty()) { + return; + } + String text = utterances.get(0); + if (ready) { + speakNow(text); + return; + } + pendingText = text; + if (engine == null) { + Log.i(TAG, "Initializing Android TTS"); + engine = new TextToSpeech(context, this::onInitialized); + } + } + + void shutdown() { + pendingText = null; + ready = false; + if (engine == null) { + return; + } + engine.stop(); + engine.shutdown(); + engine = null; + } + + private void onInitialized(int status) { + TextToSpeech currentEngine = engine; + if (currentEngine == null) { + return; + } + if (status != TextToSpeech.SUCCESS) { + fail("initialization failed with status " + status); + return; + } + int language = currentEngine.setLanguage(Locale.SIMPLIFIED_CHINESE); + if (language == TextToSpeech.LANG_MISSING_DATA + || language == TextToSpeech.LANG_NOT_SUPPORTED) { + fail("Simplified Chinese is unavailable with status " + language); + return; + } + currentEngine.setSpeechRate(0.9f); + currentEngine.setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()); + currentEngine.setOnUtteranceProgressListener(new UtteranceProgressListener() { + @Override + public void onStart(String utteranceId) { + Log.i(TAG, "TTS utterance started"); + } + + @Override + public void onDone(String utteranceId) { + Log.i(TAG, "TTS utterance completed"); + } + + @Override + public void onError(String utteranceId) { + Log.e(TAG, "TTS utterance failed"); + } + + @Override + public void onError(String utteranceId, int errorCode) { + Log.e(TAG, "TTS utterance failed with code " + errorCode); + } + }); + ready = true; + Log.i(TAG, "Android TTS ready for Simplified Chinese"); + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (audioManager != null) { + Log.i(TAG, "Media volume is " + + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + + " of " + + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)); + } + String text = pendingText; + pendingText = null; + if (text != null) { + speakNow(text); + } + } + + private void speakNow(String text) { + TextToSpeech currentEngine = engine; + if (!ready || currentEngine == null) { + return; + } + currentEngine.stop(); + List utterances = QueueAnnouncement.utterances(text); + int acceptedCount = 0; + for (String utterance : utterances) { + String utteranceId = "queue-call-" + (++utteranceSequence); + if (currentEngine.speak( + utterance, + TextToSpeech.QUEUE_ADD, + null, + utteranceId) == TextToSpeech.SUCCESS) { + acceptedCount += 1; + } + } + Log.i(TAG, "Queued " + acceptedCount + " of " + utterances.size() + " TTS utterances"); + if (acceptedCount == 0) { + listener.onSpeechUnavailable(); + } + } + + private void fail(String reason) { + Log.e(TAG, "Android TTS unavailable: " + reason); + TextToSpeech failedEngine = engine; + engine = null; + ready = false; + pendingText = null; + if (failedEngine != null) { + failedEngine.shutdown(); + } + listener.onSpeechUnavailable(); + } +} diff --git a/android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java b/android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java new file mode 100644 index 0000000..d63bffd --- /dev/null +++ b/android/app/src/main/java/cn/nianxx/queue/display/QueueCallText.java @@ -0,0 +1,43 @@ +package cn.nianxx.queue.display; + +import java.util.ArrayList; +import java.util.List; + +final class QueueCallText { + private static final String[] SPOKEN_DIGITS = { + "零", "一", "二", "三", "四", "五", "六", "七", "八", "九" + }; + + private QueueCallText() { + } + + static String format(List values) { + List numbers = new ArrayList<>(); + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + numbers.add(value.trim()); + } + } + if (numbers.isEmpty()) { + return null; + } + String first = spokenNumber(numbers.get(0)); + String calledNumbers = numbers.size() == 1 + ? first + "号" + : first + "号至" + spokenNumber(numbers.get(numbers.size() - 1)) + "号"; + return "请" + calledNumbers + ",前往入口。"; + } + + private static String spokenNumber(String value) { + StringBuilder spoken = new StringBuilder(); + for (int index = 0; index < value.length(); index += 1) { + char character = value.charAt(index); + if (character >= '0' && character <= '9') { + spoken.append(SPOKEN_DIGITS[character - '0']); + } else { + spoken.append(character); + } + } + return spoken.toString(); + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3510872..4dc3fd0 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,4 +6,5 @@ 重新加载 已阻止离开公示页面 安全连接校验失败,请联系管理员 + 未检测到可用的中文语音引擎,请在系统设置中启用中文文字转语音 diff --git a/android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java b/android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java new file mode 100644 index 0000000..4a47e42 --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/CallAnnouncementTrackerTest.java @@ -0,0 +1,44 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public final class CallAnnouncementTrackerTest { + @Test + public void establishesBaselineThenReturnsOnlyNewAnnouncements() { + CallAnnouncementTracker tracker = new CallAnnouncementTracker(); + + assertNull(tracker.accept("1:2026-08-11T00:00:00Z", "请零零零一号,前往入口。")); + assertNull(tracker.accept("1:2026-08-11T00:00:00Z", "请零零零一号,前往入口。")); + assertEquals( + "请零零零二号,前往入口。", + tracker.accept("2:2026-08-11T00:01:00Z", "请零零零二号,前往入口。")); + } + + @Test + public void acceptsNextBatchAfterCurrentCallClears() { + CallAnnouncementTracker tracker = new CallAnnouncementTracker(); + + assertNull(tracker.accept(null, null)); + assertEquals( + "请零零零一号,前往入口。", + tracker.accept("1:2026-08-11T00:00:00Z", "请零零零一号,前往入口。")); + assertNull(tracker.accept(null, null)); + assertEquals( + "请零零零二号,前往入口。", + tracker.accept("2:2026-08-11T00:01:00Z", "请零零零二号,前往入口。")); + } + + @Test + public void resetRequiresANewBaseline() { + CallAnnouncementTracker tracker = new CallAnnouncementTracker(); + assertNull(tracker.accept("1:first", "first")); + assertEquals("second", tracker.accept("2:second", "second")); + + tracker.reset(); + + assertNull(tracker.accept("3:third", "third")); + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java b/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java index 33a1ae4..193aac9 100644 --- a/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java +++ b/android/app/src/test/java/cn/nianxx/queue/display/NavigationPolicyTest.java @@ -2,6 +2,7 @@ package cn.nianxx.queue.display; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -47,4 +48,13 @@ public final class NavigationPolicyTest { assertTrue(NavigationPolicy.isProjectDisplay("https://queue.nianxx.cn/display/YYHHC")); assertFalse(NavigationPolicy.isProjectDisplay(NavigationPolicy.START_URL)); } + + @Test + public void createsSnapshotUrlOnlyForDirectProjectDisplays() { + assertEquals( + "https://queue.nianxx.cn/api/display/YYHHC/snapshot", + NavigationPolicy.snapshotUrl("https://queue.nianxx.cn/display/YYHHC")); + assertNull(NavigationPolicy.snapshotUrl(NavigationPolicy.START_URL)); + assertNull(NavigationPolicy.snapshotUrl("https://example.com/display/YYHHC")); + } } diff --git a/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java b/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java new file mode 100644 index 0000000..b225d9f --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/QueueAnnouncementTest.java @@ -0,0 +1,27 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +public final class QueueAnnouncementTest { + @Test + public void repeatsValidAnnouncementThreeTimes() { + List utterances = QueueAnnouncement.utterances(" 请零零零一六号,前往入口。 "); + + assertEquals(3, utterances.size()); + assertEquals("请零零零一六号,前往入口。", utterances.get(0)); + assertEquals(utterances.get(0), utterances.get(1)); + assertEquals(utterances.get(0), utterances.get(2)); + } + + @Test + public void ignoresMissingOrUnreasonablyLongText() { + assertTrue(QueueAnnouncement.utterances(null).isEmpty()); + assertTrue(QueueAnnouncement.utterances(" ").isEmpty()); + assertTrue(QueueAnnouncement.utterances("1".repeat(201)).isEmpty()); + } +} diff --git a/android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java b/android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java new file mode 100644 index 0000000..6f38ebb --- /dev/null +++ b/android/app/src/test/java/cn/nianxx/queue/display/QueueCallTextTest.java @@ -0,0 +1,29 @@ +package cn.nianxx.queue.display; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.util.List; + +public final class QueueCallTextTest { + @Test + public void formatsOneNumberDigitByDigit() { + assertEquals( + "请零零零一六号,前往入口。", + QueueCallText.format(List.of("00016"))); + } + + @Test + public void formatsMultipleNumbersAsRange() { + assertEquals( + "请零零零一六号至零零零一八号,前往入口。", + QueueCallText.format(List.of("00016", "00017", "00018"))); + } + + @Test + public void ignoresMissingNumbers() { + assertNull(QueueCallText.format(List.of("", " "))); + } +} diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index 2cf1836..da2a954 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -482,6 +482,7 @@ func newDisplayBatchDTO(batch model.CallBatch, tickets []displayTicketDTO) displ func publicDisplayProjectView(project map[string]any) map[string]any { return map[string]any{ "id": project["id"], + "code": project["code"], "name": project["name"], "status": project["status"], "waiting_count": project["waiting_count"], diff --git a/server/internal/httpapi/public_test.go b/server/internal/httpapi/public_test.go index 04bb391..35159bb 100644 --- a/server/internal/httpapi/public_test.go +++ b/server/internal/httpapi/public_test.go @@ -33,7 +33,7 @@ func TestDisplayBatchDTOCannotSerializePersonalFields(t *testing.T) { func TestPublicDisplayProjectViewOnlySerializesDisplayFields(t *testing.T) { view := publicDisplayProjectView(map[string]any{ - "id": "project-1", "name": "东门观光车", "status": model.ProjectRunning, + "id": "project-1", "code": "YYHHC", "name": "东门观光车", "status": model.ProjectRunning, "waiting_count": 3, "waiting_ticket_count": 3, "waiting_people_count": 7, "issued_ticket_count": 15, "latest_ticket_number": "00015", "experienced_people": 12, "current_batch": map[string]any{"tickets": []map[string]any{{"ticket_number": "00013"}}}, @@ -51,7 +51,7 @@ func TestPublicDisplayProjectViewOnlySerializesDisplayFields(t *testing.T) { t.Fatalf("public display overview leaked forbidden field %q: %s", forbidden, encoded) } } - for _, required := range []string{`"name":"东门观光车"`, `"ticket_number":"00013"`, `"waiting_people_count":7`} { + for _, required := range []string{`"code":"YYHHC"`, `"name":"东门观光车"`, `"ticket_number":"00013"`, `"waiting_people_count":7`} { if !strings.Contains(encoded, required) { t.Fatalf("public display overview missing field %q: %s", required, encoded) } diff --git a/web/src/components/ProjectScreenTile.tsx b/web/src/components/ProjectScreenTile.tsx index 52d1ce5..960b080 100644 --- a/web/src/components/ProjectScreenTile.tsx +++ b/web/src/components/ProjectScreenTile.tsx @@ -5,10 +5,11 @@ import type { AdminProjectDto, CallBatchDto } from "../types"; interface ProjectScreenTileProps { project: AdminProjectDto; standalone?: boolean; + displayHref?: string; onFullscreen?: (projectId: string) => Promise; } -export function ProjectScreenTile({ project, standalone = false, onFullscreen }: ProjectScreenTileProps) { +export function ProjectScreenTile({ project, standalone = false, displayHref, onFullscreen }: ProjectScreenTileProps) { const current = project.current_batch && typeof project.current_batch === "object" ? project.current_batch as CallBatchDto : null; @@ -50,8 +51,9 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }: hour12: false, }).format(now); - return ( -
+ const className = `screen-tile${standalone ? " screen-tile--standalone" : ""}${displayHref ? " screen-tile--link" : ""}`; + const content = ( + <>
@@ -63,7 +65,9 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }:

{project.name}

- {!standalone && onFullscreen ? ( + {displayHref ? ( + + ) : !standalone && onFullscreen ? ( ) : null}
@@ -83,6 +87,21 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }:
    {visible.map((item) =>
  1. {item.range}{item.wait}
  2. )}
) :

后续区间暂不可估算

} -
+ ); + + if (displayHref) { + return ( + + {content} + + ); + } + + return
{content}
; } diff --git a/web/src/hooks/useSpeechAnnouncements.test.ts b/web/src/hooks/useSpeechAnnouncements.test.ts deleted file mode 100644 index ce31a0f..0000000 --- a/web/src/hooks/useSpeechAnnouncements.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useSpeechAnnouncements } from "./useSpeechAnnouncements"; - -class SpeechSynthesisUtteranceMock { - lang = ""; - rate = 1; - - constructor(public text: string) {} -} - -describe("useSpeechAnnouncements", () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("把叫号文本用中文语音连续播报三次", () => { - const speak = vi.fn(); - const cancel = vi.fn(); - vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock); - vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() }); - const { result } = renderHook(() => useSpeechAnnouncements()); - - act(() => result.current.announce(["请零零零一六号,前往入口。"])); - - expect(cancel).toHaveBeenCalledOnce(); - expect(speak).toHaveBeenCalledTimes(3); - for (const [utterance] of speak.mock.calls) { - expect(utterance).toMatchObject({ - text: "请零零零一六号,前往入口。", - lang: "zh-CN", - rate: 0.9, - }); - } - }); - - it("浏览器不支持语音合成时安全跳过", () => { - const { result } = renderHook(() => useSpeechAnnouncements()); - - expect(() => result.current.announce(["请一号,前往入口。"])).not.toThrow(); - }); -}); diff --git a/web/src/hooks/useSpeechAnnouncements.ts b/web/src/hooks/useSpeechAnnouncements.ts deleted file mode 100644 index 474eab9..0000000 --- a/web/src/hooks/useSpeechAnnouncements.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useCallback } from "react"; - -const ANNOUNCEMENT_REPEAT_COUNT = 3; - -function speechSupported(): boolean { - return typeof window !== "undefined" - && typeof window.speechSynthesis?.speak === "function" - && typeof SpeechSynthesisUtterance !== "undefined"; -} - -function createUtterance(text: string): SpeechSynthesisUtterance { - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = "zh-CN"; - utterance.rate = 0.9; - return utterance; -} - -export function useSpeechAnnouncements() { - const announce = useCallback((announcements: string[]) => { - if (!speechSupported() || !announcements.length) return; - - const synthesis = window.speechSynthesis; - synthesis.cancel(); - if (synthesis.paused) synthesis.resume(); - for (const announcement of announcements) { - for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) { - synthesis.speak(createUtterance(announcement)); - } - } - }, []); - - return { announce }; -} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index e671ce2..ec0a269 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,18 +1,6 @@ import type { CallBatchDto, ProjectStatus, TicketStatus } from "../types"; const numberFormatter = new Intl.NumberFormat("zh-CN"); -const SPOKEN_DIGITS: Record = { - "0": "零", - "1": "一", - "2": "二", - "3": "三", - "4": "四", - "5": "五", - "6": "六", - "7": "七", - "8": "八", - "9": "九", -}; export function formatNumber(value: unknown, fallback = "暂无"): string { const number = typeof value === "number" ? value : Number(value); @@ -119,16 +107,6 @@ export function formatTicketNumberRange(numbers: Array ticket.ticket_number.trim()).filter(Boolean) ?? []; - if (!numbers.length) return null; - const spokenNumber = (number: string) => Array.from(number, (character) => SPOKEN_DIGITS[character] ?? character).join(""); - const calledNumbers = numbers.length === 1 - ? `${spokenNumber(numbers[0])}号` - : `${spokenNumber(numbers[0])}号至${spokenNumber(numbers[numbers.length - 1])}号`; - return `请${calledNumbers},前往入口。`; -} - export function isTimestampStale(value?: string | null, thresholdMs = 30_000, now = Date.now()): boolean { if (!value) return true; const timestamp = new Date(value).getTime(); diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index f2119df..0a88298 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -344,7 +344,12 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; ; } -export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) { +interface DisplayCenterProps { + projects: AdminProjectDto[]; + projectHref?: (project: AdminProjectDto) => string | undefined; +} + +export function DisplayCenter({ projects, projectHref }: DisplayCenterProps) { const [fullscreenError, setFullscreenError] = useState(false); const enterFullscreen = async (projectId: string) => { const tile = document.querySelector(`[data-project-screen="${projectId}"]`); @@ -365,7 +370,14 @@ export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) { {fullscreenError ? 可继续在当前页面查看实时状态。 : null} {projects.length ? (
- {projects.map((project) => )} + {projects.map((project) => ( + + ))}
) : } diff --git a/web/src/pages/DisplayPage.test.tsx b/web/src/pages/DisplayPage.test.tsx index 539db6c..e2664c1 100644 --- a/web/src/pages/DisplayPage.test.tsx +++ b/web/src/pages/DisplayPage.test.tsx @@ -94,43 +94,12 @@ describe("DisplayPage", () => { ]); }); - it("新批次出现时用前端中文语音连续播报三次叫号内容", () => { - const speak = vi.fn(); - const cancel = vi.fn(); - class SpeechSynthesisUtteranceMock { - lang = ""; - rate = 1; - - constructor(public text: string) {} - } - vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock); - vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() }); - const { container, rerender } = render(); + it("网页端不包含叫号声音控件或音频播放器", () => { + const { container } = render(); expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument(); expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument(); expect(container.querySelector("audio")).toBeNull(); - pollingResource.data.current_batch = { - batch_number: 3, - status: "CALLED", - called_at: "2026-07-10T09:05:00Z", - tickets: [ - { ticket_number: "00005", status: "CALLED", party_size: 2 }, - { ticket_number: "00006", status: "CALLED", party_size: 1 }, - ], - }; - rerender(); - rerender(); - - expect(cancel).toHaveBeenCalledOnce(); - expect(speak).toHaveBeenCalledTimes(3); - for (const [utterance] of speak.mock.calls) { - expect(utterance).toMatchObject({ - text: "请零零零零五号至零零零零六号,前往入口。", - lang: "zh-CN", - rate: 0.9, - }); - } }); }); diff --git a/web/src/pages/DisplayPage.tsx b/web/src/pages/DisplayPage.tsx index d61b76e..2c26d44 100644 --- a/web/src/pages/DisplayPage.tsx +++ b/web/src/pages/DisplayPage.tsx @@ -1,17 +1,14 @@ -import { useEffect, useRef } from "react"; import { useParams } from "react-router-dom"; import { api } from "../api"; import { ProjectScreenTile } from "../components/ProjectScreenTile"; import { usePollingResource } from "../hooks/usePollingResource"; -import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements"; -import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format"; +import { formatDateTime, isTimestampStale } from "../lib/format"; import type { AdminProjectDto } from "../types"; export { forecastRows } from "../lib/display"; export function DisplayPage() { const { token = "" } = useParams(); - const { announce } = useSpeechAnnouncements(); const resource = usePollingResource((signal) => api.display(token, signal), { enabled: Boolean(token), intervalMs: 3_000, @@ -20,24 +17,6 @@ export function DisplayPage() { const data = resource.data; const freshnessTime = resource.lastClientSuccessAt; const stale = Boolean(data) && isTimestampStale(freshnessTime, 15_000); - const current = data?.current_batch; - const currentCallKey = current ? `${current.batch_number}:${current.called_at ?? ""}` : null; - const currentAnnouncement = formatCallAnnouncement(current); - const hasSnapshot = Boolean(data); - const lastCallRef = useRef<{ token: string; key: string | null } | null>(null); - useEffect(() => { - if (!hasSnapshot) return; - const previous = lastCallRef.current; - lastCallRef.current = { token, key: currentCallKey }; - if ( - !previous - || previous.token !== token - || previous.key === currentCallKey - || !currentAnnouncement - ) return; - - announce([currentAnnouncement]); - }, [announce, currentAnnouncement, currentCallKey, hasSnapshot, token]); if (resource.loading && !data) { return
正在连接叫号服务
; } diff --git a/web/src/pages/PublicDisplayCenterPage.test.tsx b/web/src/pages/PublicDisplayCenterPage.test.tsx index 51ed480..5d383cb 100644 --- a/web/src/pages/PublicDisplayCenterPage.test.tsx +++ b/web/src/pages/PublicDisplayCenterPage.test.tsx @@ -7,6 +7,7 @@ const { displayOverview, pollingResource } = vi.hoisted(() => ({ data: { projects: [{ id: "project-1", + code: "YYHHC", name: "东门观光车", status: "RUNNING", waiting_count: 1, @@ -43,7 +44,7 @@ describe("PublicDisplayCenterPage", () => { beforeEach(() => { pollingResource.data = { ...pollingResource.data, - projects: [{ ...pollingResource.data.projects[0], current_batch: null }], + projects: [{ ...pollingResource.data.projects[0], code: "YYHHC", current_batch: null }], }; }); @@ -58,54 +59,30 @@ describe("PublicDisplayCenterPage", () => { expect(displayOverview).toHaveBeenCalledOnce(); expect(screen.getByRole("region", { name: "项目实时监控墙" })).toBeVisible(); expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible(); + expect(screen.getByRole("link", { name: "进入东门观光车项目大屏" })).toHaveAttribute("href", "/display/YYHHC"); + expect(screen.getByText("进入项目大屏")).toBeVisible(); expect(screen.queryByRole("button", { name: "退出" })).not.toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument(); }); - it("新叫号批次出现时用前端中文语音连续三次播报号码", () => { - const speak = vi.fn(); - const cancel = vi.fn(); - class SpeechSynthesisUtteranceMock { - lang = ""; - rate = 1; - - constructor(public text: string) {} - } - vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock); - vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() }); - const { container, rerender } = render(); + it("总览页不包含叫号声音控件或音频播放器", () => { + const { container } = render(); expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument(); expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument(); expect(container.querySelector("audio")).toBeNull(); - pollingResource.data = { - ...pollingResource.data, - projects: [{ - ...pollingResource.data.projects[0], - current_batch: { - batch_number: 3, - status: "CALLED", - called_at: "2026-07-31T08:01:00Z", - tickets: [{ ticket_number: "00016", status: "CALLED", party_size: 2 }], - }, - }], - }; - rerender(); - pollingResource.data = { - ...pollingResource.data, - projects: pollingResource.data.projects.map((project: Record) => ({ ...project })), - }; - rerender(); + }); - expect(cancel).toHaveBeenCalledOnce(); - expect(speak).toHaveBeenCalledTimes(3); - for (const [utterance] of speak.mock.calls) { - expect(utterance).toMatchObject({ - text: "请零零零一六号,前往入口。", - lang: "zh-CN", - rate: 0.9, - }); - } + it("项目编码缺失时不会用内部项目 ID 生成大屏地址", () => { + pollingResource.data = { + ...pollingResource.data, + projects: [{ ...pollingResource.data.projects[0], code: undefined }], + }; + + render(); + + expect(screen.queryByRole("link", { name: "进入东门观光车项目大屏" })).not.toBeInTheDocument(); + expect(screen.queryByText("进入项目大屏")).not.toBeInTheDocument(); }); }); diff --git a/web/src/pages/PublicDisplayCenterPage.tsx b/web/src/pages/PublicDisplayCenterPage.tsx index f2e2183..83e98a4 100644 --- a/web/src/pages/PublicDisplayCenterPage.tsx +++ b/web/src/pages/PublicDisplayCenterPage.tsx @@ -1,38 +1,19 @@ -import { useEffect, useRef } from "react"; import { api } from "../api"; import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback"; import { usePollingResource } from "../hooks/usePollingResource"; -import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements"; -import { formatCallAnnouncement, isTimestampStale } from "../lib/format"; -import type { CallBatchDto } from "../types"; +import { isTimestampStale } from "../lib/format"; +import type { AdminProjectDto } from "../types"; import { DisplayCenter } from "./AdminPage"; -function projectCurrentBatch(value: unknown): CallBatchDto | null { - return value && typeof value === "object" ? value as CallBatchDto : null; +function projectDisplayHref(project: AdminProjectDto) { + const code = project.code?.trim(); + return code ? `/display/${encodeURIComponent(code)}` : undefined; } export function PublicDisplayCenterPage() { - const { announce } = useSpeechAnnouncements(); const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 }); const data = resource.data; const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000); - const lastCallsRef = useRef | null>(null); - useEffect(() => { - if (!data) return; - const previousCalls = lastCallsRef.current; - const nextCalls = new Map(); - const announcements: string[] = []; - for (const project of data.projects) { - const batch = projectCurrentBatch(project.current_batch); - const key = batch ? `${batch.batch_number}:${batch.called_at ?? ""}` : null; - nextCalls.set(project.id, key); - if (!previousCalls?.has(project.id) || !key || previousCalls.get(project.id) === key) continue; - const announcement = formatCallAnnouncement(batch); - if (announcement) announcements.push(announcement); - } - lastCallsRef.current = nextCalls; - announce(announcements); - }, [announce, data]); return (
@@ -55,7 +36,7 @@ export function PublicDisplayCenterPage() { {data ? ( <> - + ) : null} diff --git a/web/src/styles.css b/web/src/styles.css index 8c9bb00..6216451 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -7405,3 +7405,43 @@ body:has(.visitor-page--mobile) { padding-block: var(--space-4); } } + +/* Public overview cards are full-document links so the Android WebView can + detect the project URL and start native announcements. */ +.screen-tile--link { + cursor: pointer; + color: inherit; + text-decoration: none; + transition: + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); +} + +.screen-tile--link:hover { + border-color: rgba(11, 107, 58, .46); + box-shadow: 0 18px 42px rgba(16, 38, 29, .14); + text-decoration: none; + transform: translateY(-2px); +} + +.screen-tile--link:focus-visible { + outline: 3px solid var(--color-primary); + outline-offset: 4px; + text-decoration: none; +} + +.screen-tile--link:active { + transform: translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + .screen-tile--link { + transition: none; + } + + .screen-tile--link:hover, + .screen-tile--link:active { + transform: none; + } +}