init
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
target/
|
||||||
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
|
!**/src/main/**/target/
|
||||||
|
!**/src/test/**/target/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea/modules.xml
|
||||||
|
.idea/jarRepositories.xml
|
||||||
|
.idea/compiler.xml
|
||||||
|
.idea/libraries/
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
### Eclipse ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
build/
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
### Mac OS ###
|
||||||
|
.DS_Store
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
4
.idea/vcs.xml
generated
Normal file
4
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings" defaultProject="true" />
|
||||||
|
</project>
|
46
pom.xml
Normal file
46
pom.xml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.9</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>org.example</groupId>
|
||||||
|
<artifactId>quick_response</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
<name>quick_response</name>
|
||||||
|
<description>Quick response buzzer app</description>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
13
src/main/java/org/example/App.java
Normal file
13
src/main/java/org/example/App.java
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package org.example;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hello world!
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public class App
|
||||||
|
{
|
||||||
|
public static void main( String[] args )
|
||||||
|
{
|
||||||
|
System.out.println( "Hello World!" );
|
||||||
|
}
|
||||||
|
}
|
11
src/main/java/org/example/QuickResponseApplication.java
Normal file
11
src/main/java/org/example/QuickResponseApplication.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package org.example;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class QuickResponseApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(QuickResponseApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
25
src/main/java/org/example/config/WebSocketConfig.java
Normal file
25
src/main/java/org/example/config/WebSocketConfig.java
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package org.example.config;
|
||||||
|
|
||||||
|
import org.example.ws.QuickResponseWebSocketHandler;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
private final QuickResponseWebSocketHandler handler;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public WebSocketConfig(QuickResponseWebSocketHandler handler) {
|
||||||
|
this.handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(handler, "/ws/quick").setAllowedOrigins("*");
|
||||||
|
}
|
||||||
|
}
|
40
src/main/java/org/example/model/Player.java
Normal file
40
src/main/java/org/example/model/Player.java
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package org.example.model;
|
||||||
|
|
||||||
|
public class Player {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private boolean host;
|
||||||
|
|
||||||
|
public Player() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Player(String id, String name, boolean host) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHost() {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHost(boolean host) {
|
||||||
|
this.host = host;
|
||||||
|
}
|
||||||
|
}
|
204
src/main/java/org/example/service/GameService.java
Normal file
204
src/main/java/org/example/service/GameService.java
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package org.example.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.example.model.Player;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class GameService {
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();
|
||||||
|
private final Map<String, Player> sessionPlayers = new ConcurrentHashMap<>();
|
||||||
|
private final List<Map<String, Object>> violations = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
private final List<Map<String, Object>> ranking = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
// 新增:本轮已违规(或已点击)的选手集合,禁止进入排名
|
||||||
|
private final Set<String> disqualified = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
|
private volatile boolean countdownActive = false;
|
||||||
|
private volatile boolean acceptingBuzz = false;
|
||||||
|
// 倒计时结束的绝对时间,用于计算违规“提前几毫秒”
|
||||||
|
private volatile long countdownEndAtMillis = 0L;
|
||||||
|
|
||||||
|
public void registerSession(WebSocketSession session) {
|
||||||
|
sessions.add(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSession(WebSocketSession session) {
|
||||||
|
sessions.remove(session);
|
||||||
|
sessionPlayers.remove(session.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void login(WebSocketSession session, String id, String name, boolean host) throws IOException {
|
||||||
|
Player p = new Player(id, name, host);
|
||||||
|
sessionPlayers.put(session.getId(), p);
|
||||||
|
Map<String, Object> ack = Map.of(
|
||||||
|
"type", "login_ack",
|
||||||
|
"payload", Map.of("host", host, "name", name, "id", id));
|
||||||
|
sendTo(session, ack);
|
||||||
|
// 同步当前状态
|
||||||
|
sendViolations();
|
||||||
|
sendRanking();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startCountdown(WebSocketSession session, int seconds) throws IOException {
|
||||||
|
Player p = sessionPlayers.get(session.getId());
|
||||||
|
if (p == null || !p.isHost()) {
|
||||||
|
sendError(session, "只有主持人可以开始倒计时");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (countdownActive) {
|
||||||
|
sendError(session, "倒计时已在进行");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 若上一轮仍开放,先清理状态但不通知错误
|
||||||
|
if (acceptingBuzz) {
|
||||||
|
resetInternal(false);
|
||||||
|
}
|
||||||
|
// 新一轮开始前清空违规名单
|
||||||
|
disqualified.clear();
|
||||||
|
|
||||||
|
countdownActive = true;
|
||||||
|
acceptingBuzz = false;
|
||||||
|
countdownEndAtMillis = System.currentTimeMillis() + seconds * 1000L;
|
||||||
|
|
||||||
|
for (int i = seconds; i >= 1; i--) {
|
||||||
|
final int val = i;
|
||||||
|
scheduler.schedule(() -> sendCountdown(val), seconds - i, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
scheduler.schedule(() -> {
|
||||||
|
countdownActive = false;
|
||||||
|
acceptingBuzz = true;
|
||||||
|
broadcastToAllExceptHost(Map.of("type", "countdown_end"));
|
||||||
|
}, seconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendCountdown(int value) {
|
||||||
|
broadcastToAllExceptHost(Map.of(
|
||||||
|
"type", "countdown",
|
||||||
|
"payload", Map.of("value", value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void buzz(WebSocketSession session) throws IOException {
|
||||||
|
Player p = sessionPlayers.get(session.getId());
|
||||||
|
if (p == null) {
|
||||||
|
sendError(session, "未登录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p.isHost()) {
|
||||||
|
sendError(session, "主持人不能抢答");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (countdownActive) {
|
||||||
|
// 原子加锁:同一轮仅允许第一次点击成功(记录为违规)
|
||||||
|
boolean first = disqualified.add(p.getId());
|
||||||
|
if (!first) { // 已点击过(可能是并发双击或已违规/已排名)
|
||||||
|
sendError(session, "本轮已抢答,不能重复抢答");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long earlyMs = Math.max(0L, countdownEndAtMillis - now);
|
||||||
|
Map<String, Object> v = new HashMap<>();
|
||||||
|
v.put("id", p.getId());
|
||||||
|
v.put("name", p.getName());
|
||||||
|
v.put("time", now);
|
||||||
|
v.put("earlyMs", earlyMs);
|
||||||
|
violations.add(v);
|
||||||
|
sendViolations();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (acceptingBuzz) {
|
||||||
|
// 原子加锁:进入排名前先锁定,防止并发双击进两次
|
||||||
|
boolean first = disqualified.add(p.getId());
|
||||||
|
if (!first) {
|
||||||
|
sendError(session, "本轮已抢答,不能重复抢答");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (ranking) {
|
||||||
|
boolean already = ranking.stream().anyMatch(e -> Objects.equals(e.get("id"), p.getId()));
|
||||||
|
if (!already) {
|
||||||
|
Map<String, Object> r = new HashMap<>();
|
||||||
|
r.put("id", p.getId());
|
||||||
|
r.put("name", p.getName());
|
||||||
|
r.put("time", System.currentTimeMillis());
|
||||||
|
r.put("rank", ranking.size() + 1);
|
||||||
|
ranking.add(r);
|
||||||
|
sendRanking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendError(session, "未开始抢答");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset(WebSocketSession session) throws IOException {
|
||||||
|
Player p = sessionPlayers.get(session.getId());
|
||||||
|
if (p == null || !p.isHost()) {
|
||||||
|
sendError(session, "只有主持人可以重置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetInternal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetInternal(boolean notify) {
|
||||||
|
countdownActive = false;
|
||||||
|
acceptingBuzz = false;
|
||||||
|
countdownEndAtMillis = 0L;
|
||||||
|
violations.clear();
|
||||||
|
ranking.clear();
|
||||||
|
disqualified.clear();
|
||||||
|
broadcast(Map.of("type", "state_reset"));
|
||||||
|
sendViolations();
|
||||||
|
sendRanking();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendViolations() {
|
||||||
|
broadcast(Map.of(
|
||||||
|
"type", "violation_update",
|
||||||
|
"payload", Map.of("list", new ArrayList<>(violations))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendRanking() {
|
||||||
|
broadcast(Map.of(
|
||||||
|
"type", "ranking_update",
|
||||||
|
"payload", Map.of("list", new ArrayList<>(ranking))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void broadcast(Map<String, Object> msg) {
|
||||||
|
for (WebSocketSession s : sessions) {
|
||||||
|
safeSend(s, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void broadcastToAllExceptHost(Map<String, Object> msg) {
|
||||||
|
for (WebSocketSession s : sessions) {
|
||||||
|
Player sp = sessionPlayers.get(s.getId());
|
||||||
|
if (sp == null || !sp.isHost()) {
|
||||||
|
safeSend(s, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void safeSend(WebSocketSession s, Map<String, Object> msg) {
|
||||||
|
try {
|
||||||
|
s.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendTo(WebSocketSession s, Map<String, Object> msg) throws IOException {
|
||||||
|
s.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendError(WebSocketSession s, String error) throws IOException {
|
||||||
|
sendTo(s, Map.of("type", "error", "payload", Map.of("message", error)));
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,61 @@
|
|||||||
|
package org.example.ws;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.example.service.GameService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.CloseStatus;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class QuickResponseWebSocketHandler extends TextWebSocketHandler {
|
||||||
|
|
||||||
|
private final GameService gameService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public QuickResponseWebSocketHandler(GameService gameService, ObjectMapper objectMapper) {
|
||||||
|
this.gameService = gameService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||||
|
gameService.registerSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||||
|
JsonNode root = objectMapper.readTree(message.getPayload());
|
||||||
|
String type = root.path("type").asText("");
|
||||||
|
JsonNode payload = root.path("payload");
|
||||||
|
switch (type) {
|
||||||
|
case "login":
|
||||||
|
String id = payload.path("id").asText("");
|
||||||
|
String name = payload.path("name").asText("");
|
||||||
|
boolean host = payload.path("host").asBoolean(false);
|
||||||
|
gameService.login(session, id, name, host);
|
||||||
|
break;
|
||||||
|
case "start":
|
||||||
|
gameService.startCountdown(session, 3); // 固定3秒倒计时
|
||||||
|
break;
|
||||||
|
case "buzz":
|
||||||
|
gameService.buzz(session);
|
||||||
|
break;
|
||||||
|
case "reset":
|
||||||
|
gameService.reset(session);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore or send error
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||||
|
gameService.removeSession(session);
|
||||||
|
}
|
||||||
|
}
|
1
src/main/resources/application.properties
Normal file
1
src/main/resources/application.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
server.port=8089
|
111
src/main/resources/static/index.html
Normal file
111
src/main/resources/static/index.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>抢答小程序 - 登录</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f8fa;
|
||||||
|
--fg: #24292f;
|
||||||
|
--muted: #57606a;
|
||||||
|
--primary: #0969da;
|
||||||
|
--border: #eaeef2;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft Yahei', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 12px auto;
|
||||||
|
max-width: 720px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text] {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>抢答小程序 - 登录</h1>
|
||||||
|
<div class="row">
|
||||||
|
<input id="name" type="text" placeholder="请输入昵称(输入 admin 将作为主持人)" />
|
||||||
|
<button id="loginBtn">进入</button>
|
||||||
|
<span id="status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const nameEl = document.getElementById('name');
|
||||||
|
const loginBtn = document.getElementById('loginBtn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
function randomId() {
|
||||||
|
return 'U' + Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBtn.onclick = () => {
|
||||||
|
const name = nameEl.value.trim();
|
||||||
|
if (!name) { alert('请输入昵称'); return; }
|
||||||
|
const isHost = name.toLowerCase() === 'admin';
|
||||||
|
const id = isHost ? 'admin' : randomId();
|
||||||
|
// 保存到本地并跳转
|
||||||
|
localStorage.setItem('qr_user', JSON.stringify({ id, name, host: isHost }));
|
||||||
|
location.href = 'play.html';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简单显示连接状态提示(无实际连接)
|
||||||
|
statusEl.textContent = '请输入昵称后点击进入';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
264
src/main/resources/static/play.html
Normal file
264
src/main/resources/static/play.html
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>抢答小程序 - 抢答页面</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f8fa;
|
||||||
|
--fg: #24292f;
|
||||||
|
--muted: #57606a;
|
||||||
|
--primary: #0969da;
|
||||||
|
--primary-soft: #e7f5ff;
|
||||||
|
--success: #1a7f37;
|
||||||
|
--danger: #d1242f;
|
||||||
|
--border: #eaeef2;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft Yahei', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 12px auto;
|
||||||
|
max-width: 900px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #94b7ee;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
font-size: 48px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 10px 0;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>抢答小程序</h1>
|
||||||
|
<div class="row">
|
||||||
|
<span id="userInfo" class="muted"></span>
|
||||||
|
<button id="backBtn">退出登录</button>
|
||||||
|
<span id="status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="hostPanel" style="display:none;">
|
||||||
|
<h3>主持人面板 <span class="tag">仅主持人可见</span></h3>
|
||||||
|
<div class="row">
|
||||||
|
<button id="startBtn" class="success">开始(3秒倒计时)</button>
|
||||||
|
<button id="resetBtn" class="danger">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="playerPanel" style="display:none;">
|
||||||
|
<div class="countdown" id="countdown"></div>
|
||||||
|
<div style="text-align:center; margin-top:12px;">
|
||||||
|
<button id="buzzBtn" style="font-size:20px; padding: 14px 28px;" disabled>抢答</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card grid">
|
||||||
|
<div>
|
||||||
|
<h3>抢答排名</h3>
|
||||||
|
<div id="ranking" class="list"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>违规抢答</h3>
|
||||||
|
<div id="violations" class="list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const user = JSON.parse(localStorage.getItem('qr_user') || 'null');
|
||||||
|
if (!user) { location.href = 'index.html'; return; }
|
||||||
|
const userInfoEl = document.getElementById('userInfo');
|
||||||
|
const backBtn = document.getElementById('backBtn');
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const resetBtn = document.getElementById('resetBtn');
|
||||||
|
const buzzBtn = document.getElementById('buzzBtn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const hostPanel = document.getElementById('hostPanel');
|
||||||
|
const playerPanel = document.getElementById('playerPanel');
|
||||||
|
const cdEl = document.getElementById('countdown');
|
||||||
|
const rankingEl = document.getElementById('ranking');
|
||||||
|
const violationsEl = document.getElementById('violations');
|
||||||
|
|
||||||
|
userInfoEl.textContent = `当前用户:${user.name}${user.host ? '(主持人)' : ''}`;
|
||||||
|
backBtn.onclick = () => { localStorage.removeItem('qr_user'); location.href = 'index.html'; };
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let isHost = false;
|
||||||
|
// 本地本轮是否已点击抢答(无论违规或合规)
|
||||||
|
let hasBuzzedThisRound = false;
|
||||||
|
// 是否处于倒计时中,用于识别新一轮开始
|
||||||
|
let isCountdownActive = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/quick';
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
ws.onopen = () => {
|
||||||
|
statusEl.textContent = '已连接';
|
||||||
|
ws.send(JSON.stringify({ type: 'login', payload: user }));
|
||||||
|
};
|
||||||
|
ws.onclose = () => { statusEl.textContent = '连接已断开'; };
|
||||||
|
ws.onerror = () => { statusEl.textContent = '连接错误'; };
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
let msg = {};
|
||||||
|
try { msg = JSON.parse(evt.data || '{}'); } catch (e) { return; }
|
||||||
|
const type = msg.type;
|
||||||
|
const payload = msg.payload || {};
|
||||||
|
if (type === 'login_ack') {
|
||||||
|
isHost = !!payload.host;
|
||||||
|
hostPanel.style.display = isHost ? 'block' : 'none';
|
||||||
|
playerPanel.style.display = isHost ? 'none' : 'block';
|
||||||
|
if (!isHost) { hasBuzzedThisRound = false; isCountdownActive = false; buzzBtn.disabled = true; }
|
||||||
|
} else if (type === 'countdown') {
|
||||||
|
if (!isHost) {
|
||||||
|
// 新一轮第一次收到倒计时,重置本地状态
|
||||||
|
if (!isCountdownActive) { hasBuzzedThisRound = false; }
|
||||||
|
isCountdownActive = true;
|
||||||
|
cdEl.textContent = payload.value;
|
||||||
|
buzzBtn.disabled = hasBuzzedThisRound; // 未点击则允许,点击过则置灰
|
||||||
|
}
|
||||||
|
} else if (type === 'countdown_end') {
|
||||||
|
if (!isHost) {
|
||||||
|
isCountdownActive = false;
|
||||||
|
cdEl.textContent = '开始!';
|
||||||
|
buzzBtn.disabled = hasBuzzedThisRound; // 未点击则可继续尝试进入排名
|
||||||
|
setTimeout(() => { cdEl.textContent = ''; }, 800);
|
||||||
|
}
|
||||||
|
} else if (type === 'violation_update') {
|
||||||
|
const list = payload.list || [];
|
||||||
|
violationsEl.innerHTML = list.map((v, idx) => {
|
||||||
|
const earlyMs = typeof v.earlyMs === 'number' ? v.earlyMs : null;
|
||||||
|
const earlyText = earlyMs != null
|
||||||
|
? `提前 ${Math.floor(earlyMs / 1000)}秒${earlyMs % 1000}毫秒`
|
||||||
|
: '违规(提前时间未知)';
|
||||||
|
return `<div class="item"><span>${idx + 1}. ${v.name} </span><span class="muted">${earlyText}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
// 若自己出现在违规列表中,则锁定本轮
|
||||||
|
if (list.some(v => v.id === user.id)) { hasBuzzedThisRound = true; buzzBtn.disabled = true; }
|
||||||
|
} else if (type === 'ranking_update') {
|
||||||
|
const list = payload.list || [];
|
||||||
|
rankingEl.innerHTML = list.map((r) => `<div class="item"><span>#${r.rank} ${r.name}</span><span class="muted">${new Date(r.time).toLocaleTimeString()}</span></div>`).join('');
|
||||||
|
// 若自己已进入排名,则锁定本轮
|
||||||
|
if (list.some(r => r.id === user.id)) { hasBuzzedThisRound = true; buzzBtn.disabled = true; }
|
||||||
|
} else if (type === 'state_reset') {
|
||||||
|
cdEl.textContent = '';
|
||||||
|
hasBuzzedThisRound = false;
|
||||||
|
isCountdownActive = false;
|
||||||
|
buzzBtn.disabled = true;
|
||||||
|
rankingEl.innerHTML = '';
|
||||||
|
violationsEl.innerHTML = '';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
const msgText = payload.message || '发生错误';
|
||||||
|
alert(msgText);
|
||||||
|
// 若服务端提示已抢答/已违规,也将前端本轮锁定
|
||||||
|
if (/已抢答|已违规/.test(msgText)) { hasBuzzedThisRound = true; buzzBtn.disabled = true; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
startBtn.onclick = () => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'start' })); };
|
||||||
|
resetBtn.onclick = () => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'reset' })); };
|
||||||
|
buzzBtn.onclick = () => {
|
||||||
|
if (ws && ws.readyState === 1) {
|
||||||
|
ws.send(JSON.stringify({ type: 'buzz' }));
|
||||||
|
// 本地先行锁定,避免并发多次发送
|
||||||
|
hasBuzzedThisRound = true;
|
||||||
|
buzzBtn.disabled = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Reference in New Issue
Block a user