Fyne ( go跨平台GUI )项目实战-员工在线考试系统APP开发详解

174 阅读42分钟

# Fyne ( go跨平台GUI )项目实战-员工在线考试系统APP开发详解

1.员工在线考试系统介绍

1.1. 系统概述

员工在线考试系统是一个基于Go语言和FYNE框架开发的跨平台应用程序,专门为企业员工培训和考核设计。该系统支持Windows、Linux、macOS以及移动平台(Android和iOS),为员工提供便捷的在线学习和考试环境。

系统主要面向制造业企业,特别是需要对不同岗位员工进行定期培训和考核的场景。通过该系统,企业可以有效地管理题库、组织考试、自动评分并生成成绩报告。

1.2. 核心功能

1.2.1 用户认证

系统提供安全的用户登录机制,员工使用工号和生日作为登录凭证。登录信息通过API与服务器验证,确保只有合法用户可以访问系统。

1.2.2 主功能界面

登录成功后,用户进入主功能界面,包含以下模块:

  • 题库管理:管理各类试题
  • 岗位学习:针对不同岗位的学习材料
  • 岗位测试:岗位相关知识测试
  • 岗位考试:正式的岗位考核
  • 系统设置:个性化设置
  • 注销登录:安全退出系统

1.2.3 在线考试

在线考试是系统的核心功能,具有以下特点:

  • 自动从服务器下载个性化试卷
  • 支持图文并茂的题目展示
  • 实时保存答题进度
  • 考试计时功能
  • 漏题检查机制
  • 自动评分和成绩分析

1.2.4 数据管理

系统采用SQLite本地数据库存储考试数据,确保在网络不稳定的情况下也能正常使用。考试完成后,成绩数据会自动上传到服务器进行汇总分析。

1.3. 技术特点

1.3.1 跨平台支持

基于FYNE框架开发,一套代码支持多个平台,降低了开发和维护成本。

1.3.2 现代化界面

采用响应式设计,界面美观,操作直观,提升用户体验。

1.3.3 数据安全

通过HTTPS与服务器通信,确保数据传输安全;本地数据采用SQLite存储,保证数据完整性。

1.3.4 离线能力

支持离线答题,网络恢复后自动同步数据,适应各种网络环境。

1.4. 应用场景

该系统特别适用于以下场景:

  • 企业员工岗位技能考核
  • 新员工入职培训测试
  • 定期安全知识考试
  • 专业技能认证考试
  • 培训效果评估

1.5. 系统优势

1.5.1 易用性

界面简洁直观,操作流程清晰,员工可以快速上手使用。

1.5.2 灵活性

支持不同岗位的个性化试卷,满足企业多样化培训需求。

1.5.3 高效性

自动化考试流程和评分机制,大大减少了人工操作,提高了考试效率。

1.5.4 可追溯性

完整的考试记录和成绩管理,便于企业进行培训效果分析和员工能力评估。

1.6. 总结

员工在线考试系统是一套功能完善、技术先进、易于部署的企业培训考核解决方案。它不仅提高了企业培训管理的效率,还通过科学的考核机制帮助提升员工专业技能,是现代企业人力资源管理的重要工具。

1.7 项目结构图

OnlineExamApp/
├── api/                     # API接口层
│   └── examsApi.go          # 考试相关API接口
├── components/              # 自定义组件
│   ├── myButtonCircle.go    # 圆形按钮组件
│   ├── myButtonRectangle.go # 矩形按钮组件
│   ├── myCircle.go          # 圆形组件
│   ├── myHyperlink.go       # 超链接组件
│   ├── myLabel.go           # 标签组件
│   └── myNumberEntry.go     # 数字输入框组件
├── config/                  # 配置文件
│   └── config.go            # 服务器配置
├── mock/                    # 模拟数据
│   └── mock.go              # 考试模拟数据
├── model/                   # 数据模型
│   ├── exams.go             # 考试数据模型
│   └── userLogin.go         # 用户登录数据模型
├── service/                 # 服务层
│   └── examsService.go      # 考试相关服务
├── sql/                     # SQL脚本
│   └── AppUpExams.sql       # 数据库初始化脚本
├── theme/                   # 主题和资源文件
│   ├── themeIcon.go         # 图标资源
│   └── themeTtf.go          # 字体资源
├── utils/                   # 工具类
│   ├── dataConv.go          # 数据转换工具
│   ├── error.go             # 错误处理工具
│   ├── sqlite.go            # SQLite数据库工具
│   └── util.go              # 通用工具
├── Demo.md                  # 打包说明文档
├── build.bat                # Windows构建脚本
├── demoMobileOnlineTest.go  # 移动端演示
├── login.go                 # 登录界面
├── main.go                  # 程序入口
├── mainFrame.go             # 主界面框架
├── upExams.json             # 考试数据JSON文件
└── widgetTable.go           # 表格组件

1.8 运行效果图

1.8.1 员工登录

在线考试-登录.png

1.8.2 主功能界面

主界面.png

1.8.3 在线考试功能界面

  • 下载试卷 在线考试界面.png
  • 开始考试 在线考试-答题.png
  • 漏题检查

在线考试-漏题检查.png

  • 选定漏题序号定位答题

在线考试-漏题检查-选择题号进行定位答题.png

  • 保存上传前的漏题检查

在线考试-保存漏题检查.png

  • 保存上传确认

在线考试-保存上传.png

  • 考试结果判定

在线考试-考试结果.png

2. FYNE组件库介绍及下载、安装、配置

2.1 FYNE组件库介绍

FYNE是一个用Go语言编写的跨平台GUI框架,允许开发者使用单一代码库为桌面和移动平台创建原生外观的应用程序。FYNE提供了丰富的UI组件和现代化的设计风格,使得构建美观且功能强大的应用程序变得简单。

FYNE的主要特性包括:

  • 跨平台支持(Windows、macOS、Linux、Android、iOS)
  • 原生外观和感觉
  • 简单易用的API
  • 内置主题支持
  • 丰富的UI组件集合

2.2 下载与安装

根据项目配置,本项目使用Go 1.17版本和FYNE v2.2.3版本。安装过程如下:

# 安装指定版本的FYNE
go get fyne.io/fyne/v2@v2.2.3

2.3 配置

FYNE的配置主要通过环境变量和代码配置实现。在本项目中,我们在main.go文件中进行了相关配置:

// 设置系统主题颜色为高亮
os.Setenv("FYNE_THEME", "light")
// 设置系统界面缩放大小
os.Setenv("FYNE_SCALE", "1.0")

3. 自定义theme

3.1 FYNE中文乱码及自定义中文字体设置

在FYNE中显示中文可能会遇到乱码问题,解决方法是使用自定义字体。本项目通过在theme/themeTtf.go中嵌入中文字体文件来解决中文显示问题:

package theme

import (
	_ "embed"
	"image/color"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/theme"
)

type MyTheme struct{}

var _ fyne.Theme = (*MyTheme)(nil)

//go:embed fonts/Alibaba-PuHuiTi-Medium.ttf
var hmTTf []byte

// return bundled font resource
func (m MyTheme) Font(s fyne.TextStyle) fyne.Resource {
	return &fyne.StaticResource{
		StaticName:    "Alibaba-PuHuiTi-Medium.ttf",
		StaticContent: hmTTf,
	}
}

func (*MyTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
	return theme.DefaultTheme().Color(n, v)
}

func (*MyTheme) Icon(n fyne.ThemeIconName) fyne.Resource {
	return theme.DefaultTheme().Icon(n)
}

func (*MyTheme) Size(n fyne.ThemeSizeName) float32 {
	return theme.DefaultTheme().Size(n)
}

main.go中应用自定义主题:

// 设置主题颜色,只有黑色和白色,默认为黑色
a.Settings().SetTheme(&mytheme.MyTheme{})

3.2 将字体打包进二进制文件

使用go:embed指令将字体文件直接嵌入到二进制文件中,避免外部依赖:

//go:embed fonts/Alibaba-PuHuiTi-Medium.ttf
var hmTTf []byte

3.3 go:embed图标资源打包到变量

项目中使用go:embed将图标资源打包到变量中,例如在theme/themeIcon.go中:

//go:embed images/myEnter.png
var myEnter []byte

func (m MyTheme) EnterIcon() fyne.Resource {
	return &fyne.StaticResource{
		StaticName:    "myEnter.png",
		StaticContent: myEnter,
	}
}

4. Go Sqlite操作

项目使用SQLite作为本地数据库,通过utils/sqlite.go文件封装数据库连接和操作:

package utils

import (
	"database/sql"
	"fmt"

	"fyne.io/fyne/v2/canvas"

	_ "github.com/mattn/go-sqlite3"
)

var (
	dbDriverName = "sqlite3"
	dbName       = "./data.db3"
)

var db *sql.DB

func GetConnection() (*sql.DB, error) {
	var err error
	sysType := GetGoos()
	fmt.Printf("sysType: %v\n", sysType)
	if sysType == "windows" {
		dbName = "./data.db3"
	}
	if sysType == "android" {
		dbName = "/data/data/com.example.onlineexamapp/files/data.db3"
	}

	db, err = sql.Open(dbDriverName, dbName)
	return db, err
}

// 封装一个处理error的函数,处理程序中的 err,
// 有err的地方直接调用这个函数就可以了
func CheckErr(e error, lblMsg *canvas.Text) bool {
	if e != nil {
		lblMsg.Text = e.Error()
		lblMsg.Refresh()
		// log.Fatal(e)
		return true
	}
	return false
}

数据库操作在service/examsService.go中实现,包括创建表、插入数据、查询数据等操作。

5. 模拟数据mock的应用

项目在mock/mock.go中提供了模拟数据,用于测试和开发:

package mock

import "OnlineExamApp/model"

func GetExamsData() []model.Exams {
	examDatas := []model.Exams{
		{Number: 1, Title: "1.我公司的质量方针中的第三句话确保质量不断的满足顾客需求所包含的意义是()", SelectItems: "A♀每个员工都必须按程序和标准规范自己的行为,新上岗和转岗的员工需要培训合格后才能上岗♂B♀体现以顾客为关注焦点和持续改进的思想♂C♀体现竞争意识,只有达到同级最优,企业才能生存发展,员工才能安居乐业♂D♀减少变差和浪费,要求每个员工都要以零缺陷的工作质量来实现零缺陷的产品和服务质量", ImagePath: "", Answer: "B", CorrectAnswer: "B", Scores: "1", Results: "0", NID: 1},
		// ... 更多模拟数据
	}
	return examDatas
}

6. 服务器IP配置

服务器配置在config/config.go中定义:

package config

type ServerConfig struct {
	Ip   string
	Port string
}

func GetServerPath() string {
	serverPath := ServerConfig{
		Ip:   "http://192.168.10.19",
		Port: "8888",
	}
	return serverPath.Ip + ":" + serverPath.Port
}

7. 项目功能模块详解

7.1 流程图

用户登录 → 验证身份 → 进入主界面 → 选择岗位考试 → 下载试卷 → 答题 → 提交答案 → 计算成绩 → 上传成绩

7.2 数据结构体

model/exams.go中定义了考试相关的数据结构:

type Exams struct {
	Number        int
	Title         string
	SelectItems   string
	ImagePath     string
	Answer        string
	CorrectAnswer string
	Scores        string
	Results       string
	NID           int
}

type OnLineExams struct {
	ID       string
	S所属类型ID  string
	S题目类型    string
	S所属岗位ID  string
	S岗位名称    string
	S题目内容    string
	S题目图片路径  string
	S备选答案    string
	S正确答案    string
	S题目分数    string
	S出题人     string
	S所属部门ID  string
	S所属部门    string
	S试卷生成时间  string
	S试卷生成人   string
	S试卷答题时间  string
	S试卷答题人   string
	S答题人所选答案 string
	S试卷答题成绩  string
	SID      string
	S考试计划号   string
	S通用专用    string
}

7.3 数据操作服务函数

service/examsService.go中实现了数据操作服务函数,包括:

  1. 创建检查表:
//创建数据表的函数
func CreateCheckTableExams(db *sql.DB) error {
	sql := `create table if not exists "exams" (
		"id" integer primary key autoincrement,
		"所属类型ID"  text not null,
		"题目类型"     text not null,
		"所属岗位ID"   text not null,
		"岗位名称"     text not null,
		"题目内容"     text not null,
		"题目图片路径"   text not null,
		"备选答案"     text not null,
		"正确答案"    text not null,
		"题目分数"     text not null,
		"出题人"      text not null,
		"所属部门ID"   text not null,
		"所属部门"     text not null,
		"试卷生成时间"   text not null,
		"试卷生成人"    text not null,
		"试卷答题时间"   text not null,
		"试卷答题人"    text not null,
		"答题人所选答案"  text not null,
		"试卷答题成绩"   text not null,
		"SID"       text not null,
		"考试计划号"    text not null,
		"通用专用"     text not null
	)`
	_, err := db.Exec(sql)
	return err
}
  1. 创建表(会先删除已存在的表):
//创建数据表的函数
func CreateTableExams(db *sql.DB) error {
	sql := `drop table  if exists "exams";create table exams(
		"id" integer primary key autoincrement,
		"所属类型ID"  text not null,
		"题目类型"     text not null,
		"所属岗位ID"   text not null,
		"岗位名称"     text not null,
		"题目内容"     text not null,
		"题目图片路径"   text not null,
		"备选答案"     text not null,
		"正确答案"    text not null,
		"题目分数"     text not null,
		"出题人"      text not null,
		"所属部门ID"   text not null,
		"所属部门"     text not null,
		"试卷生成时间"   text not null,
		"试卷生成人"    text not null,
		"试卷答题时间"   text not null,
		"试卷答题人"    text not null,
		"答题人所选答案"  text not null,
		"试卷答题成绩"   text not null,
		"SID"       text not null,
		"考试计划号"    text not null,
		"通用专用"     text not null
	)`
	_, err := db.Exec(sql)
	return err
}
  1. 插入单条数据:
//########插入数据#########
func InsertData(db *sql.DB, data model.OnLineExams) error {
	sql := `insert into exams(
		所属类型ID,
		题目类型,
		所属岗位ID,
		岗位名称,
		题目内容,
		题目图片路径,
		备选答案,
		正确答案,
		题目分数,
		出题人,
		所属部门ID,
		所属部门,
		试卷生成时间,
		试卷生成人,
		试卷答题时间,
		试卷答题人,
		答题人所选答案,
		试卷答题成绩,
		SID,
		考试计划号,
		通用专用) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
	stmt, err := db.Prepare(sql)
	if err != nil {
		return err
	}
	_, err = stmt.Exec(
		data.S所属类型ID,
		data.S题目类型,
		data.S所属岗位ID,
		data.S岗位名称,
		data.S题目内容,
		data.S题目图片路径,
		data.S备选答案,
		data.S正确答案,
		data.S题目分数,
		data.S出题人,
		data.S所属部门ID,
		data.S所属部门,
		data.S试卷生成时间,
		data.S试卷生成人,
		data.S试卷答题时间,
		data.S试卷答题人,
		data.S答题人所选答案,
		data.S试卷答题成绩,
		data.SID,
		data.S考试计划号,
		data.S通用专用)
	return err
}
  1. 批量插入数据:
func InsertDatas(db *sql.DB, data []model.OnLineExams, lblMsg *canvas.Text) error {
	//fmt.Printf("data: %v\n", data)
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	sql := `insert into exams(
		所属类型ID,
		题目类型,
		所属岗位ID,
		岗位名称,
		题目内容,
		题目图片路径,
		备选答案,
		正确答案,
		题目分数,
		出题人,
		所属部门ID,
		所属部门,
		试卷生成时间,
		试卷生成人,
		试卷答题时间,
		试卷答题人,
		答题人所选答案,
		试卷答题成绩,
		SID,
		考试计划号,
		通用专用) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
	stmt, err := tx.Prepare(sql)
	if err != nil {
		return err
	}
	i := 0
	for k, v := range data {
		//fmt.Printf("v: %v\n", v)
		lblMsg.Text = "正在保存第【" + strconv.Itoa(k) + "】条记录,请稍后。。。"
		lblMsg.Refresh()
		_, err := stmt.Exec(
			v.S所属类型ID,
			v.S题目类型,
			v.S所属岗位ID,
			v.S岗位名称,
			v.S题目内容,
			v.S题目图片路径,
			v.S备选答案,
			v.S正确答案,
			v.S题目分数,
			v.S出题人,
			v.S所属部门ID,
			v.S所属部门,
			v.S试卷生成时间,
			v.S试卷生成人,
			v.S试卷答题时间,
			v.S试卷答题人,
			v.S答题人所选答案,
			v.S试卷答题成绩,
			v.SID,
			v.S考试计划号,
			v.S通用专用)
		if err != nil {
			lblMsg.Text = err.Error()
			lblMsg.Refresh()
			tx.Rollback()
			return err
		}
		i = k
	}

	err = tx.Commit()
	lblMsg.Text = "数据保存完毕,共保存【" + strconv.Itoa(i+1) + "】条记录!"
	lblMsg.Refresh()
	return err
}
  1. 查询总记录数量:
//#########查询数据并且返回切片列表########

//查询总记录数量
func QueryDataMaxRowsCount(db *sql.DB) (rowsCount int, e error) {
	sql := `select count(id) from exams`
	stmt, err := db.Prepare(sql)
	if err != nil {
		return 0, err
	}
	rows, err := stmt.Query()
	if err != nil {
		return 0, err
	}
	var result = 0
	for rows.Next() {
		rows.Scan(&result)
	}
	return result, nil
}
  1. 查询所有数据:
//查询所有数据
func QueryDataAll(db *sql.DB) (exams []model.OnLineExams, e error) {
	sql := `select * from exams order by id`
	stmt, err := db.Prepare(sql)
	if err != nil {
		return nil, err
	}
	rows, err := stmt.Query()
	if err != nil {
		return nil, err
	}
	var result = make([]model.OnLineExams, 0)
	for rows.Next() {
		var (
			S所属类型ID  string
			S题目类型    string
			S所属岗位ID  string
			S岗位名称    string
			S题目内容    string
			S题目图片路径  string
			S备选答案    string
			S正确答案    string
			S题目分数    string
			S出题人     string
			S所属部门ID  string
			S所属部门    string
			S试卷生成时间  string
			S试卷生成人   string
			S试卷答题时间  string
			S试卷答题人   string
			S答题人所选答案 string
			S试卷答题成绩  string
			SID      string
			S考试计划号   string
			S通用专用    string
		)
		var id int
		rows.Scan(&id,
			&S所属类型ID,
			&S题目类型,
			&S所属岗位ID,
			&S岗位名称,
			&S题目内容,
			&S题目图片路径,
			&S备选答案,
			&S正确答案,
			&S题目分数,
			&S出题人,
			&S所属部门ID,
			&S所属部门,
			&S试卷生成时间,
			&S试卷生成人,
			&S试卷答题时间,
			&S试卷答题人,
			&S答题人所选答案,
			&S试卷答题成绩,
			&SID,
			&S考试计划号,
			&S通用专用)
		result = append(result,
			model.OnLineExams{
				ID:       strconv.Itoa(id),
				S所属类型ID:  S所属类型ID,
				S题目类型:    S题目类型,
				S所属岗位ID:  S所属岗位ID,
				S岗位名称:    S岗位名称,
				S题目内容:    S题目内容,
				S题目图片路径:  S题目图片路径,
				S备选答案:    S备选答案,
				S正确答案:    S正确答案,
				S题目分数:    S题目分数,
				S出题人:     S出题人,
				S所属部门ID:  S所属部门ID,
				S所属部门:    S所属部门,
				S试卷生成时间:  S试卷生成时间,
				S试卷生成人:   S试卷生成人,
				S试卷答题时间:  S试卷答题时间,
				S试卷答题人:   S试卷答题人,
				S答题人所选答案: S答题人所选答案,
				S试卷答题成绩:  S试卷答题成绩,
				SID:      SID,
				S考试计划号:   S考试计划号,
				S通用专用:    S通用专用})
	}
	return result, nil
}
  1. 查询上传考试数据:
//查询所有数据
func QueryDataUpExams(db *sql.DB) (exams []model.UpExams, e error) {
	sql := `select 试卷答题时间,试卷答题人,答题人所选答案,试卷答题成绩,SID from exams order by id`
	stmt, err := db.Prepare(sql)
	if err != nil {
		return nil, err
	}
	rows, err := stmt.Query()
	if err != nil {
		return nil, err
	}
	var result = make([]model.UpExams, 0)
	for rows.Next() {
		var (
			S试卷答题时间  string
			S试卷答题人   string
			S答题人所选答案 string
			S试卷答题成绩  string
			SID      string
		)
		rows.Scan(
			&S试卷答题时间,
			&S试卷答题人,
			&S答题人所选答案,
			&S试卷答题成绩,
			&SID)
		result = append(result,
			model.UpExams{
				S试卷答题时间:  S试卷答题时间,
				S试卷答题人:   S试卷答题人,
				S答题人所选答案: S答题人所选答案,
				S试卷答题成绩:  S试卷答题成绩,
				SID:      SID})
	}
	return result, nil
}
  1. 分页查询数据:
//查询带有条件的数据函数
func QueryDataPage(db *sql.DB, pageCount int, page int) (exams []model.OnLineExams, e error) {
	sql := "select * from exams Limit ? Offset ?"
	stmt, err := db.Prepare(sql)
	if err != nil {
		return nil, err
	}
	rows, err := stmt.Query(strconv.Itoa(pageCount), strconv.Itoa(((page - 1) * pageCount)))
	if err != nil {
		return nil, err
	}
	var result = make([]model.OnLineExams, 0)
	for rows.Next() {
		var (
			S所属类型ID  string
			S题目类型    string
			S所属岗位ID  string
			S岗位名称    string
			S题目内容    string
			S题目图片路径  string
			S备选答案    string
			S正确答案    string
			S题目分数    string
			S出题人     string
			S所属部门ID  string
			S所属部门    string
			S试卷生成时间  string
			S试卷生成人   string
			S试卷答题时间  string
			S试卷答题人   string
			S答题人所选答案 string
			S试卷答题成绩  string
			SID      string
			S考试计划号   string
			S通用专用    string
		)
		var id int
		rows.Scan(&id,
			&S所属类型ID,
			&S题目类型,
			&S所属岗位ID,
			&S岗位名称,
			&S题目内容,
			&S题目图片路径,
			&S备选答案,
			&S正确答案,
			&S题目分数,
			&S出题人,
			&S所属部门ID,
			&S所属部门,
			&S试卷生成时间,
			&S试卷生成人,
			&S试卷答题时间,
			&S试卷答题人,
			&S答题人所选答案,
			&S试卷答题成绩,
			&SID,
			&S考试计划号,
			&S通用专用)
		result = append(result,
			model.OnLineExams{
				ID:       strconv.Itoa(id),
				S所属类型ID:  S所属类型ID,
				S题目类型:    S题目类型,
				S所属岗位ID:  S所属岗位ID,
				S岗位名称:    S岗位名称,
				S题目内容:    S题目内容,
				S题目图片路径:  S题目图片路径,
				S备选答案:    S备选答案,
				S正确答案:    S正确答案,
				S题目分数:    S题目分数,
				S出题人:     S出题人,
				S所属部门ID:  S所属部门ID,
				S所属部门:    S所属部门,
				S试卷生成时间:  S试卷生成时间,
				S试卷生成人:   S试卷生成人,
				S试卷答题时间:  S试卷答题时间,
				S试卷答题人:   S试卷答题人,
				S答题人所选答案: S答题人所选答案,
				S试卷答题成绩:  S试卷答题成绩,
				SID:      SID,
				S考试计划号:   S考试计划号,
				S通用专用:    S通用专用})
	}
	return result, nil
}
  1. 根据ID查询数据:
//查询ID对应的数据
func QueryDataID(db *sql.DB, id string) (exams []model.OnLineExams, e error) {
	sql := "select * from exams where id=?"
	stmt, err := db.Prepare(sql)
	if err != nil {
		return nil, err
	}
	rows, err := stmt.Query(id)
	if err != nil {
		return nil, err
	}
	var result = make([]model.OnLineExams, 0)
	for rows.Next() {
		var (
			S所属类型ID  string
			S题目类型    string
			S所属岗位ID  string
			S岗位名称    string
			S题目内容    string
			S题目图片路径  string
			S备选答案    string
			S正确答案    string
			S题目分数    string
			S出题人     string
			S所属部门ID  string
			S所属部门    string
			S试卷生成时间  string
			S试卷生成人   string
			S试卷答题时间  string
			S试卷答题人   string
			S答题人所选答案 string
			S试卷答题成绩  string
			SID      string
			S考试计划号   string
			S通用专用    string
		)
		var id int
		rows.Scan(&id,
			&S所属类型ID,
			&S题目类型,
			&S所属岗位ID,
			&S岗位名称,
			&S题目内容,
			&S题目图片路径,
			&S备选答案,
			&S正确答案,
			&S题目分数,
			&S出题人,
			&S所属部门ID,
			&S所属部门,
			&S试卷生成时间,
			&S试卷生成人,
			&S试卷答题时间,
			&S试卷答题人,
			&S答题人所选答案,
			&S试卷答题成绩,
			&SID,
			&S考试计划号,
			&S通用专用)
		result = append(result,
			model.OnLineExams{
				ID:       strconv.Itoa(id),
				S所属类型ID:  S所属类型ID,
				S题目类型:    S题目类型,
				S所属岗位ID:  S所属岗位ID,
				S岗位名称:    S岗位名称,
				S题目内容:    S题目内容,
				S题目图片路径:  S题目图片路径,
				S备选答案:    S备选答案,
				S正确答案:    S正确答案,
				S题目分数:    S题目分数,
				S出题人:     S出题人,
				S所属部门ID:  S所属部门ID,
				S所属部门:    S所属部门,
				S试卷生成时间:  S试卷生成时间,
				S试卷生成人:   S试卷生成人,
				S试卷答题时间:  S试卷答题时间,
				S试卷答题人:   S试卷答题人,
				S答题人所选答案: S答题人所选答案,
				S试卷答题成绩:  S试卷答题成绩,
				SID:      SID,
				S考试计划号:   S考试计划号,
				S通用专用:    S通用专用})
	}
	return result, nil
}
  1. 查询所有答题人所选答案:
//查询所有ID对应的答题人所选答案数据
func QueryDataRadios(db *sql.DB) (exams []model.SelectRadios, e error) {
	sql := "select 答题人所选答案 from exams order by id"
	stmt, err := db.Prepare(sql)
	if err != nil {
		return nil, err
	}
	rows, err := stmt.Query()
	if err != nil {
		return nil, err
	}
	var result = make([]model.SelectRadios, 0)
	for rows.Next() {
		var (
			S答题人所选答案 string
		)
		rows.Scan(&S答题人所选答案)
		result = append(result, model.SelectRadios{SelectRadio: S答题人所选答案})
	}
	return result, nil
}
  1. 查询考试计划号:
//查询考试计划号
func QueryDataExaminationSchemeNumber(db *sql.DB) (examinationSchemeNumber string, e error) {
	sql := "select 考试计划号 from exams where id=1"
	stmt, err := db.Prepare(sql)
	if err != nil {
		return "", err
	}
	rows, err := stmt.Query()
	if err != nil {
		return "", err
	}
	result := ""
	for rows.Next() {
		var (
			S考试计划号 string
		)
		rows.Scan(&S考试计划号)
		result = S考试计划号
	}
	//fmt.Printf("result: %v\n", result)
	return result, nil
}
  1. 查询试卷答题时间:
//查询试卷答题时间,下载试卷的时间
func QueryDataTestPaperDownloadTime(db *sql.DB) (testPaperDownloadTime string, e error) {
	sql := "select 试卷答题时间 from exams where id=1"
	stmt, err := db.Prepare(sql)
	if err != nil {
		return "", err
	}
	rows, err := stmt.Query()
	if err != nil {
		return "", err
	}
	result := ""
	for rows.Next() {
		var (
			S试卷答题时间 string
		)
		rows.Scan(&S试卷答题时间)
		result = S试卷答题时间
	}
	//fmt.Printf("result: %v\n", result)
	return result, nil
}
  1. 根据ID删除数据:
//########删除数据#########
func DelByID(db *sql.DB, id int) (bool, error) {
	sql := `delete from exams where id=?`
	stmt, err := db.Prepare(sql)
	if err != nil {
		return false, err
	}
	res, err := stmt.Exec(id)
	if err != nil {
		return false, err
	}
	_, err = res.RowsAffected()
	if err != nil {
		return false, err
	}
	return true, nil
}
  1. 更新所选答案:
//更新所选答案
func UpdateSelectRadio(db *sql.DB, selectRadio string, editid int) (bool, error) {
	sql := `update exams set 答题人所选答案=?,试卷答题成绩=0 where id=?`
	stmt, err := db.Prepare(sql)
	if err != nil {
		return false, err
	}
	res, err := stmt.Exec(selectRadio, editid)
	if err != nil {
		return false, err
	}
	_, err = res.RowsAffected()
	if err != nil {
		return false, err
	}
	return true, nil
}
  1. 更新答题成绩:
//更新答题成绩
func UpdateFinalScore(db *sql.DB) (bool, error) {
	sql := `update exams set 试卷答题成绩=1 WHERE trim(正确答案)=trim(答题人所选答案)`
	stmt, err := db.Prepare(sql)
	if err != nil {
		return false, err
	}
	res, err := stmt.Exec()
	if err != nil {
		return false, err
	}
	_, err = res.RowsAffected()
	if err != nil {
		return false, err
	}
	//fmt.Println(sql)

	return true, nil
}
  1. 查询各岗位最终得分:
//查询各岗位最终得分,需要将各岗位的最终得分+通用题的最终得分
func QueryDataFinalScores(db *sql.DB) (exams []model.FinalScores, e error) {
	sql := `SELECT A.岗位名称,A.最终得分+B.最终得分 AS 最终得分,'' as 是否及格 FROM 
	(select 岗位名称,sum(cast(试卷答题成绩 as int)) AS 最终得分 from exams WHERE 岗位名称<>'通用' GROUP BY 岗位名称) a
	CROSS JOIN
	(select 岗位名称,sum(cast(试卷答题成绩 as int)) AS 最终得分 from exams WHERE 岗位名称='通用' GROUP BY 岗位名称) b`
	//fmt.Printf("sql: %v\n", sql)
	stmt, err := db.Prepare(sql)
	if err != nil {
		return nil, err
	}
	rows, err := stmt.Query()
	if err != nil {
		return nil, err
	}
	var result = make([]model.FinalScores, 0)
	for rows.Next() {
		var (
			S岗位名称 string
			S是否及格 string
		)
		var S最终得分 int
		rows.Scan(&S岗位名称, &S最终得分, &S是否及格)
		result = append(result, model.FinalScores{Position: S岗位名称, Score: S最终得分, IsPass: S是否及格})
	}
	return result, nil
}

7.4 API访问后台操作函数

api/examsApi.go中实现了与后台交互的API函数,包括:

  1. 上传用户登录数据到云服务器:
//上传本机考试数据到云服务器
func PostUserLoginDataCloudServer(data model.UserPassWord) (map[string]interface{}, error) {
	//获取云服务器地址
	serverPath := config.GetServerPath()
	requestBody := new(bytes.Buffer)
	json.NewEncoder(requestBody).Encode(data)
	//fmt.Printf("requestBody: %v\n", requestBody)
	url := serverPath + "/api/appuserlogin"
	req, _ := http.NewRequest("POST", url, requestBody)
	req.Header.Set("Content-Type", "application/json")
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	//reqData:=[]string{}
	// fmt.Println("response Status:", resp.Status)
	// fmt.Println("response Headers:", resp.Header)
	body, _ := ioutil.ReadAll(resp.Body)
	// fmt.Println("response Body:", string(body))
	var reqInfo map[string]interface{}
	//将json字符串转为字节数组后,反序列化到map切片数组中
	err = json.Unmarshal([]byte(body), &reqInfo)
	if err != nil {
		fmt.Printf("err.Error(): %v\n", err.Error())
	}
	// fmt.Printf("reqInfo: %v\n", reqInfo)
	// fmt.Printf("reqInfo[\"data\"]: %v\n", reqInfo["data"])
	// fmt.Printf("reqInfo[\"status\"]: %v\n", reqInfo["status"])
	// fmt.Printf("reqInfo[\"msg\"]: %v\n", reqInfo["msg"])
	return reqInfo, err
}
  1. 上传本地测试数据到云服务器:
type RequestBody struct {
	Id string `json:"id"`

	Name string `json:"name"`
}

//上传本机测试数据到云服务器
func PostLocalTestDataToCloudServer(CardNumber string, lblMsg *canvas.Text) (bool, error) {
	//获取云服务器地址
	serverPath := config.GetServerPath()
	data := []RequestBody{}
	request := RequestBody{
		Id:   CardNumber,
		Name: lblMsg.Text,
	}
	data = append(data, request)
	request = RequestBody{
		Id:   "2358",
		Name: "abdesfdsfaf",
	}
	data = append(data, request)
	requestBody := new(bytes.Buffer)
	json.NewEncoder(requestBody).Encode(data)
	url := serverPath + "/api/upexamsdatatest"
	req, _ := http.NewRequest("POST", url, requestBody)
	req.Header.Set("Content-Type", "application/json")
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	fmt.Println("response Status:", resp.Status)
	fmt.Println("response Headers:", resp.Header)
	body, _ := ioutil.ReadAll(resp.Body)
	fmt.Println("response Body:", string(body))
	return true, nil
}
  1. 上传本地考试数据到云服务器:
//上传本机考试数据到云服务器
func PostLocalExamsDataToCloudServer(data []model.UpExams) (map[string]interface{}, error) {
	//获取云服务器地址
	serverPath := config.GetServerPath()
	requestBody := new(bytes.Buffer)
	json.NewEncoder(requestBody).Encode(data)
	//fmt.Printf("requestBody: %v\n", requestBody)
	url := serverPath + "/api/appupexams"
	req, _ := http.NewRequest("POST", url, requestBody)
	req.Header.Set("Content-Type", "application/json")
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	//reqData:=[]string{}
	// fmt.Println("response Status:", resp.Status)
	// fmt.Println("response Headers:", resp.Header)
	body, _ := ioutil.ReadAll(resp.Body)
	// fmt.Println("response Body:", string(body))
	var reqInfo map[string]interface{}
	//将json字符串转为字节数组后,反序列化到map切片数组中
	err = json.Unmarshal([]byte(body), &reqInfo)
	if err != nil {
		fmt.Printf("err.Error(): %v\n", err.Error())
	}
	// fmt.Printf("reqInfo: %v\n", reqInfo)
	// fmt.Printf("reqInfo[\"data\"]: %v\n", reqInfo["data"])
	// fmt.Printf("reqInfo[\"status\"]: %v\n", reqInfo["status"])
	// fmt.Printf("reqInfo[\"msg\"]: %v\n", reqInfo["msg"])
	return reqInfo, err
}
  1. 根据卡号获取在线考试数据:
//根据卡号获取随机试卷
func GetOnLineExamsData(CardNumber string, lblMsg *canvas.Text) ([]model.OnLineExams, error) {
	//获取云服务器地址
	serverPath := config.GetServerPath()
	r, err := http.Get(serverPath + "/api/read_sjmxb?cardNumber=" + CardNumber)
	if err != nil {
		lblMsg.Text = err.Error()
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
		return []model.OnLineExams{}, err
	}
	defer func() { _ = r.Body.Close() }()
	body, _ := ioutil.ReadAll(r.Body)
	//fmt.Printf("reflect.TypeOf(body): %v\n", reflect.TypeOf(body))
	//将获取到的[]uint8数组转换为json字符串
	str := string(body)
	//创建map切片数组
	var sliceData []map[string]interface{}
	//将json字符串转为字节数组后,反序列化到map切片数组中
	err = json.Unmarshal([]byte(str), &sliceData)
	if err != nil {
		//fmt.Println("转换出错", err)
		lblMsg.Text = "json转换map出错?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	//fmt.Printf("sliceData: %v\n", sliceData)
	//循环map数组,将数组的每一行转换为结构体
	exams := []model.OnLineExams{}
	for _, v := range sliceData {
		//fmt.Printf("k: %v v: %v\n", k,v)
		var exam model.OnLineExams
		//将 map 转换为指定的结构体
		if err := utils.Decode(v, &exam); err != nil {
			lblMsg.Text = "map转换结构体出错?"
			lblMsg.Color = colornames.Red
			lblMsg.Refresh()
			//fmt.Println(err)
		}
		exams = append(exams, exam)
		//fmt.Printf("k: %v\n", k)
		//fmt.Printf("map->struct内容为->v:%v\n", exam)

	}

	return exams, err
}
  1. 将获取的试卷保存到本地sqlite数据库中:
//将获取的试卷保存到本地sqlite数据库中
func SaveOnLineExamsData(db *sql.DB, exams []model.OnLineExams, lblMsg *canvas.Text) error {
	//fmt.Printf("exams: %v\n", exams)
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// }
	// defer db.Close()
	err := service.CreateTableExams(db)
	if err != nil {
		lblMsg.Text = "创建试卷表失败,请重新生成试卷?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	err = service.InsertDatas(db, exams, lblMsg)
	if err != nil {
		lblMsg.Text = "试卷保存失败,请重新生成试卷?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	return err
}
  1. 根据题号【ID】从本地sqlite数据库中提取数据:
//根据题号【ID】从本地sqlite数据库中提取数据,在首次加载试卷或切题时使用
func QueryIdOnLineExamsData(db *sql.DB, id string, lblMsg *canvas.Text) ([]model.OnLineExams, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return []model.OnLineExams{}, err
	// }
	// defer db.Close()
	exams, err := service.QueryDataID(db, id)
	if err != nil {
		lblMsg.Text = "获取试卷第" + id + "行数据失败,请重新生成试卷?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
		return []model.OnLineExams{}, err
	}
	return exams, err
}
  1. 查询所有答题人所选答案:
//根据题号【ID】从本地sqlite数据库中提取数据,在首次加载试卷或切题时使用
func QuerySelectRadiosData(db *sql.DB, lblMsg *canvas.Text) ([]model.SelectRadios, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return []model.OnLineExams{}, err
	// }
	// defer db.Close()
	selectRadios, err := service.QueryDataRadios(db)
	if err != nil {
		lblMsg.Text = "获取已答选项数据失败,请重新答题?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
		return []model.SelectRadios{}, err
	}
	return selectRadios, err
}
  1. 获取试题总量:
//获取试题总量
func QueryRowsCountOnLineExamsData(db *sql.DB, lblMsg *canvas.Text) (int, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return 0, err
	// }
	// defer db.Close()
	rowsCount, err := service.QueryDataMaxRowsCount(db)
	if err != nil {
		lblMsg.Text = "获取试卷表总行数失败?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
		return 0, err
	}
	return rowsCount, err
}
  1. 更新试卷所选答案:
//更新试卷所选答案
func UpdateSelectRadioData(db *sql.DB, selectRadio string, editid int, lblMsg *canvas.Text) (bool, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return 0, err
	// }
	// defer db.Close()
	b, err := service.UpdateSelectRadio(db, selectRadio, editid)
	if err != nil {
		lblMsg.Text = "更新选择答案失败?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
		return false, err
	}
	return b, err
}
  1. 更新试卷成绩:
//更新试卷成绩
func UpdateFinalScoreData(db *sql.DB, lblMsg *canvas.Text) (bool, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return 0, err
	// }
	// defer db.Close()
	b, err := service.UpdateFinalScore(db)
	if err != nil {
		lblMsg.Text = "更新最终得分失败?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
		return false, err
	}
	return b, err
}
  1. 获取考试计划号:
//获取考试计划号
func QueryExaminationSchemeNumberData(db *sql.DB, lblMsg *canvas.Text) (string, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return 0, err
	// }
	// defer db.Close()
	s, err := service.QueryDataExaminationSchemeNumber(db)
	if err != nil {
		lblMsg.Text = "获取考试计划号失改?" + err.Error()
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	return s, err
}
  1. 获取考试下载时间:
//获取考试下载时间
func QueryTestPaperDownloadTimeData(db *sql.DB, lblMsg *canvas.Text) (string, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return 0, err
	// }
	// defer db.Close()
	s, err := service.QueryDataTestPaperDownloadTime(db)
	if err != nil {
		lblMsg.Text = "获取考试计划号失改?" + err.Error()
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	return s, err
}
  1. 查询各岗位最终得分:
//查询各岗位最终得分
func QueryFinalScoresData(db *sql.DB, lblMsg *canvas.Text) ([]model.FinalScores, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return 0, err
	// }
	// defer db.Close()
	finalsorces, err := service.QueryDataFinalScores(db)
	if err != nil {
		lblMsg.Text = "汇总最终成绩失改?" + err.Error()
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	return finalsorces, err
}
  1. 查询上传考试数据:
//查询各岗位最终得分
func QueryUpExamsData(db *sql.DB, lblMsg *canvas.Text) ([]model.UpExams, error) {
	// db, err := utils.GetConnection()
	// if err != nil {
	// 	lblMsg.Text = "数据库打开错误,请稍后再试?"
	// 	lblMsg.Color = colornames.Red
	// 	lblMsg.Refresh()
	// 	return 0, err
	// }
	// defer db.Close()
	exams, err := service.QueryDataUpExams(db)
	if err != nil {
		lblMsg.Text = "汇总最终成绩失改?" + err.Error()
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	return exams, err
}

7.5 界面渲染函数

界面渲染函数分布在多个文件中:

  1. 登录界面 - login.go
var popLoginModel *widget.PopUp

func PopLogin(a fyne.App, w fyne.Window) {
	vboxM := container.NewVBox()
	title := canvas.NewText("用户登录", colornames.Blue)
	title.TextSize = 25
	title.Alignment = fyne.TextAlignCenter
	vboxM.Add(title)
	vboxM.Add(widget.NewSeparator())
	lblUserId := canvas.NewText("请输入帐号:", colornames.Blue)
	lblUserId.TextSize = 16
	entryUserId := com.NewNumericalEntry()
	entryUserId.SetPlaceHolder("卡号") //提示语句
	cEntryUserId := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 200, Height: 40}), entryUserId)
	hUserId := container.NewHBox(lblUserId, cEntryUserId)
	vboxM.Add(hUserId)
	lblPassWord := canvas.NewText("请输入密码:", colornames.Red)
	lblPassWord.TextSize = 16
	entryPassWord := com.NewNumericalEntry()
	entryPassWord.SetPlaceHolder("生日:格式【19880808】") //提示语句
	entryPassWord.Password=true
	cEntryPassWord := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 200, Height: 40}), entryPassWord)
	hlblUserPassWord := container.NewHBox(lblPassWord, cEntryPassWord)
	vboxM.Add(hlblUserPassWord)
	vboxM.Add(widget.NewSeparator())
	lblUserLoginMsg := canvas.NewText("", colornames.Blue)
	lblUserLoginMsg.TextSize = 10
	ProgressBarUserLogin := widget.NewProgressBarInfinite()
	ProgressBarUserLogin.Stop()
	ProgressBarUserLogin.Hide()
	vboxM.Add(lblUserLoginMsg)
	vboxM.Add(ProgressBarUserLogin)

	btnLogin := com.NewButtonWithIcon("登录系统", mytheme.MyTheme{}.LoginEnterIcon(), "h", func() {

		if len(entryUserId.Text) == 0 {
			lblUserLoginMsg.Text = "对不起,帐号不能为空?"
			lblUserLoginMsg.Color = colornames.Red
			lblUserLoginMsg.Refresh()
			return
		}
		if len(entryPassWord.Text) == 0 {
			lblUserLoginMsg.Text = "对不起,密码不能为空?"
			lblUserLoginMsg.Color = colornames.Red
			lblUserLoginMsg.Refresh()
			return
		}

		userPassWord := model.UserPassWord{}
		userPassWord.CardNumber = entryUserId.Text
		userPassWord.PassWord = entryPassWord.Text

		//上传试卷数据到云服务器,云服务器返回状态信息
		reqInfo, _ := api.PostUserLoginDataCloudServer(userPassWord)
		//fmt.Printf("reqInfo: %v\n", reqInfo)
		status := utils.Strval(reqInfo["status"])
		msg := utils.Strval(reqInfo["msg"])
		lblUserLoginMsg.Text = msg
		lblUserLoginMsg.Color = colornames.Red
		lblUserLoginMsg.Refresh()
		switch status {
		case "ok":
			// fmt.Printf("reqInfo[\"data\"]: %v\n", reqInfo["data"])
			// fmt.Printf("reflect.TypeOf(reqInfo[\"data\"]): %v\n", reflect.TypeOf(reqInfo["data"]))
			d := utils.Strval(reqInfo["data"])
			//fmt.Printf("d: %v\n", d)
			//-------------------------------------------------------------------------------------------------
			//创建map切片数组
			var sliceData []map[string]interface{}
			//将json字符串转为字节数组后,反序列化到map切片数组中
			err := json.Unmarshal([]byte(d), &sliceData)
			// fmt.Printf("sliceData: %v\n", sliceData[0])
			if err != nil {
				//fmt.Println("转换出错", err)
				lblUserLoginMsg.Text = "json转换map出错?"
				lblUserLoginMsg.Color = colornames.Red
				lblMsg.Refresh()
			}
			//------------------------------------------------------------------------------
			var userInfo model.UserInfo
			//将 map 转换为指定的结构体
			// userInfo.S卡号= fmt.Sprintf("%v",sliceData[0]["卡号"])
			// userInfo.S姓名= fmt.Sprintf("%v",sliceData[0]["姓名"])
			// userInfo.S岗位名称= fmt.Sprintf("%v",sliceData[0]["岗位名称"])
			// userInfo.S部门名称= fmt.Sprintf("%v",sliceData[0]["部门名称"])
			if err := utils.Decode(sliceData[0], &userInfo); err != nil {
				lblUserLoginMsg.Text = "map转换结构体出错?"
				lblUserLoginMsg.Color = colornames.Red
				lblUserLoginMsg.Refresh()
				//fmt.Println(err)
			}
			//fmt.Printf("userInfo: %v\n", userInfo)
			//-------------------------------------------------------------------------------------------
			// a.Preferences().SetString("userName", userInfo.S姓名)
			a.Preferences().SetString("userPassword", userPassWord.PassWord)
			// a.Preferences().SetString("userDeptName", userInfo.S部门名称)
			// a.Preferences().SetString("userPositionName", userInfo.S岗位名称)
			// a.Preferences().SetString("userCardNumber", userInfo.S卡号)
			RefreshUserInfo(userInfo, a)
			ProgressBarUserLogin.Stop()
			ProgressBarUserLogin.Hide()
			//隐藏
			popLoginModel.Hide()
			//弹出主功能界面
			//vbox.Add(VBoxMainFrame(a,w))
			//vbox.Add(VBoxMobileOnLineTest(a, w))
		case "err":
			lblUserLoginMsg.Text = msg
			lblUserLoginMsg.Refresh()
			ProgressBarUserLogin.Stop()
			ProgressBarUserLogin.Hide()
		}

	})

	btnLogin.SetTxtSize(30)
	btnLogin.SetTxtColor(colornames.Green)
	btnLogin.SetBgColor(colornames.Lemonchiffon)
	btnLogin.SetStrokeColor(colornames.Slategrey)
	btnLogin.SetIconSize(60)
	lblLoginMsg := canvas.NewText("", colornames.Red)
	lblLoginMsg.TextSize = 25
	lblLoginMsg.Alignment = fyne.TextAlignCenter
	vboxM.Add(lblLoginMsg)
	vboxM.Add(widget.NewSeparator())
	vboxM.Add(btnLogin)
	popLoginModel = widget.NewModalPopUp(vboxM, w.Canvas())
	popLoginModel.Show()
}
  1. 主界面 - mainFrame.go
var lblUserName *canvas.Text
var lblUserCardNumber *canvas.Text
var lblDeptName *canvas.Text
var lblPositionName *canvas.Text

func VBoxMainFrame(a fyne.App, w fyne.Window) *fyne.Container {
	vboxM := container.NewVBox()
	title := canvas.NewText("员工在线培训系统", colornames.Black)
	title.TextSize = 30
	title.Alignment = fyne.TextAlignCenter
	vboxM.Add(title)
	vboxM.Add(widget.NewSeparator())
	lblUserName = canvas.NewText("登录用户:"+a.Preferences().String("userName"), colornames.Blue)
	lblUserName.TextSize = 16
	lblUserCardNumber = canvas.NewText("卡号:"+a.Preferences().String("userCardNumber"), colornames.Blue)
	lblUserCardNumber.TextSize = 16
	lblDeptName = canvas.NewText("部门:"+a.Preferences().String("userDeptName"), colornames.Blue)
	lblDeptName.TextSize = 16
	hboxUserInfo := container.NewHBox(lblUserName, widget.NewSeparator(), lblUserCardNumber, widget.NewSeparator(), lblDeptName)
	vboxM.Add(hboxUserInfo)
	vboxM.Add(widget.NewSeparator())
	lblPositionName = canvas.NewText("岗位名称:"+a.Preferences().String("userPositionName"), colornames.Black)
	lblPositionName.TextSize = 16
	vboxM.Add(lblPositionName)
	vboxM.Add(widget.NewSeparator())
	btnQuestionBankManagement := com.NewButtonCircleWithIcon("题库管理", mytheme.MyTheme{}.QuestionBankManagementIcon(), "v", func() {
		if len(a.Preferences().String("userName")) == 0 {
			PopLogin(a, w)
		} else {

		}

	})

	btnQuestionBankManagement.SetTxtSize(24)
	btnQuestionBankManagement.SetTxtColor(colornames.Black)
	btnQuestionBankManagement.SetBgColor(colornames.Lemonchiffon)
	btnQuestionBankManagement.SetStrokeColor(colornames.Slategrey)
	btnQuestionBankManagement.SetIconSize(70)
	cBtnQuestionBankManagement := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 150, Height: 150}), btnQuestionBankManagement)
	btnPositionLearning := com.NewButtonCircleWithIcon("岗位学习", mytheme.MyTheme{}.PositionLearningIcon(), "v", func() {
		if len(a.Preferences().String("userName")) == 0 {
			PopLogin(a, w)
		} else {

		}
	})

	btnPositionLearning.SetTxtSize(24)
	btnPositionLearning.SetTxtColor(colornames.Black)
	btnPositionLearning.SetBgColor(colornames.Lemonchiffon)
	btnPositionLearning.SetStrokeColor(colornames.Slategrey)
	btnPositionLearning.SetIconSize(70)
	cBtnPositionLearning := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 150, Height: 150}), btnPositionLearning)
	btnPositionTest := com.NewButtonCircleWithIcon("岗位测试", mytheme.MyTheme{}.PositionTestIcon(), "v", func() {
		if len(a.Preferences().String("userName")) == 0 {
			PopLogin(a, w)
		} else {

		}
	})

	btnPositionTest.SetTxtSize(24)
	btnPositionTest.SetTxtColor(colornames.Black)
	btnPositionTest.SetBgColor(colornames.Lemonchiffon)
	btnPositionTest.SetStrokeColor(colornames.Slategrey)
	btnPositionTest.SetIconSize(70)
	cBtnPositionTest := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 150, Height: 150}), btnPositionTest)

	btnPositionExamination := com.NewButtonCircleWithIcon("岗位考试", mytheme.MyTheme{}.PositionExaminationIcon(), "v", func() {
		if len(a.Preferences().String("userName")) == 0 {
			PopLogin(a, w)
		} else {
			childWindow := a.NewWindow("")
			childWindow.SetContent(VBoxMobileOnLineTest(a, childWindow))
			//childWindow.Content().Move(fyne.NewPos(10, 10))
			//设置窗口的运行大小
			childWindow.Resize(fyne.Size{Width: 360, Height: 680})
			childWindow.Show()
		}
	})

	btnPositionExamination.SetTxtSize(24)
	btnPositionExamination.SetTxtColor(colornames.Black)
	btnPositionExamination.SetBgColor(colornames.Lemonchiffon)
	btnPositionExamination.SetStrokeColor(colornames.Slategrey)
	btnPositionExamination.SetIconSize(70)
	cBtnPositionExamination := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 150, Height: 150}), btnPositionExamination)

	btnSystemSettings := com.NewButtonCircleWithIcon("系统设置", mytheme.MyTheme{}.SystemSettingsIcon(), "v", func() {
		if len(a.Preferences().String("userName")) == 0 {
			PopLogin(a, w)
		} else {

		}
	})

	btnSystemSettings.SetTxtSize(24)
	btnSystemSettings.SetTxtColor(colornames.Black)
	btnSystemSettings.SetBgColor(colornames.Lemonchiffon)
	btnSystemSettings.SetStrokeColor(colornames.Slategrey)
	btnSystemSettings.SetIconSize(70)
	cBtnSystemSettings := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 150, Height: 150}), btnSystemSettings)

	btnLogOut := com.NewButtonCircleWithIcon("注销登录", mytheme.MyTheme{}.LogOutIcon(), "v", func() {
		ClearUserInfo(a)
	})

	btnLogOut.SetTxtSize(24)
	btnLogOut.SetTxtColor(colornames.Red)
	btnLogOut.SetBgColor(colornames.Lemonchiffon)
	btnLogOut.SetStrokeColor(colornames.Slategrey)
	btnLogOut.SetIconSize(70)
	cBtnLogOut := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 150, Height: 150}), btnLogOut)
	c := container.New(layout.NewGridLayoutWithColumns(2), cBtnQuestionBankManagement, cBtnPositionLearning, cBtnPositionTest, cBtnPositionExamination, cBtnSystemSettings, cBtnLogOut)
	vboxM.Add(c)
	lblMainFrameMsg:=canvas.NewText("",colornames.Red)
	lblMainFrameMsg.TextSize=16
	vboxM.Add(lblMainFrameMsg)
	return vboxM

}

//注销用户登录信息
func ClearUserInfo(a fyne.App) {
	a.Preferences().SetString("userName", "")
	a.Preferences().SetString("userPassword", "")
	a.Preferences().SetString("userDeptName", "")
	a.Preferences().SetString("userPositionName", "")
	a.Preferences().SetString("userCardNumber", "")
	lblUserName.Text = "登录用户:"
	lblUserName.Refresh()
	lblUserCardNumber.Text = "卡号:"
	lblUserCardNumber.Refresh()
	lblDeptName.Text = "部门:"
	lblDeptName.Refresh()
	lblPositionName.Text = "岗位名称:"
	lblPositionName.Refresh()
}

func RefreshUserInfo(userInfo model.UserInfo, a fyne.App) {
	a.Preferences().SetString("userName", userInfo.S姓名)
	a.Preferences().SetString("userDeptName", userInfo.S部门名称)
	a.Preferences().SetString("userPositionName", userInfo.S岗位名称)
	a.Preferences().SetString("userCardNumber", userInfo.S卡号)
	lblUserName.Text = "登录用户:" + userInfo.S姓名
	lblUserName.Refresh()
	lblUserCardNumber.Text = "卡号:" + userInfo.S卡号
	lblUserCardNumber.Refresh()
	lblDeptName.Text = "部门:" + userInfo.S部门名称
	lblDeptName.Refresh()
	lblPositionName.Text = "岗位名称:" + userInfo.S岗位名称
	lblPositionName.Refresh()
}
  1. 表格组件 - widgetTable.go
func CreateTable(data [][]string, header []string, colWidth []int, size fyne.Size, lblMsg *canvas.Text) *fyne.Container {
	//创建表格-表头
	tableHeader := widget.NewTable(nil, nil, nil)
	tableHeader.Length = func() (int, int) {
		return 1, len(header)
	}
	tableHeader.CreateCell = func() fyne.CanvasObject {
		label := canvas.NewText("", color.Black)
		c := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 66, Height: 40}), label)
		return c
	}
	tableHeader.UpdateCell = func(i widget.TableCellID, o fyne.CanvasObject) {
		co := o.(*fyne.Container)
		colHeader := co.Objects[0].(*canvas.Text)
		colHeader.Text = header[i.Col]
		colHeader.TextSize = 16
		colHeader.TextStyle.Bold = true
		colHeader.TextStyle.Monospace = true
		colHeader.Color = colornames.Blue

	}
	//设置各列宽度
	for key, v := range colWidth {
		tableHeader.SetColumnWidth(key, float32(v))
	}
	r1 := canvas.NewRectangle(colornames.Darkgray)
	crt := container.New(layout.NewMaxLayout(), r1, tableHeader)
	cTableHeader := container.New(layout.NewGridWrapLayout(fyne.Size{Width: size.Width, Height: 40}), crt)
	tableHeader.Refresh()
	//创建表格--数据区
	table := widget.NewTable(nil, nil, nil)
	table.Length = func() (int, int) {
		return len(data), len(data[0])
	}
	table.CreateCell = func() fyne.CanvasObject {
		//		label := widget.NewLabel(strings.Repeat(" ", colWidth[0]/5))

		label := widget.NewLabel("")
		//label.Resize(fyne.Size{Width: 10, Height: 40})
		entry := widget.NewEntry()
		entry.OnChanged = func(s string) {
			label.SetText(entry.Text)
		}

		entry.Hide()
		c := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 66, Height: 40}), label)

		return c
	}
	table.UpdateCell = func(i widget.TableCellID, o fyne.CanvasObject) {
		// lbl := template(*widget.Label)
		// lbl.SetText(data[i.Row][i.Col])
		co := o.(*fyne.Container)
		label := co.Objects[0].(*widget.Label)
		label.SetText(data[i.Row][i.Col])
		//co.Resize(fyne.Size{Width: float32(colWidth[i.Col]),Height: 40})
		// fmt.Printf("co.Size().Width: %v\n", co.Size().Width)
		// fmt.Printf("len(data[i.Row][i.Col]): %v\n", len(data[i.Row][i.Col]))
		//设置文本是否换行
		// if len(data[i.Row][i.Col]) >= int(co.Size().Width/8) {
		// 	label.Wrapping = fyne.TextWrapBreak
		// }
		co.Refresh()

	}

	table.OnSelected = func(id widget.TableCellID) {
		lblMsg.Text="data[" + strconv.Itoa(id.Row) + "," + strconv.Itoa(id.Col) + "]" + "=[" + data[id.Row][id.Col] + "]"

		// selectID := id
		// table.UpdateCell = func(id widget.TableCellID, template fyne.CanvasObject) {
		// 	co := template.(*fyne.Container)
		// 	if selectID.Row == id.Row && selectID.Col == id.Col {
		// 		co.Objects[1].Hide()
		// 		co.Objects[0].Show()
		// 	} else {
		// 		co.Objects[1].Show()
		// 		co.Objects[0].Hide()
		// 	}

		// }
		// table.BaseWidget.Refresh()

	}

	for key, v := range colWidth {
		table.SetColumnWidth(key, float32(v))
	}

	//table.ScrollToLeading()
	//table.ScrollToTrailing()
	//指定表格滚动到第几行
	//table.ScrollTo(widget.TableCellID{Row:2,Col:0})
	cTable := container.New(layout.NewGridWrapLayout(size), table)
	//hbox := container.NewHBox()
	// for key, v := range header {
	// 	colHeader := canvas.NewText(v+strings.Repeat(" ", 12-len(v)), color.Black)
	// 	colHeader.TextSize = 16
	// 	colHeader.TextStyle.Bold = true
	// 	colHeader.TextStyle.Monospace = true
	// 	hbox.Add(colHeader)
	// 	if key < len(header)-1 {
	// 		hbox.Add(widget.NewSeparator())
	// 	}

	// }

	table.BaseWidget.Refresh()

	cT := container.NewVBox(cTableHeader, cTable)
	// cT := container.New(layout.NewBorderLayout(cTableHeader,
	// 	nil, nil, nil), cTableHeader, cTable)
	return cT
}

func CreateTableAndroid(data [][]string, header []string, colWidth []int, size fyne.Size, fontSize float32, lblMsg *canvas.Text) *fyne.Container {
	//创建表格-表头
	tableHeader := widget.NewTable(nil, nil, nil)
	tableHeader.Length = func() (int, int) {
		return 1, len(header)
	}
	tableHeader.CreateCell = func() fyne.CanvasObject {
		colHeader := canvas.NewText("", color.Black)
		colHeader.TextSize = fontSize + 2
		colHeader.TextStyle.Bold = true
		colHeader.TextStyle.Monospace = true
		colHeader.Color = colornames.Blue
		c := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 66, Height: 20}), colHeader)
		return c
	}
	tableHeader.UpdateCell = func(i widget.TableCellID, o fyne.CanvasObject) {
		co := o.(*fyne.Container)
		colHeader := co.Objects[0].(*canvas.Text)
		colHeader.Text = header[i.Col]
	}
	//设置各列宽度
	for key, v := range colWidth {
		tableHeader.SetColumnWidth(key, float32(v))
	}
	r1 := canvas.NewRectangle(colornames.Darkgray)
	crt := container.New(layout.NewMaxLayout(), r1, tableHeader)
	cTableHeader := container.New(layout.NewGridWrapLayout(fyne.Size{Width: size.Width, Height: 20}), crt)
	tableHeader.Refresh()
	//创建表格--数据区
	table := widget.NewTable(nil, nil, nil)
	table.Length = func() (int, int) {
		return len(data), len(data[0])
	}
	table.CreateCell = func() fyne.CanvasObject {
		//根据指定字符和个数返回多个相同字符组成的字符串
		//		label := widget.NewLabel(strings.Repeat(" ", colWidth[0]/5))
		label := canvas.NewText("", color.Black)
		label.TextSize = fontSize
		//label.Resize(fyne.Size{Width: 10, Height: 40})
		c := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 66, Height: 20}), label)
		return c
	}
	table.UpdateCell = func(i widget.TableCellID, o fyne.CanvasObject) {
		// lbl := template(*widget.Label)
		// lbl.SetText(data[i.Row][i.Col])
		co := o.(*fyne.Container)
		label := co.Objects[0].(*canvas.Text)
		label.Text = data[i.Row][i.Col]

		co.Refresh()

	}

	table.OnSelected = func(id widget.TableCellID) {
		lblMsg.Text="data[" + strconv.Itoa(id.Row) + "," + strconv.Itoa(id.Col) + "]" + "=[" + data[id.Row][id.Col] + "]"

		// selectID := id
		// table.UpdateCell = func(id widget.TableCellID, template fyne.CanvasObject) {
		// 	co := template.(*fyne.Container)
		// 	if selectID.Row == id.Row && selectID.Col == id.Col {
		// 		co.Objects[1].Hide()
		// 		co.Objects[0].Show()
		// 	} else {
		// 		co.Objects[1].Show()
		// 		co.Objects[0].Hide()
		// 	}

		// }
		// table.BaseWidget.Refresh()

	}

	for key, v := range colWidth {
		table.SetColumnWidth(key, float32(v))
	}

	//table.ScrollToLeading()
	//table.ScrollToTrailing()
	//指定表格滚动到第几行
	//table.ScrollTo(widget.TableCellID{Row:2,Col:0})
	cTable := container.New(layout.NewGridWrapLayout(size), table)
	//hbox := container.NewHBox()
	// for key, v := range header {
	// 	colHeader := canvas.NewText(v+strings.Repeat(" ", 12-len(v)), color.Black)
	// 	colHeader.TextSize = 16
	// 	colHeader.TextStyle.Bold = true
	// 	colHeader.TextStyle.Monospace = true
	// 	hbox.Add(colHeader)
	// 	if key < len(header)-1 {
	// 		hbox.Add(widget.NewSeparator())
	// 	}

	// }

	table.BaseWidget.Refresh()

	cT := container.NewVBox(cTableHeader, cTable)
	// cT := container.New(layout.NewBorderLayout(cTableHeader,
	// 	nil, nil, nil), cTableHeader, cTable)
	return cT
}

8. 项目中应用的fyne组件示例详解

8.1 单个应用案例

8.1.1 自定义组件-带图标圆形按钮组件

components/myButtonCircle.go中实现了自定义的带图标圆形按钮组件,该组件继承自Fyne的widget.DisableableWidget,具有完整的自定义渲染逻辑。

组件主要结构体定义:

// ButtonCircle widget has a text label and triggers an event func when clicked
type ButtonCircle struct {
	widget.DisableableWidget
	label      *canvas.Text
	background *canvas.Circle
	Text       string
	Icon       fyne.Resource
	IconSize   fyne.Size
	LayoutType string
	// Specify how prominent the ButtonCircle should be, High will highlight the ButtonCircle and Low will remove some decoration.
	//
	// Since: 1.4
	Importance    ButtonCircleImportance
	Alignment     ButtonCircleAlign
	IconPlacement ButtonCircleIconPlacement

	OnTapped func() `json:"-"`

	hovered bool
	tapAnim *fyne.Animation
}

创建圆形按钮的函数:

// NewButtonCircle creates a new ButtonCircle widget with the set label and tap handler
func NewButtonCircle(label string, tapped func()) *ButtonCircle {
	text := canvas.NewText(label, theme.ForegroundColor())
	text.TextStyle.Bold = true

	background := canvas.NewCircle(color.White)
	background.FillColor = colornames.Lightgray
	background.StrokeColor = colornames.White
	background.StrokeWidth = 2
	ButtonCircle := &ButtonCircle{
		Text:       label,
		label:      text,
		background: background,
		IconSize:   fyne.NewSize(theme.IconInlineSize(), theme.IconInlineSize()),
		OnTapped:   tapped,
	}

	ButtonCircle.ExtendBaseWidget(ButtonCircle)
	return ButtonCircle
}

// NewButtonCircleWithIcon creates a new ButtonCircle widget with the specified label, themed icon and tap handler
func NewButtonCircleWithIcon(label string, icon fyne.Resource, layoutType string, tapped func()) *ButtonCircle {
	text := canvas.NewText(label, theme.ForegroundColor())
	text.TextStyle.Bold = true

	background := canvas.NewCircle(color.White)
	background.FillColor = colornames.Lightgray
	background.StrokeColor = colornames.White
	background.StrokeWidth = 2
	ButtonCircle := &ButtonCircle{
		Text:       label,
		label:      text,
		background: background,
		Icon:       icon,
		IconSize:   fyne.NewSize(theme.IconInlineSize(), theme.IconInlineSize()),
		LayoutType: layoutType,
		OnTapped:   tapped,
	}

	ButtonCircle.ExtendBaseWidget(ButtonCircle)
	return ButtonCircle
}

设置按钮属性的方法:

func (b *ButtonCircle) SetSize(size fyne.Size) {
	b.background.Resize(size)
	b.background.Refresh()
}

func (b *ButtonCircle) SetBgColor(c color.Color) {
	b.background.FillColor = c
	b.background.Refresh()
}

func (b *ButtonCircle) SetStrokeWidth(width float32) {
	b.background.StrokeWidth = width
	b.background.Refresh()
}
func (b *ButtonCircle) SetStrokeColor(c color.Color) {
	b.background.StrokeColor = c
	b.background.Refresh()
}

func (b *ButtonCircle) SetTxtColor(c color.Color) {
	b.label.Color = c
	b.label.Refresh()
}

func (b *ButtonCircle) SetTxtSize(size float32) {
	b.label.TextSize = size
	b.label.Refresh()
}
func (b *ButtonCircle) SetIconSize(size float32) {
	b.IconSize = fyne.NewSize(size, size)
}

组件渲染器实现:

// CreateRenderer is a private method to Fyne which links this widget to its renderer
func (b *ButtonCircle) CreateRenderer() fyne.WidgetRenderer {
	b.ExtendBaseWidget(b)
	tapBG := canvas.NewCircle(color.Transparent)
	b.tapAnim = newButtonCircleTapAnimation(tapBG, b)
	b.tapAnim.Curve = fyne.AnimationEaseOut
	objects := []fyne.CanvasObject{
		b.background,
		tapBG,
		b.label,
	}
	r := &ButtonCircleRenderer{
		// ShadowingRenderer: widget.NewShadowingRenderer(objects, shadowLevel),
		background:   b.background,
		tapBG:        tapBG,
		ButtonCircle: b,
		label:        b.label,
		objects:      objects,
		// layout:       layout.NewVBoxLayout(),
	}
	//fmt.Println(r.ButtonCircle.LayoutType)
	if r.ButtonCircle.LayoutType == "h" {
		r.layout = layout.NewHBoxLayout()
	} else {
		r.layout = layout.NewVBoxLayout()
	}

	r.updateIconAndText()
	r.applyTheme()
	return r
}

按钮点击事件处理:

// Tapped is called when a pointer tapped event is captured and triggers any tap handler
func (b *ButtonCircle) Tapped(*fyne.PointEvent) {
	if b.Disabled() {
		return
	}

	b.tapAnimation()
	b.Refresh()

	if b.OnTapped != nil {
		b.OnTapped()
	}
}

func (b *ButtonCircle) tapAnimation() {
	if b.tapAnim == nil {
		return
	}
	b.tapAnim.Stop()
	b.tapAnim.Start()
}

组件渲染器实现:

type ButtonCircleRenderer struct {
	// *ShadowingRenderer
	icon         *canvas.Image
	label        *canvas.Text
	background   *canvas.Circle
	tapBG        *canvas.Circle
	ButtonCircle *ButtonCircle
	objects      []fyne.CanvasObject
	layout       fyne.Layout
}

func (r *ButtonCircleRenderer) Destroy() {}
func (r *ButtonCircleRenderer) Objects() []fyne.CanvasObject {
	return r.objects
}

// Layout the components of the ButtonCircle widget
func (r *ButtonCircleRenderer) Layout(size fyne.Size) {
	var inset fyne.Position
	bgSize := size
	if r.ButtonCircle.Importance != LowCircleImportance {
		inset = fyne.NewPos(theme.Padding()/2, theme.Padding()/2)
		bgSize = size.Subtract(fyne.NewSize(theme.Padding(), theme.Padding()))
	}
	// r.LayoutShadow(bgSize, inset)
	r.background.Move(inset)
	r.background.Resize(bgSize)

	hasIcon := r.icon != nil
	hasLabel := r.label.Text != ""
	if !hasIcon && !hasLabel {
		// Nothing to layout
		return
	}
	iconSize := r.ButtonCircle.IconSize
	// iconSize := fyne.NewSize(theme.IconInlineSize(), theme.IconInlineSize())
	labelSize := r.label.MinSize()
	padding := r.padding()
	if hasLabel {
		if hasIcon {
			// Both
			var objects []fyne.CanvasObject
			if r.ButtonCircle.IconPlacement == ButtonCircleIconLeadingText {
				objects = append(objects, r.icon, r.label)
			} else {
				objects = append(objects, r.label, r.icon)
			}
			// r.objects=objects
			r.icon.SetMinSize(iconSize)
			min := r.layout.MinSize(objects)
			r.layout.Layout(objects, min)
			pos := alignedCirclePosition(r.ButtonCircle.Alignment, padding, min, size)
			r.label.Move(r.label.Position().Add(pos))
			r.icon.Move(r.icon.Position().Add(pos))
		} else {
			// Label Only
			r.label.Move(alignedCirclePosition(r.ButtonCircle.Alignment, padding, labelSize, size))
			r.label.Resize(labelSize)
		}
	} else {
		// Icon Only
		r.icon.Move(alignedCirclePosition(r.ButtonCircle.Alignment, padding, iconSize, size))
		r.icon.Resize(iconSize)
	}
}

// MinSize calculates the minimum size of a ButtonCircle.
// This is based on the contained text, any icon that is set and a standard
// amount of padding added.
func (r *ButtonCircleRenderer) MinSize() (size fyne.Size) {
	hasIcon := r.icon != nil
	hasLabel := r.label.Text != ""
	iconSize := fyne.NewSize(theme.IconInlineSize(), theme.IconInlineSize())
	labelSize := r.label.MinSize()
	if hasLabel {
		size.Width = labelSize.Width
	}
	if hasIcon {
		if hasLabel {
			size.Width += theme.Padding()
		}
		size.Width += iconSize.Width
	}
	size.Height = fyne.Max(labelSize.Height, iconSize.Height)
	size = size.Add(r.padding())
	return
}

func (r *ButtonCircleRenderer) Refresh() {
	//设置鼠标移入和移出时的颜色
	if r.ButtonCircle.hovered {
		r.background.StrokeWidth = 2
		r.background.StrokeColor = colornames.Blanchedalmond
		r.background.FillColor = colornames.Lemonchiffon
	} else {
		r.background.StrokeWidth = 2
		r.background.StrokeColor = colornames.Snow
		r.background.FillColor = colornames.Lightgray
	}
	r.label.Text = r.ButtonCircle.Text
	r.updateIconAndText()
	r.applyTheme()
	r.background.Refresh()
	r.Layout(r.ButtonCircle.Size())
	canvas.Refresh(r.ButtonCircle)
	// canvas.Refresh(r.ButtonCircle.super())
}

// applyTheme updates this ButtonCircle to match the current theme
func (r *ButtonCircleRenderer) applyTheme() {
	r.background.FillColor = r.ButtonCircle.background.FillColor
	// r.background.FillColor = r.ButtonCircleColor()
	// r.label.TextSize = theme.TextSize()
	r.label.TextSize = r.ButtonCircle.label.TextSize
	r.label.Color = r.ButtonCircle.label.Color
	// r.label.Color = theme.ForegroundColor()
	switch {
	// case r.ButtonCircle.disabled:
	// r.label.Color = theme.DisabledColor()
	case r.ButtonCircle.Importance == HighCircleImportance:
		// r.label.Color = theme.BackgroundColor()
		r.label.Color = r.ButtonCircle.label.Color
	}
	if r.icon != nil && r.icon.Resource != nil {
		switch res := r.icon.Resource.(type) {
		case *theme.ThemedResource:
			if r.ButtonCircle.Importance == HighCircleImportance {
				r.icon.Resource = theme.NewInvertedThemedResource(res)
				r.icon.Refresh()
			}
		case *theme.InvertedThemedResource:
			if r.ButtonCircle.Importance != HighCircleImportance {
				r.icon.Resource = res.Original()
				r.icon.Refresh()
			}
		}
	}
}

按钮点击动画实现:

func newButtonCircleTapAnimation(bg *canvas.Circle, w fyne.Widget) *fyne.Animation {
	return fyne.NewAnimation(canvas.DurationStandard, func(done float32) {
		mid := (w.Size().Width - theme.Padding()) / 2
		size := mid * done
		bg.Resize(fyne.NewSize(size*2, w.Size().Height-theme.Padding()))
		bg.Move(fyne.NewPos(mid-size, theme.Padding()/2))

		r, g, bb, a := theme.PressedColor().RGBA()
		aa := uint8(a)
		fade := aa - uint8(float32(aa)*done)
		bg.FillColor = &color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(bb), A: fade}
		canvas.Refresh(bg)
	})
}

在项目中的实际应用示例:

// 创建带图标的圆形按钮
btnQuestionBankManagement := com.NewButtonCircleWithIcon("题库管理", mytheme.MyTheme{}.QuestionBankManagementIcon(), "v", func() {
    // 按钮点击事件处理
})

// 设置按钮样式
btnQuestionBankManagement.SetTxtSize(24)
btnQuestionBankManagement.SetTxtColor(colornames.Black)
btnQuestionBankManagement.SetBgColor(colornames.Lemonchiffon)
btnQuestionBankManagement.SetStrokeColor(colornames.Slategrey)
btnQuestionBankManagement.SetIconSize(70)

该组件具有以下特点:

  1. 支持文本和图标显示
  2. 支持水平和垂直布局
  3. 具有鼠标悬停效果
  4. 具有点击动画效果
  5. 可自定义颜色、大小等属性
  6. 完全基于Fyne框架实现,保持原生外观
8.1.2 标签组件

使用canvas.Text创建标签:

title := canvas.NewText("员工在线培训系统", colornames.Black)
title.TextSize = 30
title.Alignment = fyne.TextAlignCenter
8.1.3 布局组件

使用不同的布局管理器:

// 垂直布局
vboxM := container.NewVBox()

// 水平布局
hboxUserInfo := container.NewHBox()

// 网格布局
c := container.New(layout.NewGridLayoutWithColumns(2), cBtnQuestionBankManagement, cBtnPositionLearning, cBtnPositionTest, cBtnPositionExamination, cBtnSystemSettings, cBtnLogOut)
8.1.4 按钮组件

在项目中使用了多种按钮组件,包括自定义按钮和标准按钮:

// 自定义带图标按钮
btnLogin := com.NewButtonWithIcon("登录系统", mytheme.MyTheme{}.LoginEnterIcon(), "h", func() {
    // 登录处理逻辑
})

btnLogin.SetTxtSize(30)
btnLogin.SetTxtColor(colornames.Green)
btnLogin.SetBgColor(colornames.Lemonchiffon)
btnLogin.SetStrokeColor(colornames.Slategrey)
btnLogin.SetIconSize(60)

// 圆形按钮
btnPrev = com.NewButtonCircleWithIcon("上一题", mytheme.MyTheme{}.PagePrevIcon(), "v", func() {
    go switchTheTitle(prevNumber)
})
btnPrev.SetIconSize(27)
btnPrev.SetTxtSize(16)
8.1.5 输入框组件

项目中使用了标准输入框和自定义数字输入框:

// 标准输入框
entryUserId := widget.NewEntry()
entryUserId.SetPlaceHolder("卡号") //提示语句

// 自定义数字输入框
entryUserId := com.NewNumericalEntry()
entryUserId.SetPlaceHolder("卡号") //提示语句

// 密码输入框
entryPassWord := com.NewNumericalEntry()
entryPassWord.SetPlaceHolder("生日:格式【19880808】") //提示语句
entryPassWord.Password=true
8.1.6 选项组组件

项目中使用了单选按钮组来让用户选择答案,并处理选项选择事件:

//创建备选答案选项组
func createRadioGroup() *fyne.Container {
	lblSelectRadio := canvas.NewText("", colornames.Green)
	radioGroup := widget.NewRadioGroup(radioData, func(s string) {
		if len(s) > 0 {
			rsData[currentNumber-1].SelectRadio = s
			lblSelectRadio.Text = "->【" + s + "】"
			lblSelectRadio.Refresh()
		} else {
			rsData[currentNumber-1].SelectRadio = ""
			lblSelectRadio.Text = ""
			lblSelectRadio.Refresh()
		}
		//选择一个选项后,自动将选择答案保存到数据库对应的ID-答题人所选答案中
		b, err := api.UpdateSelectRadioData(db, s, currentNumber, lblToMsg)
		if err != nil {
			lblToMsg.Text = err.Error()
			lblToMsg.Color = colornames.Red
			lblToMsg.Refresh()
		}
		if !b {
			lblToMsg.Text = "数据保存失败?"
			lblToMsg.Color = colornames.Red
			lblToMsg.Refresh()
		}
	})
	
	radioGroup.Horizontal = true
	radioGroup.BaseWidget.MinSize().AddWidthHeight(100, 80)
	radioGroup.BaseWidget.Refresh()
	hRadioGroup := container.NewHBox(radioGroup, lblSelectRadio)
	return hRadioGroup
}

选项组组件的主要特点:

  1. 使用widget.NewRadioGroup创建单选按钮组
  2. 通过回调函数处理选项选择事件
  3. 根据选择状态更新UI显示
  4. 自动保存用户选择到数据库
  5. 提供错误处理和用户反馈
8.1.7 进度条组件

项目中使用了无限进度条来显示加载状态:

// 无限进度条
ProgressBarUserLogin := widget.NewProgressBarInfinite()
ProgressBarUserLogin.Stop()
ProgressBarUserLogin.Hide()

// 在需要时启动进度条
ProgressBarUserLogin.Show()
ProgressBarUserLogin.Start()

// 完成后停止进度条
ProgressBarUserLogin.Stop()
ProgressBarUserLogin.Hide()
8.1.8 表单组件

项目中使用了表格组件来展示数据:

// 创建表格
func CreateTable(data [][]string, header []string, colWidth []int, size fyne.Size, lblMsg *canvas.Text) *fyne.Container {
	//创建表格-表头
	tableHeader := widget.NewTable(nil, nil, nil)
	tableHeader.Length = func() (int, int) {
		return 1, len(header)
	}
	tableHeader.CreateCell = func() fyne.CanvasObject {
		label := canvas.NewText("", color.Black)
		c := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 66, Height: 40}), label)
		return c
	}
	tableHeader.UpdateCell = func(i widget.TableCellID, o fyne.CanvasObject) {
		co := o.(*fyne.Container)
		colHeader := co.Objects[0].(*canvas.Text)
		colHeader.Text = header[i.Col]
		colHeader.TextSize = 16
		colHeader.TextStyle.Bold = true
		colHeader.TextStyle.Monospace = true
		colHeader.Color = colornames.Blue
	}
	//设置各列宽度
	for key, v := range colWidth {
		tableHeader.SetColumnWidth(key, float32(v))
	}
	// ... 更多表格配置
}

8.2 当前项目综合案例详解-在线考试

主界面框架展示了多种FYNE组件的综合应用:

在线考试功能-开发代码详解

在线考试功能是本项目的核心功能之一,主要实现在demoMobileOnlineTest.go文件中。该功能包含了完整的考试流程,包括试卷下载、答题、交卷和成绩展示等环节。

8.2.1. 包引用和变量声明

首先,程序导入了必要的包和自定义模块:

import (
	"OnlineExamApp/api"
	com "OnlineExamApp/components"
	"OnlineExamApp/utils"

	// "OnlineExamApp/mock"
	"OnlineExamApp/model"
	mytheme "OnlineExamApp/theme"
	"errors"
	"fmt"
	"image/color"
	"strconv"
	"strings"
	"time"

	"fyne.io/fyne/v2"
	"golang.org/x/image/colornames"

	"fyne.io/fyne/v2/canvas"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/layout"
	"fyne.io/fyne/v2/widget"
)

接着声明了考试功能所需的各种全局变量:

// 用户信息变量
var name string
var dept string
var position string
var cardNumber string

// 考试数据变量
var examsData []model.OnLineExams
var examsDataRows = 0

// 题号控制变量
var currentNumber = 1 //当前题号
var prevNumber = 1    //上一题号
var nextNumber = 2    //下一题号

// 答题数据变量
var rsData []model.SelectRadios

// 界面组件变量
var btnPrev *com.ButtonCircle
var btnNext *com.ButtonCircle
var btnToViewALeakage *com.ButtonCircle
var btnSubmit *com.ButtonCircle
var lblDept *canvas.Text
var lblName *canvas.Text
var lblCardNumber *canvas.Text
var lblClock *canvas.Text
var lblMsg *canvas.Text
var btnReadExams *com.Button
var btnLoadLocalExamsData *com.Button

// 考试计时变量
var testTime = 00.00
var testSecond = 0

// 界面容器变量
var radioData = []string{}
var cBoxTexts *fyne.Container
var cRadioGroup *fyne.Container
var cAreaOfDataVariation *fyne.Container
var window fyne.Window
var image *canvas.Image

// 成绩展示变量
var cFinalScore *fyne.Container //最终得分
var lblToMsg *canvas.Text
var ToViewALeakage []string
var popModel *widget.PopUp         //用于弹出窗口显示漏题
var examinationSchemeNumber string //生产计划号
var lblExaminationSchemeNumber *canvas.Text
var testPaperDownloadTime string //试卷下载时间
var lblTestPaperDownloadTime *canvas.Text
var lblUpDataMsg *canvas.Text

8.2.2. 界面组件实例化

主考试界面通过VBoxMobileOnLineTest函数创建,包含用户信息展示、功能按钮和考试内容区域:

func VBoxMobileOnLineTest(a fyne.App, w fyne.Window) *fyne.Container {
	// 获取用户信息
	name = a.Preferences().String("userName")
	dept = a.Preferences().String("userDeptName")
	position = a.Preferences().String("userPositionName")
	cardNumber = a.Preferences().String("userCardNumber")
	
	// 设置窗口大小
	w.Resize(fyne.Size{Width: 360, Height: 680})
	window = w
	
	// 创建主容器
	vbox := container.NewVBox()
	
	// 标题
	title := canvas.NewText("岗位考试", colornames.Blue)
	title.TextSize = 20
	title.Alignment = fyne.TextAlignCenter
	vbox.Add(title)
	vbox.Add(widget.NewSeparator())
	
	// 用户信息展示区域
	hboxTopA := container.NewHBox()
	lblDept = canvas.NewText("部门:"+dept, colornames.Blue)
	lblDept.TextSize = 12
	hboxTopA.Add(lblDept)
	hboxTopA.Add(widget.NewSeparator())
	lblName = canvas.NewText("姓名:"+name, colornames.Blue)
	lblName.TextSize = 12
	hboxTopA.Add(lblName)
	hboxTopA.Add(widget.NewSeparator())
	lblCardNumber = canvas.NewText("卡号:"+cardNumber, colornames.Blue)
	lblCardNumber.TextSize = 12
	hboxTopA.Add(lblCardNumber)
	hboxTopA.Add(widget.NewSeparator())
	
	// 考试计时器
	lblClock = canvas.NewText("计时:0分00秒", colornames.Red)
	lblClock.TextSize = 12
	upTestTime(lblClock)
	// 每秒刷新一次计时器
	go func() {
		for range time.Tick(time.Second) {
			upTestTime(lblClock)
		}
	}()
	hboxTopA.Add(lblClock)
	vbox.Add(hboxTopA)
	vbox.Add(widget.NewSeparator())

	// 功能按钮区域
	hboxTopB := container.NewHBox()
	lblMsg = canvas.NewText("", colornames.Green)
	lblMsg.TextSize = 10
	vbox.Add(lblMsg)
	
	// 下载新试卷按钮
	btnReadExams = com.NewButtonWithIcon("下载新试卷", mytheme.MyTheme{}.DataDownIcon(), "h", func() {
		// 弹出确认对话框
		popMessage(w)
	})
	btnReadExams.SetIconSize(27)
	btnReadExams.SetTxtSize(18)
	btnReadExams.SetTxtColor(colornames.Blue)
	btnReadExams.SetBgColor(colornames.Lemonchiffon)
	btnReadExams.SetStrokeColor(colornames.Slategrey)
	hboxTopB.Add(btnReadExams)
	hboxTopB.Add(widget.NewSeparator())
	hboxTopB.Add(widget.NewSeparator())
	
	// 加载已答试卷按钮
	btnLoadLocalExamsData = com.NewButtonWithIcon("加载已答试卷", mytheme.MyTheme{}.LoadDataIcon(), "h", func() {
		// 返回第一行试卷数据,用于页面渲染
		loadFirstLineExamData(w)
	})
	btnLoadLocalExamsData.SetIconSize(27)
	btnLoadLocalExamsData.SetTxtSize(18)
	btnLoadLocalExamsData.SetTxtColor(colornames.Blue)
	btnLoadLocalExamsData.SetBgColor(colornames.Lemonchiffon)
	btnLoadLocalExamsData.SetStrokeColor(colornames.Slategrey)
	hboxTopB.Add(btnLoadLocalExamsData)
	vbox.Add(hboxTopB)
	vbox.Add(widget.NewSeparator())

	// 考试信息展示区域
	hboxTopC := container.NewHBox()
	lblExaminationSchemeNumber = canvas.NewText("考试计划号:", colornames.Blue)
	lblExaminationSchemeNumber.TextSize = 12
	hboxTopC.Add(lblExaminationSchemeNumber)
	vbox.Add(hboxTopC)
	vbox.Add(widget.NewSeparator())
	
	hboxTopD := container.NewHBox()
	lblTestPaperDownloadTime = canvas.NewText("试卷下载时间:", colornames.Blue)
	lblTestPaperDownloadTime.TextSize = 12
	hboxTopD.Add(lblTestPaperDownloadTime)
	vbox.Add(hboxTopD)
	vbox.Add(widget.NewSeparator())
	
	hboxTopE := container.NewHBox()
	lblPosition := canvas.NewText("答题试卷:岗位【"+position+"】", colornames.Blue)
	lblPosition.TextSize = 12
	hboxTopE.Add(lblPosition)
	vbox.Add(hboxTopE)
	vbox.Add(widget.NewSeparator())
	
	// 考试内容展示区域
	cAreaOfDataVariation = container.NewVBox()
	vbox.Add(cAreaOfDataVariation)
	return vbox
}

8.2.3. 功能相关事件代码

8.2.3.1 考试计时器更新功能

// 更新考试计时器显示
func upTestTime(clock *canvas.Text) {
	testSecond = testSecond + 1
	if testSecond == 60 {
		t := strconv.FormatFloat(testTime, 'f', 0, 32)
		bitSize := int(2)
		testTime, _ = strconv.ParseFloat(t, bitSize)
		testSecond = 0
	} else {
		testTime = testTime + 0.01
	}

	formatted := strconv.FormatFloat(testTime, 'f', 2, 32)
	formatted = strings.Replace(formatted, ".", "分", -1) + "秒"
	clock.Text = "计时:" + formatted
	clock.Refresh()
}

8.2.3.2 下载新试卷功能

// 下载新试卷
func downNewExamsData(w fyne.Window) {
	lblMsg.Text = "正在根据岗位生成试卷,请稍后。。。"
	lblMsg.Color = colornames.Green
	lblMsg.Refresh()
	
	// 从服务器获取在线考试数据
	OnlineExamsData, err := api.GetOnLineExamsData(cardNumber, lblMsg)
	if err != nil {
		lblMsg.Text = "生成试卷失败,请检查本机联网是否正常?"
		lblMsg.Color = colornames.Red
		lblMsg.Refresh()
	}
	
	lblMsg.Text = "正在保存试卷到本机数据库中,请稍后。。。"
	lblMsg.Color = colornames.Green
	lblMsg.Refresh()
	
	// 将获取的试卷数据保存到本地SQLite数据库中
	if err := api.SaveOnLineExamsData(db, OnlineExamsData, lblMsg); err != nil {
		return
	}
	
	// 返回第一行试卷数据,用于页面渲染
	loadFirstLineExamData(w)
}

8.2.3.3 弹出确认对话框功能

// 创建弹出窗体,用于显示【下载新试卷】和【加载已答试卷】提示信息
func popMessage(w fyne.Window) {
	vboxM := container.NewVBox()
	title := canvas.NewText("提示信息", colornames.Blue)
	title.TextSize = 25
	title.Alignment = fyne.TextAlignCenter
	vboxM.Add(title)
	vboxM.Add(widget.NewSeparator())
	
	lblAsk1 := canvas.NewText("你确认要【下载新试卷】吗?", colornames.Blue)
	lblAsk1.TextSize = 20
	lblAsk2 := canvas.NewText("(1)下载新试卷将覆盖已答试卷数据", colornames.Black)
	lblAsk3 := canvas.NewText("(2)如接续上次答题,请【加载已答试卷】", colornames.Red)
	lblAsk3.TextSize = 16
	scbox := container.NewVBox(lblAsk1, widget.NewSeparator(), lblAsk2, widget.NewSeparator(), lblAsk3)
	vboxM.Add(scbox)
	vboxM.Add(widget.NewSeparator())
	
	lblDownExamsMsg := canvas.NewText("", colornames.Blue)
	lblDownExamsMsg.TextSize = 10
	ProgressBarDown := widget.NewProgressBarInfinite()
	ProgressBarDown.Stop()
	ProgressBarDown.Hide()
	vboxM.Add(lblDownExamsMsg)
	vboxM.Add(ProgressBarDown)
	
	// 加载已答试卷按钮
	btnLoadData := com.NewButtonWithIcon("加载已答试卷", mytheme.MyTheme{}.LoadDataIcon(), "h", func() {
		popModel.Hide()
		// 返回第一行试卷数据,用于页面渲染
		loadFirstLineExamData(w)
	})
	btnLoadData.SetTxtSize(20)
	btnLoadData.SetTxtColor(colornames.Red)
	btnLoadData.SetBgColor(colornames.Lemonchiffon)
	btnLoadData.SetStrokeColor(colornames.Slategrey)
	btnLoadData.SetIconSize(25)

	// 下载新试卷按钮
	btnDownData := com.NewButtonWithIcon("下载新试卷", mytheme.MyTheme{}.DataDownIcon(), "h", func() {
		lblDownExamsMsg.Text = "正在下载新试卷,请稍后。。。"
		lblDownExamsMsg.Refresh()
		ProgressBarDown.Show()
		ProgressBarDown.Start()
		// 下载新试卷
		downNewExamsData(w)
		ProgressBarDown.Stop()
		ProgressBarDown.Hide()
		popModel.Hide()
	})

	btnDownData.SetTxtSize(16)
	btnDownData.SetTxtColor(colornames.Blue)
	btnDownData.SetBgColor(colornames.Lemonchiffon)
	btnDownData.SetStrokeColor(colornames.Slategrey)
	btnDownData.SetIconSize(20)

	hboxBtns := container.NewHBox(btnLoadData, widget.NewSeparator(), btnDownData)
	vboxM.Add(hboxBtns)
	popModel = widget.NewModalPopUp(vboxM, w.Canvas())
	popModel.Show()
}

8.2.3.4 加载本地试卷数据功能

// 加载已选答案数据
func loadSelectRadiosData() {
	examsDataRows, _ = api.QueryRowsCountOnLineExamsData(db, lblMsg)
	if examsDataRows > 0 {
		rsData, _ = api.QuerySelectRadiosData(db, lblMsg)
	}
}

// 加载本地试卷考试计划号
func loadExaminationSchemeNumber() {
	examinationSchemeNumber, _ = api.QueryExaminationSchemeNumberData(db, lblMsg)
	lblExaminationSchemeNumber.Text = "考试计划号:" + examinationSchemeNumber
	lblExaminationSchemeNumber.Refresh()
}

// 加载本地试卷下载时间
func loadTestPaperDownloadTime() {
	testPaperDownloadTime, _ = api.QueryTestPaperDownloadTimeData(db, lblMsg)
	lblTestPaperDownloadTime.Text = "试卷下载时间:" + testPaperDownloadTime
	lblTestPaperDownloadTime.Color = colornames.Blue
	lblTestPaperDownloadTime.Refresh()
}

// 判断试卷下载时间与本地时间之间的时间差
func checkTheTestPaperDownloadTime() float64 {
	// 将指定字符时间转换为时间
	t, _ := time.ParseInLocation("2006-01-02 15:04:05", testPaperDownloadTime, time.Local)
	// 计算两个时间差
	sub1 := time.Now().Sub(t)
	return sub1.Minutes()
}

// 加载第一行试卷数据
func loadFirstLineExamData(w fyne.Window) {
	// 返回第一行试卷数据,用于页面渲染
	var err error
	examsData, err = api.QueryIdOnLineExamsData(db, "1", lblMsg)
	if err != nil {
		return
	}
	dataRows := len(examsData)
	if dataRows > 0 {
		// 加载已选答案列表
		loadSelectRadiosData()
		// 加载本地试卷考试计划号
		loadExaminationSchemeNumber()
		// 加载本地试卷下载时间
		loadTestPaperDownloadTime()
		
		// 创建考试界面
		c := createExams(w)
		cAreaOfDataVariation.RemoveAll()
		cAreaOfDataVariation.Add(c)
		cAreaOfDataVariation.Refresh()
		
		currentNumber = 1 // 当前题号
		prevNumber = 1    // 上一题号
		nextNumber = 2    // 下一题号
		
		// 检查试卷下载时间是否超出考试时间规定的时限
		timeDifference := checkTheTestPaperDownloadTime()
		if timeDifference > 240000 {
			// 触发自动下载新试卷--弹出自定义信息对话框
			popMessageDwon(w)
		} else {
			// 切到第一题
			switchTheTitle(1)
		}
	}
}

8.2.3.5 创建考试界面功能

// 创建题目及备选答案及题目导航条控件
func createExams(w fyne.Window) *fyne.Container {
	vbox := container.NewVBox()
	
	// 根据题号创建题目和备选答案
	vBoxTexts := createTexts(1)
	cBoxTexts = container.New(layout.NewGridWrapLayout(fyne.Size{Width: 360, Height: 250}), vBoxTexts)
	vbox.Add(cBoxTexts)
	vbox.Add(widget.NewSeparator())
	
	// 创建选项组和选定答案标签
	hRadioGroup := createRadioGroup()
	cRadioGroup = container.NewVBox()
	cRadioGroup.Add(hRadioGroup)
	vbox.Add(cRadioGroup)
	vbox.Add(widget.NewSeparator())
	
	// 题目导航条
	// 上一题按钮
	btnPrev = com.NewButtonCircleWithIcon("上一题", mytheme.MyTheme{}.PagePrevIcon(), "v", func() {
		go switchTheTitle(prevNumber)
	})
	btnPrev.SetIconSize(27)
	btnPrev.SetTxtSize(16)
	cBtnPrev := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 80, Height: 80}), btnPrev)
	
	// 下一题按钮
	btnNext = com.NewButtonCircleWithIcon("下一题", mytheme.MyTheme{}.PageNextIcon(), "v", func() {
		go switchTheTitle(nextNumber)
	})
	btnNext.SetIconSize(27)
	btnNext.SetTxtSize(16)
	cBtnNext := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 80, Height: 80}), btnNext)
	
	// 查漏答按钮
	btnToViewALeakage = com.NewButtonCircleWithIcon("查漏答", mytheme.MyTheme{}.QueryIcon(), "v", func() {
		// 获取漏题数据
		getToViewALeakageData()
		if len(ToViewALeakage) > 0 {
			// 弹出漏题列表窗口
			popToViewALeakageList(ToViewALeakage, w)
		} else {
			lblToMsg.Text = "所有题目已答,检查无误后交卷"
			lblToMsg.Color = colornames.Green
			lblToMsg.Refresh()
		}
	})
	btnToViewALeakage.SetIconSize(27)
	btnToViewALeakage.SetTxtSize(16)
	cBtnToViewALeakage := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 80, Height: 80}), btnToViewALeakage)
	
	// 交卷按钮
	btnSubmit = com.NewButtonCircleWithIcon("交卷", mytheme.MyTheme{}.SubmitIcon(), "v", func() {
		// 获取漏题数据
		getToViewALeakageData()
		if len(ToViewALeakage) > 0 {
			// 弹出漏题列表窗口
			popToViewALeakageList(ToViewALeakage, w)
		} else {
			lblToMsg.Text = "所有题目已答,检查无误后交卷"
			lblToMsg.Color = colornames.Green
			lblToMsg.Refresh()
			// 创建弹出窗体,用于显示【交卷前确认】提示信息
			popMessageUpExams(w)
		}
	})
	btnSubmit.SetIconSize(27)
	btnSubmit.SetTxtSize(16)
	cBtnSubmit := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 80, Height: 80}), btnSubmit)
	
	// 导航按钮布局
	hPagingToolbar := container.NewHBox(cBtnPrev, cBtnNext, cBtnToViewALeakage, cBtnSubmit)
	vbox.Add(hPagingToolbar)
	vbox.Add(widget.NewSeparator())

	lblToMsg = canvas.NewText("", colornames.Blue)
	vbox.Add(lblToMsg)

	return vbox
}

8.2.3.6 选项组创建功能

// 创建备选答案选项组
func createRadioGroup() *fyne.Container {
	lblSelectRadio := canvas.NewText("", colornames.Green)
	radioGroup := widget.NewRadioGroup(radioData, func(s string) {
		if len(s) > 0 {
			rsData[currentNumber-1].SelectRadio = s
			lblSelectRadio.Text = "->【" + s + "】"
			lblSelectRadio.Refresh()
		} else {
			rsData[currentNumber-1].SelectRadio = ""
			lblSelectRadio.Text = ""
			lblSelectRadio.Refresh()
		}
		
		// 选择一个选项后,自动将选择答案保存到数据库对应的ID-答题人所选答案中
		b, err := api.UpdateSelectRadioData(db, s, currentNumber, lblToMsg)
		if err != nil {
			lblToMsg.Text = err.Error()
			lblToMsg.Color = colornames.Red
			lblToMsg.Refresh()
		}
		if !b {
			lblToMsg.Text = "数据保存失败?"
			lblToMsg.Color = colornames.Red
			lblToMsg.Refresh()
		}
	})

	radioGroup.Horizontal = true
	radioGroup.BaseWidget.MinSize().AddWidthHeight(100, 80)
	radioGroup.BaseWidget.Refresh()
	hRadioGroup := container.NewHBox(radioGroup, lblSelectRadio)
	return hRadioGroup
}

8.2.3.7 题目切换功能

// 切题
func switchTheTitle(i int) {
	currentNumber = i // 当前题号
	if i == 1 {
		prevNumber = 1 // 上一题号
	} else {
		prevNumber = i - 1 // 上一题号
	}
	if i == examsDataRows {
		nextNumber = examsDataRows // 上一题号
	} else {
		nextNumber = i + 1 // 上一题号
	}

	examsData, _ = api.QueryIdOnLineExamsData(db, strconv.Itoa(currentNumber), lblMsg)
	selectRadioValue := rsData[currentNumber-1].SelectRadio
	
	// 切题
	vBoxTexts := createTexts(currentNumber)
	cBoxTexts.RemoveAll()
	cBoxTexts.Add(vBoxTexts)
	
	// 切选项组
	hRadioGroup := createRadioGroup()
	cRadioGroup.RemoveAll()
	cRadioGroup.Add(hRadioGroup)
	
	if len(selectRadioValue) != 0 {
		hRadioGroup.Objects[0].(*widget.RadioGroup).SetSelected(selectRadioValue)
		hRadioGroup.Objects[1].(*canvas.Text).Text = "->【" + selectRadioValue + "】"
	}
}

8.2.3.8 交卷功能

// 创建弹出窗体,用于显示【交卷前确认】提示信息
func popMessageUpExams(w fyne.Window) {
	vboxM := container.NewVBox()
	title := canvas.NewText("提示信息", colornames.Blue)
	title.TextSize = 25
	title.Alignment = fyne.TextAlignCenter
	vboxM.Add(title)
	vboxM.Add(widget.NewSeparator())
	
	lblAsk1 := canvas.NewText("您确定要交卷吗?", colornames.Blue)
	lblAsk1.TextSize = 25
	lblAsk2 := canvas.NewText("1、交卷前请检查好所选答案是否正确?", colornames.Black)
	lblAsk2.TextSize = 16
	lblAsk3 := canvas.NewText("2、交卷后试卷将锁定,不能修改?", colornames.Red)
	lblAsk3.TextSize = 16
	scbox := container.NewVBox(lblAsk1, widget.NewSeparator(), lblAsk2, widget.NewSeparator(), lblAsk3)
	vboxM.Add(scbox)
	vboxM.Add(widget.NewSeparator())
	
	lblUpExamsMsg := canvas.NewText("", colornames.Blue)
	lblUpExamsMsg.TextSize = 10
	ProgressBar := widget.NewProgressBarInfinite()
	ProgressBar.Stop()
	ProgressBar.Hide()
	vboxM.Add(lblUpExamsMsg)
	vboxM.Add(ProgressBar)
	
	// 检查试卷按钮
	btnClose := com.NewButtonWithIcon("检查试卷", mytheme.MyTheme{}.WarningIcon(), "h", func() {
		popModel.Hide()
	})
	btnClose.SetTxtSize(22)
	btnClose.SetTxtColor(colornames.Red)
	btnClose.SetBgColor(colornames.Lemonchiffon)
	btnClose.SetStrokeColor(colornames.Slategrey)
	btnClose.SetIconSize(28)
	
	// 确认交卷按钮
	btnEnterUpExams := com.NewButtonWithIcon("确认交卷", mytheme.MyTheme{}.EnterIcon(), "h", func() {
		lblUpExamsMsg.Text = "正在向云服务器提交试卷,请稍后。。。"
		lblUpExamsMsg.Refresh()
		ProgressBar.Show()
		ProgressBar.Start()
		
		// 更新sqlite数据库中所有题目的最后分数
		_, err := api.UpdateFinalScoreData(db, lblToMsg)
		if err != nil {
			lblToMsg.Text = err.Error()
			return
		}
		
		// 获取sqlite数据库中已答试卷数据
		upExams, _ := api.QueryUpExamsData(db, lblMsg)
		lblUpDataMsg = canvas.NewText("", colornames.Green)
		
		if len(upExams) > 0 {
			// 上传试卷数据到云服务器,云服务器返回状态信息
			reqInfo, _ := api.PostLocalExamsDataToCloudServer(upExams)
			status := utils.Strval(reqInfo["status"])
			msg := utils.Strval(reqInfo["msg"])
			lblUpDataMsg = canvas.NewText(msg, colornames.Green)
			
			switch status {
			case "ok":
				ProgressBar.Stop()
				ProgressBar.Hide()
				// 隐藏弹窗
				popModel.Hide()
				// 弹出最终得分窗口
				popFinalScore(w)
			case "saveErr":
				lblUpExamsMsg.Text = msg
				lblUpExamsMsg.Refresh()
				ProgressBar.Stop()
				ProgressBar.Hide()
			case "repeatErr":
				ProgressBar.Stop()
				ProgressBar.Hide()
				popModel.Hide()
				lblUpDataMsg.Color = colornames.Red
				// 弹出最终得分窗口
				popFinalScore(w)
			}
		} else {
			lblUpExamsMsg.Text = "对不起,获取试卷失败,请稍后再试"
			lblUpExamsMsg.Color = colornames.Red
			lblUpExamsMsg.Refresh()
		}
	})
	
	btnEnterUpExams.SetTxtSize(16)
	btnEnterUpExams.SetTxtColor(colornames.Green)
	btnEnterUpExams.SetBgColor(colornames.Lemonchiffon)
	btnEnterUpExams.SetStrokeColor(colornames.Slategrey)
	btnEnterUpExams.SetIconSize(20)
	
	hboxBtns := container.NewHBox(btnClose, widget.NewSeparator(), widget.NewSeparator(), widget.NewSeparator(), btnEnterUpExams)
	vboxM.Add(hboxBtns)
	popModel = widget.NewModalPopUp(vboxM, w.Canvas())
	popModel.Show()
}

8.2.3.9 成绩展示功能

// 创建交卷后最终得分窗体,用于显示【交卷后最终得分】提示信息
func popFinalScore(w fyne.Window) {
	vboxM := container.NewVBox()
	title := canvas.NewText("各岗位试卷最终得分如下:", colornames.Blue)
	title.TextSize = 25
	title.Alignment = fyne.TextAlignCenter
	vboxM.Add(title)
	vboxM.Add(widget.NewSeparator())
	
	// 获取最终成绩数据
	data, _ := api.QueryFinalScoresData(db, lblToMsg)
	list := NewListStruct(data)
	
	// 最终得分显示
	cFinalScore = container.NewVBox()
	sFinalScore := container.NewVScroll(cFinalScore)
	sFinalScore.SetMinSize(fyne.Size{Width: 300, Height: 300})
	cFinalScore.RemoveAll()
	cFinalScore.Add(list)
	vboxM.Add(cFinalScore)
	vboxM.Add(widget.NewSeparator())
	vboxM.Add(lblUpDataMsg)
	vboxM.Add(widget.NewSeparator())
	
	// 关闭按钮
	btnFinalScoreClose := com.NewButtonWithIcon("关闭", mytheme.MyTheme{}.CloseIcon(), "h", func() {
		popModel.Hide()
		lblToMsg.Text = ""
		lblToMsg.Color = colornames.Blue
		lblToMsg.Refresh()
	})
	btnFinalScoreClose.SetTxtSize(20)
	btnFinalScoreClose.SetTxtColor(colornames.Red)
	btnFinalScoreClose.SetBgColor(colornames.Lemonchiffon)
	btnFinalScoreClose.SetStrokeColor(colornames.Slategrey)
	btnFinalScoreClose.SetIconSize(25)
	vboxM.Add(btnFinalScoreClose)
	popModel = widget.NewModalPopUp(vboxM, w.Canvas())
	popModel.Show()
}

// 创建最终得分列表
func NewListStruct(data []model.FinalScores) *fyne.Container {
	list := widget.NewList(
		func() int {
			return len(data)
		},
		func() fyne.CanvasObject {
			lblPosition := widget.NewLabel("")
			lblScore := widget.NewLabel("")
			lblIsPass := canvas.NewText("", colornames.Black)
			cPosition := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 100, Height: 40}), lblPosition)
			cScore := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 100, Height: 40}), lblScore)
			cIsPass := container.New(layout.NewGridWrapLayout(fyne.Size{Width: 100, Height: 40}), lblIsPass)
			return container.NewHBox(cPosition, widget.NewSeparator(), cScore, widget.NewSeparator(), cIsPass)
		},
		func(lii widget.ListItemID, co fyne.CanvasObject) {
			item := data[lii]
			c := co.(*fyne.Container)
			lblPosition := c.Objects[0].(*fyne.Container).Objects[0].(*widget.Label)
			lblPosition.SetText(item.Position)
			lblScore := c.Objects[2].(*fyne.Container).Objects[0].(*widget.Label)
			score := strconv.Itoa(item.Score)
			if lii == 0 {
				lblScore.SetText(score)
			} else {
				lblScore.SetText(score + "分")
			}
			lblIsPass := c.Objects[4].(*fyne.Container).Objects[0].(*canvas.Text)
			if item.Score > 60 {
				lblIsPass.Text = "及格"
				lblIsPass.Color = colornames.Green
			} else {
				lblIsPass.Text = "不及格,需补考"
				lblIsPass.Color = colornames.Red
			}
		},
	)
	cList := container.New(layout.NewGridWrapLayout(fyne.NewSize(355, 300)), list)
	return cList
}

9. 应用程序入口main.go

package main

import (
	"database/sql"
	"fmt"
	"os"
	"strconv"
	"sync"
	"time"
	"OnlineExamApp/api"
	mytheme "OnlineExamApp/theme"
	"OnlineExamApp/utils"
	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/canvas"
	"fyne.io/fyne/v2/container"
	"golang.org/x/image/colornames"
)

var db *sql.DB
var vbox *fyne.Container

func databaseOpen() {
	var err error
	db, err = utils.GetConnection()
	if err != nil {
		fmt.Println("数据库打开错误,请稍后再试?")
	}
}

// 初始化时设置环境字体,用于显示中文,在main()函数最后删除环境字体
func init() {
	//系统主题颜色为高亮
	os.Setenv("FYNE_THEME", "light")
	//系统界面缩放大小
	os.Setenv("FYNE_SCALE", "1.0")
	//打开数据库
	databaseOpen()
}

// 为应用程序添加全局key
func setAppPerferences(a fyne.App) {
	a.Preferences().SetString("userId", "")
	a.Preferences().SetString("userName", "")
	a.Preferences().SetString("userPassword", "")
	a.Preferences().SetString("userDeptName", "")
	a.Preferences().SetString("userDeptId", "")
	a.Preferences().SetString("userPositionName", "")
	a.Preferences().SetString("userPositionId", "")
	a.Preferences().SetString("userCardNumber", "")
}
func main() {

	a := app.NewWithID("com.example.onlineexamapp")
	//设置App全局变量值
	setAppPerferences(a)
	//设置主题颜色,只有黑色和白色,默认为黑色
	//a.Settings().SetTheme(theme.LightTheme())
	//mytheme := &theme.MyTheme{}
	a.Settings().SetTheme(&mytheme.MyTheme{})
	//创建主窗口
	w := a.NewWindow("员工在线考试系统V1.0")
	w.Content().Move(fyne.NewPos(10, 10))
	//设置窗口的运行大小
	w.Resize(fyne.Size{Width: 360, Height: 680})
	vbox = container.NewVBox()
	//----------------------------------------------------------------
	//弹出用户登录
	//主界面
	vbox.Add(VBoxMainFrame(a, w))
	//----------------------------------------------------------------
	//将容器添加到窗口中
	w.SetContent(vbox)
	w.ShowAndRun()
	db.Close()
}


  • 以上代码完整展示了在线考试功能的实现,包括用户界面创建、考试流程控制、数据交互和成绩展示等核心功能。通过这些代码,用户可以完成从登录到考试再到成绩查看的完整流程。

10. 编码打包

根据Demo.md中的说明,项目支持多种平台的打包:

10.1 打包桌面程序

# 安装fyne命令行工具
go install fyne.io/fyne/v2/cmd/fyne@latest

# 打包不同平台的程序
fyne package -os darwin -icon myapp.png
fyne package -os linux -icon myapp.png
fyne package -os windows -icon myapp.png

10.2 打包手机程序

# 打包Android应用
fyne package -os android -appID com.example.onlineexamapp -icon mobileIcon.png

# 打包iOS应用
fyne package -os ios -appID com.example.myapp -icon mobileIcon.png

10.3 发布应用

  • 详细的发布步骤请参考FYNE官网文档发布应用。文件中的说明。