Wails + Go 实现图形化桌面应用
编写一个热点查看程序,包含百度热搜、微博热搜、头条、知乎等,涉及技术点 Golang 使用golang 1.9, Wails + Vue3 +Vite
效果展示
编写一个热点查看程序,包含百度热搜、微博热搜、头条、知乎等,废话不说上效果图:
-
效果图1:
-
效果图2
- 打包大小
涉及技术点
Golang
使用golang 1.9 编写代码
Wails + vue3
使用Wails技术实现GUI渲染,页面组件使用ant-design-vue,vite进行前端资源打包。
Wails技术
https://wails.io/zh-Hans/docs/introduction
Wails 是一个可让您使用 Go 和 Web 技术编写桌面应用的项目。
将它看作为 Go 的快并且轻量的 Electron 替代品。 您可以使用 Go 的灵活性和强大功能,结合丰富的现代前端,轻松的构建应用程序。
- 原生菜单、对话框、主题和半透明
- Windows、macOS 和 linux 支持
- 内置 Svelte、React 、Preact 、Vue、Lit 和 Vanilla JS 的模板
- 从 JavaScript 轻松调用 Go 方法
- 自动将 Go 结构体转换为 TypeScript 模块
- Windows 上不需要 CGO 或外部 DLL
- 使用 Vite 的实时开发模式
- 可以轻松创建、构建和打包应用的强大命令行工具
- 丰富的 运行时库
- 使用 Wails 构建的应用程序兼容 Apple & Microsoft 商店
colly v2
colly v2 实现数据抓取:
Go colly爬虫框架精简高效【杠杠的】入门到精通 - 掘金 (juejin.cn)
应用程序打包
window环境为例:wails build -clean
将资源文件和程序打包程成独立的exe文件。
环境准备
go环境
从 Go 下载页面 下载 Go,并配置好环境变量,还需要确保的 PATH
环境变量包含您的 ~/go/bin
目录路径
node环境
npm --version 检查环境
WebView2
在window环境下运行,需要保证WebView2,现在window10/11默认已经安装好了,微软强制内置的环境,可以忽略,如果后续环境检测不通过可以再额外进行安装。
Wails 环境
命令行运行 go install github.com/wailsapp/wails/v2/cmd/wails@latest
安装 Wails CLI
环境检测
命令行运行 wails doctor
命令,类似如下结果,说明完成环境配置了。
如果提示 wails 找不到命令,检查 …go/bin 是否配置path环境
PS C:\Users\14639> wails doctor
DEB | Using go webview2loader
Wails CLI v2.5.1
SUCCESS Done.
# System
OS | Windows 10 Home China
Version | 2009 (Build: 22000)
ID | 21H2
Go Version | go1.19.9
Platform | windows
Architecture | amd64
# Wails
Version | v2.5.1
# Dependencies
Dependency | Package Name | Status | Version
WebView2 | N/A | Installed | 113.0.1774.57
Nodejs | N/A | Installed | 16.14.2
npm | N/A | Installed | 8.5.0
*upx | N/A | Available |
*nsis | N/A | Available |
* - Optional Dependency
# Diagnosis
Your system is ready for Wails development!
Optional package(s) installation details:
- upx : Available at https://upx.github.io/
- nsis : More info at https://wails.io/docs/guides/windows-installer/
♥ If Wails is useful to you or your company, please consider sponsoring the project:
https://github.com/sponsors/leaanthony
具体环境配置细节可以参考wails官网:安装 | Wails
项目开发
项目创建
直接使用wails脚手架创建,wails init -n wails-demo -t vue
,使用vue进行开发,这里模式使用的vue3,打包使用的vite。相关技术不了解的同学可以自行学习。
项目结构
- 新增或修改前端依赖相关,需要进入到
frontend
文件夹下 - 项目dev模式运行和打包在项目
根目录
即可
项目命令
- 开发模式启动:
wails dev
- 打包可执行文件:
wails build -clean
- 前端操作:
npm install xxx
代码介绍
先给出源码仓库(码云):wails-demo: wails-demo (gitee.com) 感兴趣的可以下载一下本地运行。下载后直接运行wails dev
即可
- 核心代码介绍
启动类
main.go
程序的运行启动入口
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
// 下面代码不能删除,是为了go打包资源文件
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
// NewMenu 窗口操作菜单
//newMenu := menu.NewMenu()
//FileMenu := newMenu.AddSubmenu("菜单")
//FileMenu.AddText("设置", keys.CmdOrCtrl("t"), func(data *menu.CallbackData) {
// runtime.EventsEmit(app.ctx, "open-file", time.Now().Format("2006-01-02 15:04:05"))
//})
//FileMenu.AddSeparator()
//FileMenu.AddText("退出", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
// runtime.Quit(app.ctx)
//})
// Create application with options
err := wails.Run(&options.App{
Title: "实时热点",
Width: 1024,
Height: 768,
DisableResize: true,
//Menu: newMenu,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}
App.go
主要承担和前端js的通信和方法绑定Bind。
package main
import (
"context"
)
// App struct
type App struct {
ctx context.Context
hsr *HotSearchRouter
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
hsr := &HotSearchRouter{}
hsr.Init()
a.hsr = hsr
}
// Greet returns a greeting for the given name
func (a *App) Greet(index int) []HotSearchDto {
if index == Last {
return []HotSearchDto{}
}
return a.hsr.Route(index).Visit()
}
hot_search.go
爬取热搜数据
package main
import (
"encoding/json"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/gocolly/colly/v2"
"github.com/gocolly/colly/v2/extensions"
"github.com/labstack/gommon/log"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
)
const (
BaiDu = iota
WeiBo
TouTiao
ZhiHu
Last
)
// HotSearchDto 搜索结果项
type HotSearchDto struct {
Sort int `json:"sort"`
Title string `json:"title"`
Desc string `json:"desc"`
Url string `json:"url"`
Hot string `json:"hot"`
}
// IHotSearch 热搜接口
type IHotSearch interface {
BindHTMLSelector()
Visit() []HotSearchDto
}
// BaseSearch 基础搜索服务 实现接口的三个方法
type BaseSearch struct {
Url string
Collector *colly.Collector
Data []HotSearchDto
Limit int
}
//func (bs *BaseSearch) Ajax() {
// fmt.Println("base ajax")
// bs.Data = []HotSearchDto{}
//}
func (bs *BaseSearch) BindHTMLSelector() {
fmt.Println("Nothing to do")
}
func (bs *BaseSearch) Visit() []HotSearchDto {
bs.Data = []HotSearchDto{}
err := bs.Collector.Visit(bs.Url)
if err != nil {
fmt.Printf("%v\n", err)
return []HotSearchDto{}
}
return bs.Data
}
// HotSearchRouter 路由选择器
type HotSearchRouter struct {
Router map[int]IHotSearch
}
func (r *HotSearchRouter) newCollector() *colly.Collector {
return colly.NewCollector(
colly.IgnoreRobotsTxt(),
colly.AllowURLRevisit(),
func(collector *colly.Collector) {
// 设置随机ua
extensions.RandomUserAgent(collector)
// 设置cookiejar
cjar, err := cookiejar.New(nil)
if err == nil {
collector.SetCookieJar(cjar)
}
})
}
func (r *HotSearchRouter) Init() {
r.Router = make(map[int]IHotSearch)
r.Router[BaiDu] = &BaiDuHotSearch{BaseSearch{
Url: "https://top.baidu.com/board?tab=realtime",
Collector: r.newCollector(),
}}
r.Router[BaiDu].BindHTMLSelector()
r.Router[WeiBo] = &WeiBoHotSearch{BaseSearch{
Url: "https://weibo.com/ajax/side/hotSearch",
Collector: r.newCollector(),
}}
r.Router[WeiBo].BindHTMLSelector()
r.Router[TouTiao] = &TouTiaoHotSearch{BaseSearch{
Url: "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc",
Collector: r.newCollector(),
}}
r.Router[TouTiao].BindHTMLSelector()
r.Router[ZhiHu] = &ZhiHuHotSearch{BaseSearch{
Url: "https://tophub.today/n/mproPpoq6O",
Collector: r.newCollector(),
}}
r.Router[ZhiHu].BindHTMLSelector()
}
func (r *HotSearchRouter) Route(key int) IHotSearch {
return r.Router[key]
}
// BaiDuHotSearch 百度
type BaiDuHotSearch struct {
BaseSearch
}
func (hs *BaiDuHotSearch) BindHTMLSelector() {
hs.Collector.OnHTML(".container-bg_lQ801", func(element *colly.HTMLElement) {
element.DOM.Find(".category-wrap_iQLoo").Each(func(index int, itemSelection *goquery.Selection) {
contentSelection := itemSelection.ChildrenFiltered(".content_1YWBm")
title := contentSelection.Find(".c-single-text-ellipsis").Text()
href, _ := contentSelection.ChildrenFiltered("a").Attr("href")
desc := contentSelection.Find(".hot-desc_1m_jR").First().Text()
if len(desc) > 0 {
desc = strings.ReplaceAll(desc, "查看更多>", "")
}
hot := itemSelection.Find(".trend_2RttY .hot-index_1Bl1a").Text()
hs.Data = append(hs.Data, HotSearchDto{
Sort: index,
Title: title,
Url: href,
Desc: desc,
Hot: hot,
})
})
})
}
// WeiBoHotSearch 微博
type WeiBoHotSearch struct {
BaseSearch
}
// BindHTMLSelector 微博 重写父类ajax获取数据
func (hs *WeiBoHotSearch) BindHTMLSelector() {
hs.Collector.OnResponse(func(response *colly.Response) {
if response.StatusCode == http.StatusOK {
var tempMap = make(map[string]interface{})
err := json.Unmarshal(response.Body, &tempMap)
if err != nil {
log.Errorf("json反序列化失败:%v", err)
}
realtimeArr := tempMap["data"].(map[string]interface{})["realtime"].([]interface{})
for i, v := range realtimeArr {
word := v.(map[string]interface{})["word"].(string)
wordScheme := word
wsi := v.(map[string]interface{})["word_scheme"]
if wsi != nil {
wordScheme = wsi.(string)
}
ci := v.(map[string]interface{})["category"]
category := "分类"
if ci != nil {
category = ci.(string)
}
hot := v.(map[string]interface{})["num"].(float64)
hs.Data = append(hs.Data, HotSearchDto{
Sort: i,
Title: word,
Url: "https://s.weibo.com/weibo?q=" + url.QueryEscape(wordScheme),
Desc: fmt.Sprintf("%s: %s", category, word),
Hot: strconv.Itoa(int(hot)),
})
}
return
}
log.Errorf("读取微博ajax接口失败:%s", string(response.Body))
})
}
// 头条
type TouTiaoHotSearch struct {
BaseSearch
}
func (hs *TouTiaoHotSearch) BindHTMLSelector() {
hs.Collector.OnResponse(func(response *colly.Response) {
if response.StatusCode == http.StatusOK {
var tempMap = make(map[string]interface{})
err := json.Unmarshal(response.Body, &tempMap)
if err != nil {
log.Errorf("json反序列化失败:%v", err)
}
dataArr := tempMap["data"].([]interface{})
for i, v := range dataArr {
title := v.(map[string]interface{})["Title"].(string)
link := v.(map[string]interface{})["Url"].(string)
hot := v.(map[string]interface{})["HotValue"].(string)
labelInter := v.(map[string]interface{})["LabelDesc"]
desc := title
if labelInter != nil {
desc = labelInter.(string) + ":" + desc
}
hs.Data = append(hs.Data, HotSearchDto{
Sort: i,
Title: title,
Url: link,
Desc: desc,
Hot: hot,
})
}
return
}
log.Errorf("读取头条ajax接口失败:%s", string(response.Body))
})
}
// 知乎
type ZhiHuHotSearch struct {
BaseSearch
}
func (hs *ZhiHuHotSearch) BindHTMLSelector() {
hs.Collector.OnHTML(".Zd-p-Sc", func(element *colly.HTMLElement) {
element.DOM.Find(".cc-dc-c tbody").First().Find("tr").Each(func(i int, selection *goquery.Selection) {
title := selection.Find(".al a").Text()
href, _ := selection.Find(".al a").Attr("href")
hot := selection.Find("td:nth-child(3)").Text()
hs.Data = append(hs.Data, HotSearchDto{
Sort: i,
Title: title,
Url: element.Request.AbsoluteURL(href),
Desc: title,
Hot: hot,
})
})
})
}
前端核心代码 App.vue
<script setup>
import {reactive} from 'vue'
// import HelloWorld from './components/HelloWorld.vue'
import txImg from './assets/images/tx.gif'
import {Greet} from '../wailsjs/go/main/App'
import { onMounted } from 'vue'
import {
PieChartOutlined,
BarChartOutlined,
DotChartOutlined,
LineChartOutlined} from '@ant-design/icons-vue';
onMounted(() => {
tabClick(0)
})
const data = reactive({
activeKey: 0,
image: [
"https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
txImg,
"https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
txImg,
"https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
],
hotData: {
0: [],
1: [],
2: [],
3: []
},
loading: false
})
function handleMouse(e) {
// e.preventDefault();
}
function tabClick(index) {
data.loading = true
Greet(index).then(result => {
console.log(result)
data.loading = false
data.hotData[index] = result
})
}
function urlClick(url) {
window.runtime.BrowserOpenURL(url)
return false
}
</script>
<template>
<div style="width: 100%; height: 100%;overflow: hidden;padding-bottom: 200px" @contextmenu="handleMouse">
<div style="text-align: center">
<a-image :width="200"
:src="data.image[data.activeKey]"/>
</div>
<div style="padding: 10px;overflow: auto;height: 100%;">
<a-tabs v-model:activeKey="data.activeKey" type="card" @tabClick="tabClick">
<a-tab-pane :key="0">
<template #tab>
<span>
<pie-chart-outlined />
百度
</span>
</template>
<a-list item-layout="horizontal" :data-source="data.hotData[0]" rowKey="sort" :loading="data.loading">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :description="item.desc">
<template #title>
<a href="javascript:" @click="urlClick(item.url)">{{ item.title }}</a>
</template>
<template #avatar>
<a-avatar v-if="item.sort < 3" style="background-color: red">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=3 && item.sort < 6" style="background-color: #f56a00">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=6 && item.sort < 9" style="background-color: #c8b50eff">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else>{{ item.sort + 1 }}</a-avatar>
</template>
</a-list-item-meta>
<div>热度:
<span v-if="item.sort < 3" style="color: red">{{ item.hot }}</span>
<span v-else-if="item.sort >=3 && item.sort < 6" style="color: #f56a00">{{ item.hot }}</span>
<span v-else-if="item.sort >=6 && item.sort < 9" style="color: #c8b50eff">{{ item.hot }}</span>
<span v-else>{{ item.hot }}</span>
</div>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
<a-tab-pane :key="1">
<template #tab>
<span>
<bar-chart-outlined />
微博
</span>
</template>
<a-list item-layout="horizontal" :data-source="data.hotData[1]" rowKey="sort" :loading="data.loading">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :description="item.desc">
<template #title>
<a href="javascript:" @click="urlClick(item.url)">{{ item.title }}</a>
</template>
<template #avatar>
<a-avatar v-if="item.sort < 3" style="background-color: red">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=3 && item.sort < 6" style="background-color: #f56a00">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=6 && item.sort < 9" style="background-color: #c8b50eff">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else>{{ item.sort + 1 }}</a-avatar>
</template>
</a-list-item-meta>
<div>热度:
<span v-if="item.sort < 3" style="color: red">{{ item.hot }}</span>
<span v-else-if="item.sort >=3 && item.sort < 6" style="color: #f56a00">{{ item.hot }}</span>
<span v-else-if="item.sort >=6 && item.sort < 9" style="color: #c8b50eff">{{ item.hot }}</span>
<span v-else>{{ item.hot }}</span>
</div>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
<a-tab-pane :key="2">
<template #tab>
<span>
<dot-chart-outlined />
头条
</span>
</template>
<a-list item-layout="horizontal" :data-source="data.hotData[2]" rowKey="sort" :loading="data.loading">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :description="item.desc">
<template #title>
<a href="javascript:" @click="urlClick(item.url)">{{ item.title }}</a>
</template>
<template #avatar>
<a-avatar v-if="item.sort < 3" style="background-color: red">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=3 && item.sort < 6" style="background-color: #f56a00">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=6 && item.sort < 9" style="background-color: #c8b50eff">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else>{{ item.sort + 1 }}</a-avatar>
</template>
</a-list-item-meta>
<div>热度:
<span v-if="item.sort < 3" style="color: red">{{ item.hot }}</span>
<span v-else-if="item.sort >=3 && item.sort < 6" style="color: #f56a00">{{ item.hot }}</span>
<span v-else-if="item.sort >=6 && item.sort < 9" style="color: #c8b50eff">{{ item.hot }}</span>
<span v-else>{{ item.hot }}</span>
</div>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
<a-tab-pane :key="3">
<template #tab>
<span>
<line-chart-outlined />
知乎
</span>
</template>
<a-list item-layout="horizontal" :data-source="data.hotData[3]" rowKey="sort" :loading="data.loading">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :description="item.desc">
<template #title>
<a href="javascript:" @click="urlClick(item.url)">{{ item.title }}</a>
</template>
<template #avatar>
<a-avatar v-if="item.sort < 3" style="background-color: red">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=3 && item.sort < 6" style="background-color: #f56a00">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else-if="item.sort >=6 && item.sort < 9" style="background-color: #c8b50eff">{{ item.sort + 1 }}</a-avatar>
<a-avatar v-else>{{ item.sort + 1 }}</a-avatar>
</template>
</a-list-item-meta>
<div>热度:
<span v-if="item.sort < 3" style="color: red">{{ item.hot }}</span>
<span v-else-if="item.sort >=3 && item.sort < 6" style="color: #f56a00">{{ item.hot }}</span>
<span v-else-if="item.sort >=6 && item.sort < 9" style="color: #c8b50eff">{{ item.hot }}</span>
<span v-else>{{ item.hot }}</span>
</div>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
</a-tabs>
</div>
</div>
</template>
<style>
.ant-layout-header {
background-color: #7cb305;
}
</style>
更多推荐
所有评论(0)