功能:大屏 APK 原生播报及项目入口
具体项目页由 APK 原生轮询并播报三次,总览页不播报。总览项目卡片通过项目编码进入对应大屏。
This commit is contained in:
@@ -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`
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.nianxx.queue.display;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
final class CallAnnouncementTracker {
|
||||
private boolean initialized;
|
||||
private String lastCallKey;
|
||||
|
||||
String accept(String callKey, String announcement) {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
lastCallKey = callKey;
|
||||
return null;
|
||||
}
|
||||
if (Objects.equals(lastCallKey, callKey)) {
|
||||
return null;
|
||||
}
|
||||
lastCallKey = callKey;
|
||||
return callKey != null ? announcement : null;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
initialized = false;
|
||||
lastCallKey = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package cn.nianxx.queue.display;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
final class DisplayAnnouncementPoller {
|
||||
interface Listener {
|
||||
void onAnnouncement(String text);
|
||||
}
|
||||
|
||||
private static final String TAG = "XQKQueueDisplay";
|
||||
private static final long POLL_INTERVAL_SECONDS = 3;
|
||||
private static final int NETWORK_TIMEOUT_MILLIS = 5_000;
|
||||
|
||||
private final Listener listener;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(
|
||||
runnable -> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> values) {
|
||||
List<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,5 @@
|
||||
<string name="retry">重新加载</string>
|
||||
<string name="blocked_navigation">已阻止离开公示页面</string>
|
||||
<string name="ssl_error">安全连接校验失败,请联系管理员</string>
|
||||
<string name="speech_unavailable">未检测到可用的中文语音引擎,请在系统设置中启用中文文字转语音</string>
|
||||
</resources>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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());
|
||||
}
|
||||
}
|
||||
@@ -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("", " ")));
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import type { AdminProjectDto, CallBatchDto } from "../types";
|
||||
interface ProjectScreenTileProps {
|
||||
project: AdminProjectDto;
|
||||
standalone?: boolean;
|
||||
displayHref?: string;
|
||||
onFullscreen?: (projectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<article className={`screen-tile${standalone ? " screen-tile--standalone" : ""}`} data-project-screen={project.id}>
|
||||
const className = `screen-tile${standalone ? " screen-tile--standalone" : ""}${displayHref ? " screen-tile--link" : ""}`;
|
||||
const content = (
|
||||
<>
|
||||
<header className="screen-tile__header">
|
||||
<div className="screen-tile__clock" aria-label={`当前时间 ${dateLabel} ${timeLabel}`}>
|
||||
<img className="screen-tile__logo" src="/xiaoqikong-logo.jpg" alt="" aria-hidden="true" />
|
||||
@@ -63,7 +65,9 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }:
|
||||
<div className="screen-tile__project">
|
||||
<h2>{project.name}</h2>
|
||||
</div>
|
||||
{!standalone && onFullscreen ? (
|
||||
{displayHref ? (
|
||||
<span className="button button--primary screen-tile__fullscreen" aria-hidden="true">进入项目大屏</span>
|
||||
) : !standalone && onFullscreen ? (
|
||||
<button className="button button--primary screen-tile__fullscreen" onClick={() => void onFullscreen(project.id)}>全屏展示</button>
|
||||
) : null}
|
||||
</header>
|
||||
@@ -83,6 +87,21 @@ export function ProjectScreenTile({ project, standalone = false, onFullscreen }:
|
||||
<ol>{visible.map((item) => <li key={item.range}><strong>{item.range}</strong><span>{item.wait}</span></li>)}</ol>
|
||||
) : <p>后续区间暂不可估算</p>}
|
||||
</section>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
|
||||
if (displayHref) {
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href={displayHref}
|
||||
aria-label={`进入${project.name}项目大屏`}
|
||||
data-project-screen={project.id}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className={className} data-project-screen={project.id}>{content}</article>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
import type { CallBatchDto, ProjectStatus, TicketStatus } from "../types";
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("zh-CN");
|
||||
const SPOKEN_DIGITS: Record<string, string> = {
|
||||
"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<string | null | undefined
|
||||
return `${valid[0]} 至 ${valid[valid.length - 1]}`;
|
||||
}
|
||||
|
||||
export function formatCallAnnouncement(batch?: CallBatchDto | null): string | null {
|
||||
const numbers = batch?.tickets.map((ticket) => 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();
|
||||
|
||||
@@ -344,7 +344,12 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 ? <FeedbackBanner tone="warning" title="浏览器未允许全屏">可继续在当前页面查看实时状态。</FeedbackBanner> : null}
|
||||
{projects.length ? (
|
||||
<section className="screen-wall" aria-label="项目实时监控墙">
|
||||
{projects.map((project) => <ProjectScreenTile project={project} onFullscreen={enterFullscreen} key={project.id} />)}
|
||||
{projects.map((project) => (
|
||||
<ProjectScreenTile
|
||||
project={project}
|
||||
displayHref={projectHref?.(project)}
|
||||
onFullscreen={projectHref ? undefined : enterFullscreen}
|
||||
key={project.id}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : <EmptyState title="暂无可监控项目" />}
|
||||
</>
|
||||
|
||||
@@ -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(<DisplayPage />);
|
||||
it("网页端不包含叫号声音控件或音频播放器", () => {
|
||||
const { container } = render(<DisplayPage />);
|
||||
|
||||
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(<DisplayPage />);
|
||||
rerender(<DisplayPage />);
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(speak).toHaveBeenCalledTimes(3);
|
||||
for (const [utterance] of speak.mock.calls) {
|
||||
expect(utterance).toMatchObject({
|
||||
text: "请零零零零五号至零零零零六号,前往入口。",
|
||||
lang: "zh-CN",
|
||||
rate: 0.9,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <main className="display-page display-page--state"><strong>正在连接叫号服务</strong></main>;
|
||||
}
|
||||
|
||||
@@ -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(<PublicDisplayCenterPage />);
|
||||
it("总览页不包含叫号声音控件或音频播放器", () => {
|
||||
const { container } = render(<PublicDisplayCenterPage />);
|
||||
|
||||
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(<PublicDisplayCenterPage />);
|
||||
pollingResource.data = {
|
||||
...pollingResource.data,
|
||||
projects: pollingResource.data.projects.map((project: Record<string, unknown>) => ({ ...project })),
|
||||
};
|
||||
rerender(<PublicDisplayCenterPage />);
|
||||
});
|
||||
|
||||
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(<PublicDisplayCenterPage />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: "进入东门观光车项目大屏" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("进入项目大屏")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Map<string, string | null> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const previousCalls = lastCallsRef.current;
|
||||
const nextCalls = new Map<string, string | null>();
|
||||
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 (
|
||||
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
||||
@@ -55,7 +36,7 @@ export function PublicDisplayCenterPage() {
|
||||
{data ? (
|
||||
<>
|
||||
<FreshnessBanner offline={resource.offline} stale={stale} timestamp={resource.lastClientSuccessAt} refreshing={resource.refreshing} errorMessage={resource.error?.message} onRetry={resource.refresh} />
|
||||
<DisplayCenter projects={data.projects} />
|
||||
<DisplayCenter projects={data.projects} projectHref={projectDisplayHref} />
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user