功能:大屏 APK 原生播报及项目入口

具体项目页由 APK 原生轮询并播报三次,总览页不播报。总览项目卡片通过项目编码进入对应大屏。
This commit is contained in:
2026-08-11 00:58:17 +08:00
parent 5ad8bfcd03
commit e4e3e6a735
27 changed files with 728 additions and 236 deletions

View File

@@ -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`
## 构建

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -6,4 +6,5 @@
<string name="retry">重新加载</string>
<string name="blocked_navigation">已阻止离开公示页面</string>
<string name="ssl_error">安全连接校验失败,请联系管理员</string>
<string name="speech_unavailable">未检测到可用的中文语音引擎,请在系统设置中启用中文文字转语音</string>
</resources>

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}

View File

@@ -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());
}
}

View File

@@ -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("", " ")));
}
}

View File

@@ -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"],

View File

@@ -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)
}

View File

@@ -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>;
}

View File

@@ -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();
});
});

View File

@@ -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 };
}

View File

@@ -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();

View File

@@ -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="暂无可监控项目" />}
</>

View File

@@ -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,
});
}
});
});

View File

@@ -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>;
}

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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;
}
}