简介:面向Qt与无人机地面站开发初学者及具备一定C++基础的上手项目,基于Windows 10与Qt 5.15.2 MinGW 64-bit环境,使用Qt5和opmapcontrol构建可交互地图,支持谷歌、必应、雅虎、GIS等地图源切换、街道图层选择、缓存及拖动缩放。工程共179个文件,以52个cpp、60个h源码为核心,配合42个png素材、4个ui、4个pro工程文件及svg图标、qrc资源等,压缩包约1.01MB,目录便于按模块查阅。已有1180人学习下载。读者可据此理解地图控件封装、投影转换、航点增删改查与保存加载、home和安全区域设置、运动轨迹绘制等路径,也能观察多源地图与缓存的组织方式,适合作为地面站地图模块的参考原型与二次开发起点。
1. 从一张会漂移的航点图说起:Qt5 互动地图在无人机地面站里的真实位置
很多人第一次给无人机地面站加地图,会先塞一个浏览器控件加载网页地图,结果鼠标事件、图元叠加和串口刷新互相打架。Qt5 互动地图更适合把底图、航点、无人机图标和遥测数据放进同一套场景坐标里:瓦片按 z/x/y 分层加载,经纬度先换算成墨卡托像素,再映射到 QGraphicsView 或 QML 画布。这个方向适合做地面站界面、航线规划和飞行回放的开发者;新手能按步骤跑通一张可拖动缩放的地图,熟手重点盯坐标基准和串口帧解析两处。下面按底图、交互、数据接入、避坑和离线验收拆开讲。
2. 地图底图与坐标链路:瓦片层级、墨卡托换算和纠偏参数
2.1 瓦片金字塔与缩放级别:先定 z 范围再谈清晰度
互动地图的第一层不是控件,而是瓦片金字塔。常见做法是把世界地图切成 256×256 的瓦片,按缩放级别 z 分层,每层是 2 的 z 次方行和列。z=0 一张图覆盖全球,z 每加 1,瓦片数量翻四倍。地面站不需要一直加载到最大级别,先定 z 范围更实际:任务规划看 10~14 级足够,飞行跟随看 14~16 级,局部分辨率不够再往 17、18 级补。范围定大了,离线包体积和首屏请求都会上去,翻车往往不是代码写错,而是瓦片数量失控。
资源里的底图加载器一般会保留一个 z 范围参数,而不是让用户无限滚轮。我习惯在配置里写minZoom、maxZoom,缩放时先夹住级别,再触发瓦片补载。这样在低级别不会因为缺瓦片出现大片灰白,高级别也不会把内存打满。
| 参数 | 常用值 | 说明 |
|---|---|---|
| tileSize | 256 | 瓦片边长,改 512 时所有像素换算都要同步 |
| minZoom | 10 | 任务规划下限,太小会看不清航线 |
| maxZoom | 16 | 飞行跟随上限,再高对普通屏幕意义不大 |
| cacheLimit | 300~800 张 | 内存缓存张数,按瓦片大小和机器内存调 |
| requestLimit | 4~8 | 同时请求数,太高会拖慢界面线程 |
提示:z 范围不是越大越好,先把任务规划和飞行跟随两个场景分开定,后面离线包也按这个范围切。
2.2 Web 墨卡托换算:经纬度到像素的四个参数
瓦片编号和屏幕坐标之间隔着一次墨卡托换算。常见公式里四个关键参数是经度、纬度、缩放级别和瓦片边长。经度决定 x,纬度先转弧度再取对数决定 y。纬度要夹在 ±85.05112878 附近,否则 y 会跑到无穷大,地图直接翻车。下面这段 C++ 函数把经纬度换成某级别下的像素坐标,后面再除以 tileSize 就是瓦片编号。
// 经纬度 -> Web 墨卡托像素坐标;zoom 为缩放级别,tileSize 常用 256 QPointF lonLatToPixel(double lon, double lat, int zoom, int tileSize = 256) { const double n = std::pow(2.0, zoom); // 纬度先夹住,避免 log 域外 lat = qBound(-85.05112878, lat, 85.05112878); double x = (lon + 180.0) / 360.0 * n * tileSize; double sinLat = std::sin(qDegreesToRadians(lat)); double y = (0.5 - std::log((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * M_PI)) * n * tileSize; return QPointF(x, y); }这段函数的输入是经纬度和 z,输出是像素坐标。tileSize要和瓦片源一致,资源里默认 256;如果换成 512 的瓦片,n * tileSize和后续瓦片编号都要改。qBound那行不是可选项,缺了以后高纬度航点会把视图中心拉飞。M_PI在 Qt 环境里一般可用,工程里如果编译不过,换成3.14159265358979323846或项目里的常量。
瓦片编号由像素坐标反推:x = floor(pixelX / tileSize),y = floor(pixelY / tileSize)。屏幕像素和场景像素之间还有一次视图变换,常见误用是把鼠标坐标直接当瓦片像素,结果缩放后航点偏移。我的做法是统一走mapToScene,只在场景坐标里做业务,屏幕坐标只用于鼠标事件入口。
2.3 坐标基准与纠偏:WGS84、GCJ-02、BD-09 不要混用
无人机遥测里常见的经纬度是 WGS84,而不同地图底图可能使用不同的坐标基准。资源里保留了一个坐标转换接口,不把纠偏算法写死在绘制代码里。接入前先确认三件事:遥测帧里的坐标是什么基准、底图瓦片是什么基准、航点文件是什么基准。三个来源只要有一个没对齐,航点就会整体偏移,表现是“地图是对的,飞机图标也在动,但航线就是压不到路上”。
struct GeoPoint { double lon; double lat; }; // 坐标转换入口:只在数据进入地图前调用一次,避免绘制时反复转 GeoPoint toMapPoint(const GeoPoint& src, CoordType srcType, CoordType mapType, const OffsetTable& table);CoordType可以留成枚举,OffsetTable放局部偏移表或转换参数。常见做法是查表加局部插值,或者用工程里已有的转换库,不建议在paintEvent里现算。toMapPoint只在“数据进入地图前”调用一次,地图内部统一存显示坐标。这样切换底图时只需要换入口参数,不用把航点、轨迹、飞机图标全部改一遍。
注意:坐标基准只转一次,转完在地图内部保持同一种坐标。入口转、出口再转,偏移会叠加,排查起来很玄学。
2.4 底图加载流程:缓存目录、命名规则与并发请求
底图加载器一般分三层:内存缓存、本地瓦片目录、网络请求。先查内存,再查本地z/x/y.png,最后才发请求。资源里的目录结构按tiles/{z}/{x}/{y}.png组织,离线包直接平铺进去就能用。下面这段 bash 只是检查目录层级,不涉及具体服务地址。
# 统计每级瓦片数量,快速看离线包有没有缺层 find tiles -type f -name '*.png' | awk -F/ '{print $2}' | sort -n | uniq -c输出里每一行是“数量 + z 级别”。如果 z=12 只有几十张,而相邻级别上千张,八成是切片范围没覆盖任务区。find的路径按实际工程改,-name '*.png'也要和瓦片格式一致。加载器里的并发请求数我一般限制在 4~8,太高会让 UI 线程排队,表现为拖动地图时一卡一卡。请求失败要记 URL 和 z/x/y,不要只打一句“加载失败”,否则离线缺块很难定位。
3. QGraphicsView 互动地图:场景坐标、平移缩放与航点图层
3.1 场景坐标系:把地图当画布,而不是控件
Qt5 做互动地图有两条常见路线:QML 的 Map 类型,或者 QGraphicsView 自绘瓦片。QML 上手快,但离线瓦片和自定义图元容易被插件限制;QGraphicsView 把地图当成一张大画布,瓦片、航点、航线、无人机图标都是图元,交互和图层控制更直接。资源里的示例走的是 QGraphicsView 路线,核心是自定义MapView和MapScene,视图负责鼠标和缩放,场景负责瓦片与业务图元。
场景坐标不是屏幕坐标。地图平移时,场景坐标不变,变的是视图变换;航点图元用场景坐标定位,无人机图标也用场景坐标定位。这样缩放时不需要重新计算每个航点的屏幕位置,只更新视图变换。常见误用是把航点坐标直接设成屏幕像素,一缩放就全乱。
class MapView : public QGraphicsView { Q_OBJECT public: explicit MapView(QWidget* parent = nullptr); protected: void wheelEvent(QWheelEvent* e) override; void mousePressEvent(QMouseEvent* e) override; void mouseMoveEvent(QMouseEvent* e) override; private: QPointF m_lastPos; bool m_dragging = false; };MapView只处理视图层交互,不碰瓦片业务。m_lastPos记录拖动起点,m_dragging防止鼠标移动时误触发航点编辑。构造函数里设置setRenderHint(QPainter::Antialiasing)、setDragMode(QGraphicsView::NoDrag),把拖动交给自定义逻辑,避免和图元移动抢事件。场景里再分瓦片层、航线层、航点层、飞机层,用setZValue控制叠放顺序。
3.2 平移与缩放:鼠标锚点不跳的换算
滚轮缩放最容易翻车的地方是鼠标锚点漂移:用户指着某个航点放大,结果航点跑到屏幕外。原因是直接调scale()改变了视图变换,但没有补偿鼠标下的场景坐标。常见做法是先记录缩放前鼠标对应的场景点,缩放后再取一次,算出差值去调整视图中心。下面这段是简化版,实际工程里还要夹住 z 范围。
void MapView::wheelEvent(QWheelEvent* e) { QPointF before = mapToScene(e->pos()); double factor = e->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15; scale(factor, factor); QPointF after = mapToScene(e->pos()); QPointF delta = after - before; // 把鼠标下的场景点拉回原位,减少锚点漂移 translate(delta.x(), delta.y()); e->accept(); }factor用 1.15 还是 1.2 看手感,太大缩放会跳,太小滚轮要滚很多次。before和after都必须是场景坐标,不能拿屏幕坐标直接减。translate的方向和视图变换有关,如果发现越缩越偏,先把delta取反试一次,再检查是否混用了mapToScene和mapFromScene。拖动平移则更简单:按下记录m_lastPos,移动时算屏幕差值,用horizontalScrollBar()->setValue()或translate()反向移动。
提示:缩放和拖动都只改视图,不改航点坐标。航点坐标一旦跟着视图变,回放和保存都会出错。
3.3 航点、航线与无人机图标:图元分层和命中测试
航点用QGraphicsEllipseItem或自定义WaypointItem,航线用QGraphicsPathItem,无人机图标用QGraphicsPixmapItem。每个图元设置setFlag(QGraphicsItem::ItemIsSelectable)和ItemIsMovable时要谨慎:航点可拖动,飞机图标不可拖动,航线不可选中。命中测试靠QGraphicsScene::items(),不要在mousePressEvent里遍历所有航点做距离计算,图元系统已经做了空间索引。
class WaypointItem : public QGraphicsEllipseItem { public: explicit WaypointItem(int id, QGraphicsItem* parent = nullptr) : QGraphicsEllipseItem(-6, -6, 12, 12, parent), m_id(id) { setBrush(QColor(30, 120, 220)); setPen(QPen(Qt::white, 2)); setFlag(QGraphicsItem::ItemIsSelectable, true); setFlag(QGraphicsItem::ItemIsMovable, true); setZValue(30); } int id() const { return m_id; } private: int m_id; };-6, -6, 12, 12让图元以中心为原点,拖动时位置不会偏。setZValue(30)保证航点压在瓦片和航线上方。m_id用来和业务数据关联,图元移动后通过itemChange或场景信号回写经纬度。航线用QPainterPath按航点顺序连,拐角多的时候可以用quadTo做圆角,但不要为了好看把路径点改掉,回放时还要按原始航点算距离。
飞机图标建议单独一层,setZValue(50),并且setFlag(ItemIsMovable, false)。位置更新只调setPos,不要重建图元。重建图元会让选中状态丢失,鼠标事件也会断,数据一快就表现为图标闪烁。
3.4 交互状态机:拖动、编辑、框选不要互相抢事件
地图交互至少有四种状态:浏览、拖动航点、画航线、框选。没有状态机时,鼠标按下到底该给谁处理全靠事件顺序,很容易出现“想拖地图结果拖动了一个航点”。资源里用一个简单的InteractionMode枚举控制:浏览模式下航点不接收鼠标,编辑模式下航点可拖动,画线模式下场景接收点击。切换模式时统一设置图元的ItemIsMovable和setAcceptedMouseButtons。
enum class InteractionMode { Browse, EditWaypoint, DrawRoute, BoxSelect }; void MapScene::setMode(InteractionMode mode) { m_mode = mode; for (auto* item : m_waypointItems) { bool editable = (mode == InteractionMode::EditWaypoint); item->setFlag(QGraphicsItem::ItemIsMovable, editable); item->setAcceptedMouseButtons(editable ? Qt::LeftButton : Qt::NoButton); } }setMode要在 UI 线程调用,图元列表m_waypointItems只存指针,不拥有对象。setAcceptedMouseButtons(Qt::NoButton)让浏览模式下点击穿透到地图,避免航点挡住拖动。画线模式不要复用航点拖动逻辑,单独用场景的mousePress收集点,否则两种状态会互相覆盖。
4. 遥测帧接入地图:串口解析、位置平滑与轨迹缓存
4.1 串口帧格式:帧头、长度、校验与粘包
地面站地图要动起来,关键是串口遥测。串口是字节流,不是消息流,一次readAll可能读到半帧,也可能读到两帧半。资源里的解析器用固定帧头加长度字段,帧格式如下,具体字段按自己的遥测协议改,但解析思路一样。
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 固定魔数,用来找帧起点 |
| 长度 | 1 字节 | 载荷长度,不含帧头、长度和校验 |
| 消息 ID | 1 字节 | 区分位置帧、状态帧、心跳帧 |
| 载荷 | N 字节 | 经纬度、高度、航向等 |
| 校验 | 2 字节 | CRC 或累加和,按协议定 |
void SerialParser::onReadyRead() { m_buffer += m_serial->readAll(); while (m_buffer.size() >= 6) { // 找帧头,找不到就丢一个字节继续滑 if ((quint8)m_buffer[0] != 0xAA || (quint8)m_buffer[1] != 0x55) { m_buffer.remove(0, 1); continue; } quint8 len = (quint8)m_buffer[2]; int frameLen = len + 5; // 帧头2 + 长度1 + ID1 + 载荷len + 校验1,按实际协议改 if (m_buffer.size() < frameLen) break; QByteArray frame = m_buffer.left(frameLen); m_buffer.remove(0, frameLen); if (!checkCrc(frame)) { emit parseError("crc"); continue; } emit frameReady(parsePayload(frame)); } }m_buffer必须跨次读取保留,不能每次readAll后清空。frameLen的计算最容易错:帧头 2 字节、长度 1 字节、消息 ID 1 字节、载荷len字节、校验 1 或 2 字节,按实际协议加减。while里先找帧头再判长度,找不到帧头就remove(0,1)滑动,不要一次清空缓冲区。CRC 失败只丢弃当前帧,不要把后面的数据一起扔。
注意:串口粘包不是异常,是常态。解析器要能处理半帧、多帧、错帧,不能假设一次读取正好一帧。
4.2 解析线程与信号槽:别在 UI 线程里等 readAll
串口读取和解析放工作线程,UI 线程只接收解析后的位置。常见做法是QThread加moveToThread,串口对象在工作线程里创建和打开,解析器也跟着 worker 走。信号槽用Qt::QueuedConnection跨线程,位置更新频率高时不要每个字节发一次信号,按帧发。
QThread* thread = new QThread(this); SerialWorker* worker = new SerialWorker; worker->moveToThread(thread); connect(thread, &QThread::started, worker, &SerialWorker::open); connect(worker, &SerialWorker::positionUpdated, this, &MapWidget::onPosition); connect(worker, &SerialWorker::parseError, this, &MapWidget::onParseError); thread->start();SerialWorker::open里创建QSerialPort,不要在 UI 线程创建后再跨线程用。positionUpdated发的是解析后的结构体,不是原始QByteArray。parseError要带错误类型和原始帧片段,便于排查。线程退出时先停串口、断连接、thread->quit()、thread->wait(),顺序反了会崩在析构。
4.3 位置平滑与地图跟随:经纬度更新不等于重绘全图
遥测位置会有小抖动,地图上表现为飞机图标来回跳。常见做法是一阶低通:新位置按比例混入旧位置,alpha取 0.2~0.4,太小跟随滞后,太大没有平滑效果。平滑只作用于显示,日志和回放存原始值,否则事后分析会被改过的轨迹误导。
void MapWidget::onPosition(const GeoPoint& p) { m_lat = m_lat + 0.25 * (p.lat - m_lat); m_lon = m_lon + 0.25 * (p.lon - m_lon); QPointF scenePos = geoToScene(m_lon, m_lat); m_droneItem->setPos(scenePos); // 只移动图元,不重建场景 if (m_followDrone) centerOn(m_droneItem); }geoToScene是前面墨卡托换算加视图偏移的封装,输入经纬度,输出场景坐标。m_droneItem->setPos只更新图元位置,不触发全图重绘。centerOn在跟随模式下调用,手动拖动地图时要把m_followDrone置 false,否则用户拖不动。轨迹缓存用环形缓冲,按距离或时间抽稀,不要每个位置点都存,回放时点太密反而看不清。
4.4 轨迹缓存与回放:环形缓冲和抽稀参数
轨迹回放不是把历史点全画出来,而是按时间窗口取点。资源里用环形缓冲存最近 N 个点,回放时按索引推进。抽稀参数常用“最小距离”,比如两点距离小于 2 米就不存,减少抖动点。下面这段只表达结构,具体距离按机型速度调。
struct TrackPoint { double lon; double lat; qint64 ms; }; void TrackBuffer::append(const GeoPoint& p, qint64 ms) { if (!m_points.isEmpty()) { const auto& last = m_points.last(); if (distanceMeters(last.lon, last.lat, p.lon, p.lat) < 2.0) return; } m_points.append({p.lon, p.lat, ms}); if (m_points.size() > m_capacity) m_points.removeFirst(); }m_capacity按内存和回放时长定,几千到几万点都常见。distanceMeters用 Haversine 或局部平面近似,短距离用平面近似就够。回放时不要用QTimer每毫秒推进,按时间戳插值,否则高密度轨迹会卡。抽稀只影响显示轨迹,原始日志另存。
5. 避坑与排查:地图空白、坐标偏移、帧错位的五条血泪记录
5.1 地图一片灰白:瓦片 URL 和 z/x/y 顺序对不上
现象是地图控件能拖动,但底图全是灰白格子,日志里请求不少,返回也有内容,就是贴不上去。原因通常是瓦片 URL 模板里z/x/y顺序写反,或者本地目录层级和加载器拼路径的规则不一致。比如加载器按tiles/{z}/{x}/{y}.png找,离线包却是tiles/{x}/{y}/{z}.png,文件明明存在,程序就是找不到。还有一种情况是缩放级别超出瓦片源范围,z=17 的请求返回空白图,加载器把它当成功缓存了。
解决分三步:先把一个瓦片的完整 URL 或本地路径打印出来,手动在文件管理器里对一遍;再检查minZoom、maxZoom是否夹住;最后看缓存里有没有把空白图当成功结果存下来。加载器里加一条规则:返回图片宽高为 0 或解码失败,不写缓存,并记录 z/x/y。这样下次滑到同一级别不会继续吃空白缓存。
注意:灰白不一定是网络问题,先核对路径模板,再查缓存污染。
5.2 航点整体偏移几百米:坐标基准混用且转错了入口
现象是飞机图标和航点都在动,位置关系也对,但整条航线压在底图旁边,偏移量固定。原因多半是遥测坐标是 WGS84,底图用的是另一种基准,绘制时直接叠上去,没有做入口转换;或者转换做了两次,入口转一次,绘制时又转一次,偏移叠加。还有一种隐蔽情况:航点文件从别处导入,文件里已经是显示坐标,导入后又被当成原始坐标转了一遍。
解决方法是把坐标基准当成数据类型管理:串口解析出来的坐标标记为原始基准,航点导入时标记来源,进入地图场景前统一走一次toMapPoint,场景内部只存显示坐标。排查时拿一个已知点做对照,分别在入口、场景、图元三处打印经纬度,看在哪一步发生跳变。不要靠肉眼调偏移量,调出来的参数换一个区域就失效。
5.3 串口一开地图就卡:UI 线程 readAll 加高频重绘
现象是串口没数据时地图很顺,一接遥测就开始卡,拖动地图掉帧,飞机图标一闪一闪。原因常见有两个:串口readyRead连到 UI 线程的槽,槽里直接readAll和解析;位置每来一帧就调用update()或重建图元,刷新频率跟着串口波特率走。波特率高的时候一秒钟几十上百帧,UI 线程根本画不过来。
解决是把串口读取和解析挪到工作线程,UI 线程只接收解析后的位置;位置更新只调setPos,不调用全场景update();刷新频率用定时器合并,比如 30~60 Hz 取最新位置。图元不要重建,重建会丢选中状态,也会让事件链断掉。判断是不是这个问题,可以在槽里打时间戳,看两次刷新间隔是不是远小于屏幕刷新周期。
5.4 缩放时鼠标锚点漂移:只调 scale 没补场景差值
现象是鼠标指着某个航点滚轮放大,航点往旁边跑,越放大偏得越远。原因是scale()改变了视图变换,但鼠标下的场景坐标变了,视图中心没跟着补偿。有人在wheelEvent里直接setTransformationAnchor(QGraphicsView::AnchorUnderMouse),这个在简单场景下能用,但和自定义拖动、centerOn混用时容易互相覆盖。
解决是按第 3 章的写法,缩放前后各取一次mapToScene(e->pos()),算差值再调整视图。差值方向如果反了,先取反验证,再检查有没有同时开AnchorUnderMouse。调试时在缩放前后打印鼠标场景坐标,正常情况两者应该很接近;差几百像素就是补偿没生效。
5.5 离线瓦片缺块:目录层级对但 z 范围没覆盖任务区
现象是联网时地图正常,切到无网环境后任务区边缘缺一块,或者高级别全是灰白。原因不是加载器坏了,而是离线包切片时只切了中心区域,z 范围也没覆盖任务区边缘;还有一种情况是瓦片格式是.jpg,加载器只找.png,文件在但匹配不上。
解决是先按任务区外接矩形算瓦片范围,再按 z 范围逐级切片;切片完用统计脚本看每级数量,数量突变就补。加载器里把扩展名做成配置,不要写死。离线验收时不要只看中心点,把任务区四个角和最远航点都拖一遍,缺块会立刻暴露。
6. 离线瓦片与验收:无网环境下的缓存目录、加载耗时和回放校验
6.1 离线瓦片目录与缓存策略
离线包按tiles/{z}/{x}/{y}.png平铺,加载器先查内存缓存,再查本地文件,最后才走网络。无网环境把网络请求关掉,只留本地和内存,避免每次缺块都等超时。下面这段 bash 用来统计每级瓦片数量,切片后先跑一遍,数量明显少于相邻级别的,就是范围没覆盖。
# 按 z 级别统计离线瓦片数量,快速发现缺层 find tiles -type f -name '*.png' | awk -F/ '{print $2}' | sort -n | uniq -c-F/按路径分隔符切,$2是 z 目录;如果目录结构不同,把$2改成对应字段。sort -n让级别按数字排,uniq -c出数量。这个脚本只做粗筛,精确缺块还要按 x/y 范围比对。
6.2 验收指标:首屏、缩放和回放
离线地图验收我一般看三个指标,不通过就不接真实遥测。首屏耗时看从打开窗口到第一张瓦片出现的时间;缩放延迟看连续滚轮时画面跟手程度;回放偏差看同一段轨迹回放后航点与底图是否仍然对齐。表格里的值是参考范围,按机器和瓦片大小调。
| 指标 | 观察方法 | 常见问题 |
|---|---|---|
| 首屏耗时 | 打时间戳,窗口显示到首张瓦片 | 缓存未命中、路径拼错 |
| 缩放延迟 | 连续滚轮 10 次,看是否卡顿 | 瓦片请求过多、重绘全场景 |
| 回放偏差 | 回放已知航点,对比底图 | 坐标转两次、抽稀过度 |
| 缺块率 | 任务区四角拖动检查 | z 范围不足、扩展名不匹配 |
我早前图省事,离线包只切了任务区中心,验收也只看了中心点,结果外场飞到边缘,地图直接缺一块,现场又没有后悔药。从那以后每次离线验收都强制走一遍“四角 + 最远航点 + 回放对齐”,先看统计脚本,再看首屏和缩放,最后才接串口。资源包里保留了可跑的示例工程和参数表,先按第 2 章的坐标链路核对一遍,再接自己的遥测帧,能少走不少弯路。希望帮到你。
本文还有配套的精品资源,点击获取