news 2026/10/31 23:36:45

Java公交车实时监控系统:GPS接入、Kafka缓冲与WebSocket推送实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java公交车实时监控系统:GPS接入、Kafka缓冲与WebSocket推送实战

简介:这是一套基于Java打造的公交车实时监控系统设计源码,面向具有一定Java基础、希望学习企业级实时监控系统开发的开发者,可支撑课程设计、项目实训或毕业设计。系统采用微服务架构,基于Spring Boot封装各业务模块,并通过RESTful API与笑园实时公交API对接,实现车辆位置、运行轨迹、车辆状态等数据的实时获取与展示。资源包共43个文件,约61KB,其中包含37个Java源代码文件作为核心业务逻辑,辅以2个XML配置文件用于组件灵活配置,1个YAML文件简化配置流程,1个SQL文件支撑监控数据持久化存储,另有gitignore及说明文档。当前已有279人学习下载。从源码中可梳理出数据接口调用的标准化流程、后端模块划分与配置管理思路,有助于理解实时监控系统的工程化实现,适合作为智能交通方向的项目参考。

1. 用 Java 把公交车的实时位置搬到浏览器里:这套源码解决的不只是画点

调度室里的大屏要实时显示每一辆公交车的当前位置,这个需求听起来简单,真正动手做的时候会发现,它牵扯到 GPS 数据接入、消息缓冲、实时推送、前端地图渲染四块内容。我拆的这套基于 Java 实现的公交车实时监控系统设计源码,就是把“上报 → 缓冲 → 推送 → 展示”这条链路完整打通,并且每一层都预留了替换的余地。适合做 Java 课程设计、毕业设计,或者公司内部需要一套可演示的调度原型系统的开发者。动手之前先记住一个结论:这个项目最耗时间的不是地图上画点,而是数据对齐和时间戳处理,源码的价值恰恰是把这些坑提前踩平。

2. 系统架构与数据模型:先把车辆位置从报文到浏览器走一遍

2.1 五个环节组成的链路,每一环都能单独拆开替换

拿到这套源码,第一件事不是看代码,而是看它的数据流向。整个系统从模拟器到前端地图一共经过五个环节,理解这一条链路之后,后面改代码才有明确的下手点。

车辆 GPS 模拟器生成经纬度、速度、方向角,封装成一条 JSON 消息上报给接入接口;接入接口负责验签和基本格式校验,随后把消息投递到 Kafka;下游的位置消费服务从 Kafka 拉取消息,一方面把最新位置写入 Redis 做缓存,另一方面通过 WebSocket 推送给浏览器;浏览器端的地图组件收到 JSON 后解析经纬度,更新车辆图标。全程没有数据库参与实时链路,MySQL 只用来存车辆基础信息和历史轨迹,这很关键。

中间的每个环节都可以替换:模拟器可以换成真实车载终端,接入接口可以直接对接 TCP 长连接,Kafka 在单机演示时可以用内存队列替代。源码里用配置开关控制这条数据链路,我一般拿到项目先搜@KafkaListener和@ServerEndpoint这两个注解,就能快速定位核心处理逻辑。各层职责按模块拆开看会更清楚,下面这张表可以直接对照源码包结构去翻。

环节核心组件职责替换场景
数据源GpsSimulator生成并上报车辆位置换成真实终端报文
接入层GpsController校验消息、投递到消息队列增加鉴权或限流
缓冲层Kafka Topicbus-gps削峰填谷、解耦上下游单机换内存队列
消费推送PositionConsumer / WebSocket更新缓存、推送到浏览器增加多路推送策略
展示层网页地图组件渲染车辆标点、运行状态换成小程序或客户端

2.2 车辆与位置表设计:字段冗余少一点,查询才不至于绕

数据库在这套系统里承担的是辅助角色,但表结构设计直接影响后面做历史轨迹回放时的工作量。源码里核心就是两张表:车辆基础信息表和位置记录表。车辆表保存车牌号、线路编号、车辆状态,位置表保存每一次上报的经纬度、速度、方向角和 GPS 设备时间。

位置表的设计有几个值得注意的决策。经纬度字段用DECIMAL(10,6),这个精度大约是 0.1 米,足够公交定位使用;速度用SMALLINT保存整数 km/h,没有用浮点;方向角用SMALLINT存 0 到 359 的整数。GPS 时间单独用一个DATETIME字段,而不是直接用数据库的CURRENT_TIMESTAMP,这个细节在实际运行中很重要——报文到达时间和设备采集时间之间存在网络延迟,后续做轨迹分析要以设备时间为准。

CREATE TABLE vehicle ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(20) NOT NULL UNIQUE COMMENT '车牌号', line_no VARCHAR(10) NOT NULL COMMENT '线路编号', status TINYINT DEFAULT 1 COMMENT '1运营 0停运', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公交车基础信息'; CREATE TABLE bus_position ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(20) NOT NULL COMMENT '车牌号', lng DECIMAL(10,6) NOT NULL COMMENT '经度', lat DECIMAL(10,6) NOT NULL COMMENT '纬度', speed SMALLINT DEFAULT 0 COMMENT '速度km/h', direction SMALLINT DEFAULT 0 COMMENT '方位角0-359', gps_time DATETIME NOT NULL COMMENT 'GPS设备时间', KEY idx_plate_time (plate_no, gps_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆位置上报记录';

注意idx_plate_time这个联合索引,它是给历史轨迹查询用的。如果你直接按plate_no查全部记录,车辆跑一天就会产生几万条数据,没有索引的查询会直接拖垮页面;有了联合索引之后,按时间和车牌过滤基本在毫秒级完成。我一般建议把这两张表的数据保留策略做得简单一些,实时表只保留最近三天,历史轨迹归档到另一张分区表即可。

2.3 为什么用 Kafka 做缓冲而不是直接写数据库

这是整套系统里最值得在面试或答辩时展开的设计点。一台公交车平均 3 到 5 秒上报一条位置,看似频率不高,但一辆运行 14 小时的公交车一天就要产生 1 万条左右的位置记录;一个城市按 200 辆车计算,一天就是 200 万条写入。如果每次上报直接 INSERT 到 MySQL,数据库会持续处于高负载状态,而且前端推送和数据库写入互相争抢连接资源时,实时性会被拉垮。

Kafka 在这里承担的是削峰和异步化的角色。接入接口收到位置消息后只做校验和序列化,投递到 Kafka 就立即返回;下游的 PositionConsumer 按照自己的节奏消费消息,批量更新 Redis、批量推送、按批次落库。这样数据库不再直接面对高频上报,而是接收集中批量写入,压力完全可控。

这套系统里 Kafka 主题只保存最近的消费进度,不需要长时间保留数据,所以主题的retention.ms我建议直接设成3600000,也就是一小时。如果你跑单机演示觉得 Kafka 太重,源码里有开关可以直接切换到内存队列模式,这就是链路解耦带来的优势——每一层替换起来都不影响其他模块运行。

3. 数据采集与模拟:手写一个 GPS 模拟器的正确姿势

3.1 模拟器关注三个参数:路线、车速、上报节奏

没有真实车载终端时,模拟器就需要生成足够可信的位置消息,让整个系统转起来。源码里的模拟器核心就三件事:按预设路线移动车辆、随机调整速度和方向、按上报间隔发送消息。初次看代码的时候,我建议先把这三个参数找出来,别急着跑工程。

路线本质上是一组经纬度坐标点数组,模拟器在相邻两个坐标点之间做线性插值。车速决定点位前进的步长,比如 30 km/h 比 50 km/h 每一步移动的距离更短。上报节奏决定消息产生的频率,源码默认 1000 毫秒上报一次,如果单机演示想看得更连贯,调整到 500 毫秒也没问题,但要注意下游消费端的处理速度。

很多人写模拟器容易犯一个错误:把路线当成直线来跑。真实公交是在站点之间走折线的,直线插值会让车从楼顶上穿过去。源码的路线表里每个点就是一个站台或拐点,站点之间用线性插值,拐点处切换目标点,这样跑出来的轨迹在地图上才像一条正常的公交线路。

3.2 模拟器核心代码:插值移动和随机扰动

下面这段代码是从模拟器里抽出来的核心逻辑,它的作用就是让一辆车沿着预设路线持续移动,并叠加随机扰动让数据看着更真实。

public class GpsSimulator implements Runnable { private final String plateNo; private final List<Point> route; private final Random random = new Random(); private int segmentIndex = 0; private double progress = 0.0; @Override public void run() { Point start = route.get(segmentIndex); Point next = route.get(segmentIndex + 1); // 每次移动 2% 到 3% 的区间,速度由这个步长体现 progress += 0.02 + random.nextDouble() * 0.01; if (progress >= 1.0) { segmentIndex = (segmentIndex + 1) % (route.size() - 1); progress = 0.0; } double lng = start.lng + (next.lng - start.lng) * progress; double lat = start.lat + (next.lat - start.lat) * progress; GpsMessage msg = new GpsMessage(); msg.setPlateNo(plateNo); msg.setLng(lng); msg.setLat(lat); msg.setSpeed(20 + random.nextInt(25)); // 20-45km/h 之间随机 msg.setDirection(computeDirection(start, next)); // 按线段方向计算方位角 msg.setGpsTime(System.currentTimeMillis()); sendToKafka(msg); // 实际生产环境通过 KafkaTemplate 发送 } }

这里的核心逻辑是progress这个变量,它代表车辆在当前线段中走过的比例。每次run()增加 2% 到 3%,步长随机浮动,车速因此有了变化。计算新坐标时用start + (next - start) * progress做线性插值,这就是最简单也最不容易出错的轨迹生成方式。方向角通过当前线段两个点的经纬度差值计算,用Math.atan2转成角度即可。

上报时间直接用System.currentTimeMillis()是模拟器可以接受的,因为模拟器没有真实设备时钟。但在真实终端场景里,这里必须换成设备采集时间,我在第 5 章会详细说明原因。

3.3 让 200 台车同时动起来:调度与线程池

模拟器类本身只负责生成一条位置消息,要让多辆车同时运行,需要一个调度层来控制节奏。源码里用一个线程池来管理所有模拟器任务,每个任务代表一辆车。

public class SimulatorScheduler { private final ExecutorService executor = Executors.newFixedThreadPool(30); public void start(List<String> plateNos, List<List<Point>> routes) { for (int i = 0; i < plateNos.size(); i++) { GpsSimulator simulator = new GpsSimulator(plateNos.get(i), routes.get(i)); // 每辆车按自己的频率上报,不需要全局同步 executor.submit(simulator); } } }

线程池设了 30 个线程,而不是车辆数。原因很简单:模拟器的任务并不计算密集,每个任务只是循环执行插值和消息发送,大部分时间在等待网络 I/O,30 个线程处理 200 辆车完全足够。submit()之后模拟器会在内部用while(running)循环持续上报,每轮循环之间通过Thread.sleep(1000)控制节奏。

有个细节值得注意:每辆车都在自己的循环里上报,所以不会出现所有车辆在同一时刻集中发送消息的情况。这样更接近真实场景,消息到达接入层的时间是错开的,Kafka 的写入压力也是平稳的。如果你想提高模拟强度,把 sleep 间隔缩短到 200 毫秒,就能模拟高频上报场景,顺便测试下游的吞吐上限。

4. 实时推送链路:WebSocket 与 Redis 的配合

4.1 为什么实时推送选 WebSocket 而不是轮询

浏览器要实时显示车辆位置,最简单的做法是前端每 5 秒调一次接口拉最新位置,这种轮询方式在数据量小的时候还算凑合,但一旦车辆超过 100 辆,每秒要重复拉取大量不变的数据,浪费带宽也浪费数据库连接。这套系统选 WebSocket 的原因很直接:位置信息是服务端主动产生的数据流,客户端只需要在连接建立后被动接收,实时性可以压到 1 秒以内,而且连接数量可控。

轮询的延迟是固定间隔决定的,间隔设得越短越费资源;WebSocket 是事件驱动,数据一到就推,没有冗余请求。另外一个隐藏优势是 WebSocket 链接可以复用,不用每次建立新的 HTTP 连接,这对持续推送的场景非常重要。

4.2 WebSocket 服务端实现:连接管理、广播、心跳

@Component @ServerEndpoint("/ws/bus") public class BusWebSocket { // 线程安全的连接集合,避免并发遍历问题 private static final CopyOnWriteArraySet<Session> SESSIONS = new CopyOnWriteArraySet<>(); @OnOpen public void onOpen(Session session) { SESSIONS.add(session); } @OnClose public void onClose(Session session) { SESSIONS.remove(session); } @OnError public void onError(Session session, Throwable error) { SESSIONS.remove(session); } /** * 广播位置消息,所有订阅了车辆位置的页面都会收到 */ public static void broadcast(String json) { for (Session session : SESSIONS) { if (session.isOpen()) { session.getBasicRemote().sendText(json); } } } }

这段代码最关键的两个点:连接集合用了CopyOnWriteArraySet,它保证并发遍历时不抛ConcurrentModificationException,代价是写操作开销高,但连接建立和断开本身不频繁,这个取舍划算;broadcast是静态方法,因为消费者线程从 Kafka 拿到消息时没有 HttpSession 上下文,必须通过静态方法直接调用,这也是很多教程里容易省略的细节。

代码里对每个 session 直接sendText是同步调用。200 辆车每辆每秒一条消息,每秒就有 200 次广播,每个连接都要写一次,如果页面连接数到了 200 个,单线程循环阻塞就会明显拖慢消费速度。所以真正的高并发场景里,需要把同步 send 改成异步发送,或者引入多线程推送,但那是后话,先用同步方案把链路跑通最重要。

4.3 Redis 在链路里的角色:车辆信息预热与最新位置缓存

Redis 在这套系统里做两件事:缓存最新位置,供页面回显和查询使用;缓存车辆基础信息,避免下游每次消费消息都去查 MySQL。车辆信息变化极少,启动时一次性预热到 Redis 是常规做法。

@Component public class CacheService { private final StringRedisTemplate redisTemplate; public void preloadVehicles() { // 从数据库查出所有车辆基础信息后写入 Redis Hash List<Vehicle> vehicles = vehicleMapper.selectList(null); for (Vehicle v : vehicles) { redisTemplate.opsForHash().put("vehicle:info", v.getPlateNo(), JSON.toJSONString(v)); } } public void updatePosition(GpsMessage msg) { // 用 String 结构存最新位置,过期时间 10 分钟 String key = "bus:pos:" + msg.getPlateNo(); redisTemplate.opsForValue().set(key, JSON.toJSONString(msg), 10, TimeUnit.MINUTES); } }

preloadVehicles在系统启动后执行一次,后续车辆基本信息都从 Redis 的 Hash 结构里取,查询复杂度是 O(1),数据库压力几乎为零。这部分的另一个用途是给新接入的页面做初始化——当页面刷新时,WebSocket 只推送新消息,初始的位置数据就得靠 Redis 兜底,否则页面打开的瞬间地图上全是空点。这个场景很常见,我一般建议在浏览器页面加载完成后,先调用一次 REST 接口从 Redis 拉全量最新位置,再建立 WebSocket 订阅增量更新,这样前端体验最好。

5. 避坑与排查:把这套系统真正跑起来要过的五关

5.1 车辆漂移:坐标系混用是头号元凶

现象:车辆轨迹在地图上正常移动,但整体位置偏移,车开到河里或者道路几十米开外的地方。

原因:模拟器生成的是 WGS-84 坐标系经纬度,前端地图用的是 GCJ-02 坐标系,两种坐标系在城市区域会有几十米的偏差,看起来就是“漂移”。很多刚入手的人以为是硬件问题,折腾半天发现是坐标没转换。

解决:统一在后端做坐标转换,模拟器输出原坐标,接入层或消费层拿到消息后调一次坐标转换工具,转成 GCJ-02 再推给前端。转换代码用标准算法就行,上百行网上一搜一大片。重点是把转换放在后端,前端不再做任何坐标处理。

5.2 Kafka 消费积压:广播阻塞了整个消费线程

现象:模拟器正常运行,Kafka 控制台显示生产者消息数量在涨,但消费者只消费了一小部分,积压越来越严重,页面上的车辆位置最新更新时间明显滞后。

原因:消费者线程同步调broadcast(),而broadcast()内部逐条 session 同步发送,当 WebSocket 连接数较多或者某个浏览器页面卡顿时,发送会阻塞,消费者被卡住无法继续拉取消息,积压由此产生。

解决:把 WebSocket 推送从 Kafka 消费线程中拆出去。消费消息后先更新 Redis,再交给一个异步推送执行器去广播,消费线程本身立刻返回继续拉取下一批。我在源码里把异步推送改成ExecutorService配合submit(),消费者不再直接调broadcast()。实际上手时可以先看 Kafka Lag 指标确认是不是这个原因,再决定优化方向。

5.3 sendText 抛异常导致消费者线程挂起

现象:系统运行几分钟后,页面上所有车辆全部卡死,不再更新,重启后恢复正常,过几分钟又卡死。

原因:某个浏览器页面被强制关闭,WebSocket 连接断开了,但服务器端的session还没有从连接集合里移除。下一次循环广播时对失效 session 调用sendText抛出IOException,异常没有拦截,导致整个线程中断,后续所有车辆的推送全部停止。

解决:给sendText调用包一层 try-catch,捕获到IOException就立即把 session 从SESSIONS集合中移除,同时关闭连接释放资源。这个异常处理看起来很简单,但它直接决定系统的可用性,我见过不少项目忽略这个细节导致推送服务不稳定。

5.4 时间戳用错:用后端时间给 GPS 报文盖章

现象:车辆在隧道里行驶时,WebSocket 推送的消息到达时间正常,但前端用时间排序后,车辆位置看起来在倒退,轨迹线来回穿插,行驶里程统计偏大。

原因:模拟器或接入层在数据落库时,用了System.currentTimeMillis()作为最终时间,而这个时间是服务端接收调度的时间,不是 GPS 设备采集的时间。当消息因为网络波动延迟到达时,服务端时间戳会比设备时间晚,前端按时间排序就把顺序打乱了。

解决:消息体里始终保留设备上报的gpsTime,所有排序和轨迹回放都用它。服务端时间只在写入日志或计算处理延迟时使用,绝不覆盖设备时间。这个教训之后我一直把“时间戳只能由数据源产生”当成铁律。

5.5 MySQL 批量插入没提速,先查数据包大小

现象:把单条 INSERT 改成批量 INSERT 后,写入速度不仅没提升,反而频繁报错,日志里出现PacketTooBigException。

原因:MySQL 默认的max_allowed_packet是 4MB,位置记录表字段较多,一次批量插入 2000 条的消息体很容易超过这个限制,导致整批失败。

解决:后端批量插入按 500 条一批切割,或者执行前先算一下 JSON 报文大小。我通常会顺手把 MySQL 的max_allowed_packet调整到 16MB,给后续扩展留余量。两个方案都做,效果最好,这个参数在配置文件中改一行就能生效。

6. 端到端验证与压测:一条命令确认链路还活着

6.1 写一个最小 WebSocket 客户端做冒烟测试

拿到源码跑起来之后,我习惯先不打开浏览器,而是用一个最小客户端直接连 WebSocket 端点,确认消息真的能推出来。这个方法比打开地图页面更快更可靠,也方便在服务器上排查问题。

public class WsSmokeTest { public static void main(String[] args) throws Exception { // 本地起一个 WebSocket 连接,不依赖浏览器 WebSocketClient client = new WebSocketClient(new URI("ws://localhost:8080/ws/bus")) { @Override public void onMessage(String message) { // 解析经纬度字段,打印结构确认数据完整 GpsMessage msg = JSON.parseObject(message, GpsMessage.class); System.out.printf("车辆 %s 位置 %.6f,%.6f 速度 %d km/h%n", msg.getPlateNo(), msg.getLng(), msg.getLat(), msg.getSpeed()); } }; client.connect(); } }

这个测试类不用集成任何测试框架,main 方法就能跑。拿到消息后打印车牌、经纬度和速度,确认三件事:消息能持续到达、JSON 字段完整、推送间隔正常。如果客户端连不上,优先检查 WebSocket 端点路径和端口,别先怀疑模拟器。

6.2 用“消费积压量”判断系统健康度

冒烟测试通过之后,还有一个更长期的验证指标值得关注:Kafka 消费者积压量(Lag)。这个数值代表消费者还没处理的消息条数,理想情况下应该接近零。如果积压量持续增加,说明消费者处理速度跟不上生产者速度,这个指标能提前暴露问题,比等到页面卡死再排查省事得多。

我经常在测试环境用一段脚本每 5 秒打印一次 Lag 值,观察它是否稳定。启动模拟器后,积压量先飙升再下降,说明缓存接收正常;如果积压量一直不降,就得按第 5 章的顺序排查广播阻塞、异常捕获和时间戳问题。用这种方式压了一个晚上,积压量始终在个位数徘徊,这套系统的可靠性基本就有底了。

从那以后我每次做实时系统,都会强制先跑一遍 WebSocket 冒烟测试和 Lag 监控,确认链路通畅再考虑功能扩展。处理完这五类坑,这套源码基本就能稳定运行在单机环境里了,换数据源、加地图适配、调整推送频率这些扩展点,也都在链路里预留好了接口位置。希望这个拆解过程能帮到你,少走我踩过的弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 2:57:01

Godot编辑器移植鸿蒙PC的可行性:从渲染、窗口到沙箱的完整技术拆解

最近社区和几个群里同时在发酵两件事&#xff1a;一边是开源鸿蒙 PC 版开始放出 x86 镜像&#xff0c;另一边是 Godot 4.x 因为开源、跨平台、中文教程越来越多&#xff0c;被不少独立团队选成了正式引擎。两股热度一交汇&#xff0c;于是冒出同一个问题&#xff1a;Godot 编辑…

作者头像 李华
网站建设 2026/10/31 1:56:02

5G NR LDPC编译码链路MATLAB实现:从基图展开到归一化最小和译码

做5G物理层仿真&#xff0c;只要和数据信道沾边&#xff0c;大概率绕不开LDPC码。从LTE时代的Turbo码到NR时代全面转向LDPC&#xff0c;这个转变不是拍脑袋决定的&#xff0c;背后牵扯到吞吐量、译码时延、硬件实现复杂度一串工程账。本篇我就用MATLAB把一套完整的5G NR LDPC编…

作者头像 李华
网站建设 2026/10/19 6:23:06

AI-Native SDLC落地指南:从流程重构到工程化配套

1. 先拆概念&#xff1a;AI-Native SDLC到底在改什么第一次听到"AI-Native SDLC"这个词的人&#xff0c;十有八九会以为它等于"用AI写代码"。如果只是这样理解&#xff0c;那你大概率会失望&#xff1a;Copilot之类工具补全再快&#xff0c;也撑不起一条研…

作者头像 李华
网站建设 2026/10/26 12:38:02

多Agent协作与智能体容错:AI系统可靠性的工程实践

今天这个早报&#xff0c;我想换个聊法&#xff1a;不堆链接&#xff0c;只讲哪些信息值得停一分钟&#xff0c;哪些能直接指导你开工。10月4日的AI圈&#xff0c;表面上风和日丽&#xff0c;其实暗流涌动——Agent生态在密集迭代&#xff0c;开发工具链在加速整合&#xff0c;…

作者头像 李华
网站建设 2026/10/21 2:54:49

AI风向:从Agent到模型部署,AI工程化与内容创作实战

1. 这一天的AI风向&#xff1a;Agent从对话框走向物理世界10月4日&#xff0c;周日&#xff0c;这个点应该不少人还在假期里。我翻了翻这几天技术社区和各个项目的动态&#xff0c;AI圈的信息密度相当大。虽然“AI科技前沿”这个固定栏目我一直在跟&#xff0c;但说真的&#x…

作者头像 李华
网站建设 2026/10/18 23:17:08

ASP.NET DropDownList多选报错?一文详解根因与5种解决方案

前两天在群里看到有人被这个报错卡了一下午&#xff0c;正好我刚帮一个同事排查过同样的异常&#xff0c;就把它仔细说透。在 ASP.NET Web Forms 的世界里&#xff0c;"不能在 DropDownList 中选择多个项"&#xff08;Cannot select multiple items in a DropDownLis…

作者头像 李华