使用 Go 和 Wails 构建跨平台桌面应用程序

由于多种原因,Electron 曾经(并且仍然)大受欢迎。首先,其跨平台功能使开发人员能够从单个代码库支持 Linux、Windows 和 macOS。最重要的是,它对于熟悉 Javascript 的开发人员来说有一个精简的学习曲线。

尽管它有其缺点(其中应用程序大小和内存消耗最为突出),但它为创建跨平台桌面应用程序提供了丰富的可能性。

然而,自其发布以来,许多替代品也加入了竞争。本文探讨了这样一种替代方案 - Wails,该项目使得使用 Go 和 Web 技术(例如 React 和 Vue)编写桌面应用程序成为可能。Wails 的一个主要卖点是它不嵌入浏览器,而是使用平台的本机渲染引擎。这使其成为Electron 的轻量级替代品。

为了熟悉 Wails,您将构建一个 GitHub 桌面客户端,它将与GitHub API交互,并提供以下功能:

  1. 查看公共存储库和要点
  2. 查看经过身份验证的用户的私有存储库和要点
  3. 为经过身份验证的用户创建一个新的要点。

后端将用 Go 编写,前端将使用React和Vite 。UI 组件将使用Ant Design (AntD)创建。

怎么运行的
如前所述,Wails 的工作原理是将用 Go 编写的后端与使用 Javascript 库/框架或使用 Vanilla HTML 和 Javascript 编写的前端相结合。即使您的函数和数据类型是在后端声明的,Wails 也可以在前端调用它们。更重要的是,当在后端声明一个结构体时,Wails 能够生成一个TypeScript模型以在前端使用。其结果是前端和后端之间的无缝通信。您可以在此处阅读有关 Wails 如何工作的更多信息。

先决条件
要学习本教程,您将需要以下内容:

  • 对Go和React的基本了解
  • go1.19
  • 新项目管理
  • Wails的最新安装

入门
通过运行以下命令创建一个新的 Wails 项目

wails init -n github_demo -t react

这搭建了一个新项目,后端使用 Go,前端使用 React + Vite。脚手架过程完成后,通过运行以下命令导航到新创建的文件夹并运行项目。

cd github_demo
wails dev

这将运行应用程序,如下图所示。

在这里插入图片描述
关闭应用程序并在您喜欢的编辑器或 IDE 中打开项目目录,开始向应用程序添加功能。

构建后端
添加 API 请求功能
应用程序首先需要具备向 GitHub API 发送 GET 和 POST 请求的能力。在应用程序的根目录中,创建一个名为api.go的新文件。在此文件中,添加以下代码。

package main
import (
        "bytes"
        "fmt"
        "io"
        "net/http"
)
func makeRequest(requestType, url, token string, payload []byte ) ([]byte, error){
        client := &http.Client{}

        var request *http.Request
        if payload != nil {
                requestBody := bytes.NewReader(payload)
                request, _ = http.NewRequest(requestType, url, requestBody)
        } else {
                request, _ = http.NewRequest(requestType, url, nil)
        }

        request.Header.Set("Accept", "application/vnd.github+json")

        if token != "" {
                request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
        }

        response, err := client.Do(request)
        if err != nil {
                return nil, fmt.Errorf("request failed: %w", err)
        }

        body, _ := io.ReadAll(response.Body)
        return body, nil
}

func MakeGetRequest(url string, token string) ([]byte, error) {
        return makeRequest("GET", url, token, nil)
}

func MakePostRequest(url, token string, payload []byte) ([]byte, error){
        return makeRequest("POST", url, token, payload)
}

该makeRequest()函数在内部用于向指定的 URL 发出请求。除了指定 URL 之外,请求类型、令牌和负载也会传递给该函数。使用这些,可以准备请求并与函数返回的 API 响应一起发送。

和函数分别包裹该函数以MakeGetRequest()发送GET 和 POST 请求。MakePostRequest()makeRequest()

将辅助函数绑定到应用程序
有了 API 功能,您可以声明一些将绑定到前端的辅助函数。这是通过为结构添加接收器函数来完成的App。

您可以在app.go末尾看到一个示例,其中Greet()声明了一个名为 的接收器函数。

func (a *App) Greet(name string) string {
        return fmt.Sprintf("Hello %s, It's show time!", name)
}

现在,将以下代码添加到app.go。

type APIResponse []interface{}
type Gist struct {
        Description string      `json:"description"`
        Public      bool        `json:"public"`
        Files       interface{} `json:"files"`
}

const BaseUrl = "https://api.github.com"

var githubResponse APIResponse

func (a *App) GetPublicRepositories() (APIResponse, error) {
        url := fmt.Sprintf("%s/repositories", BaseUrl)
        response, err := MakeGetRequest(url, "")

        if err != nil {
                return nil, err
        }

        json.Unmarshal(response, &githubResponse)
        return githubResponse, nil
}

func (a *App) GetPublicGists() (APIResponse, error) {
        url := fmt.Sprintf("%s/gists/public", BaseUrl)
        response, err := MakeGetRequest(url, "")

        if err != nil {
                return nil, err
        }

        json.Unmarshal(response, &githubResponse)
        return githubResponse, nil
}

func (a *App) GetRepositoriesForAuthenticatedUser(token string) (APIResponse, error) {
        url := fmt.Sprintf("%s/user/repos?type=private", BaseUrl)
        response, err := MakeGetRequest(url, token)

        if err != nil {
                return nil, err
        }

        json.Unmarshal(response, &githubResponse)
        return githubResponse, nil
}

func (a *App) GetGistsForAuthenticatedUser(token string) (APIResponse, error) {
        url := fmt.Sprintf("%s/gists", BaseUrl)
        response, err := MakeGetRequest(url, token)

        if err != nil {
                return nil, err
        }

        json.Unmarshal(response, &githubResponse)
        return githubResponse, nil
}

func (a *App) GetMoreInformationFromURL(url, token string) (APIResponse, error) {
        response, err := MakeGetRequest(url, token)

        if err != nil {
                return nil, err
        }

        json.Unmarshal(response, &githubResponse)
        return githubResponse, nil
}

func (a *App) GetGistContent(url, token string) (string, error) {
        githubResponse, err := MakeGetRequest(url, token)

        if err != nil {
                return "", err
        }

        return string(githubResponse), nil
}

func (a *App) CreateNewGist(gist Gist, token string) (interface{}, error) {
        var githubResponse interface{}

        requestBody, _ := json.Marshal(gist)
        url := fmt.Sprintf("%s/gists", BaseUrl)
        response, err := MakePostRequest(url, token, requestBody)

        if err != nil {
                return nil, err
        }
        json.Unmarshal(response, &githubResponse)
        return githubResponse, nil
}

然后,如果您的文本编辑器或 IDE 没有自动为您执行此操作,请将“encoding/json”添加到文件顶部的导入列表中。

除了现有代码之外,它还声明了两种新类型:APIResponse和Gist。这些将分别用于对来自 API 的响应和 Gist 的结构进行建模。接下来,它声明该App结构的接收器函数:

该GetPublicRepositories()函数通过 GET 请求从 GitHub API 检索公共存储库列表。由于此路由不需要身份验证,因此将传递一个空字符串作为令牌。
该GetPublicGists()函数通过 GET 请求从 GitHub API 检索公共要点列表。也不需要身份验证,因此将空字符串作为令牌传递。
该GetRepositoriesForAuthenticatedUser()函数用于获取经过身份验证的用户的私有存储库的列表。该函数将令牌作为参数。
该GetGistsForAuthenticatedUser()函数用于检索经过身份验证的用户的要点。该函数还采用令牌作为参数。
该GetMoreInformationFromURL()函数用于获取有关存储库的更多信息。此信息可以是提交历史记录、贡献者列表或已为存储库添加星标的用户列表。它需要两个参数,即要调用的 url 和身份验证令牌。对于公共存储库,令牌将为空字符串。
该GetGistContent()函数用于获取 Gist 的内容。该函数采用 Gist 原始内容的 URL 和身份验证令牌(公共 Gists 为空字符串)。它返回与 Gist 内容相对应的字符串。
该CreateNewGist()函数用于为经过身份验证的用户创建新的要点。该函数采用两个参数,即要创建的要点以及用户的身份验证令牌。

构建前端
前端的所有代码都存储在frontend文件夹中。但在编写任何代码之前,请使用以下命令添加 JavaScript 依赖项。

cd frontend
npm install antd @ant-design/icons react-router-dom prismjs

依赖关系如下:

Ant Design - 这可以帮助设计师/开发人员轻松构建美观且灵活的产品
Ant-design 图标- 这使您可以访问 AntD 的 SVG 图标集
React-router - 这将用于实现客户端路由
Prismjs - 这将用于实现 Gists 的语法突出显示

接下来,在frontend/src文件夹中创建一个名为Components的文件夹。

添加身份验证
为了进行身份验证,用户需要提供 GitHub个人访问令牌。该令牌包含在对需要身份验证的端点的请求标头中。如果您没有,请创建一个 - 但是,您必须为您的令牌设置以下权限才能用于此项目。

在这里插入图片描述
对于此项目,React Context API将用于存储令牌一小时,之后用户必须再次提供令牌来重新进行身份验证。

在frontend/src/components文件夹中,创建一个名为context的新文件夹。在该文件夹中,创建一个名为AuthModal.jsx的新文件并向其中添加以下代码。

import {Form, Input, Modal} from "antd";
import {EyeInvisibleOutlined, EyeTwoTone} from "@ant-design/icons";

const AuthModal = ({shouldShowModal, onSubmit, onCancel}) => {
    const [form] = Form.useForm();

    const onFormSubmit = () => {
        form.validateFields().then((values) => {
            onSubmit(values.token);
        });
    };

    return (<Modal
            title="Provide Github Authentication Token"
            centered
            okText="Save"
            cancelText="Cancel"
            open={shouldShowModal}
            onOk={onFormSubmit}
            onCancel={onCancel}
        >
            <Form
                form={form}
                name="auth_form"
                initialValues={{
                    token: "",
                }}
            >
                <Form.Item
                    name="token"
                    label="Token"
                    rules={[{
                        required: true, message: "Please provide your Github Token!",
                    },]}
                >
                    <Input.Password
                        placeholder="Github Token"
                        iconRender={(visible) => visible ? <EyeTwoTone/> : <EyeInvisibleOutlined/>}
                    />
                </Form.Item>
            </Form>
        </Modal>);
};

export default AuthModal;

该组件呈现身份验证表单。该表单有一个字段供用户粘贴和保存令牌。propshouldShowModal用于有条件地渲染表单,而onSubmit和onCancelprop 用于响应用户的操作。

接下来,再次在context文件夹中创建一个名为AuthContext.jsx的新文件,并向其中添加以下代码。

import {Button, Result} from "antd";
import React, {createContext, useContext, useEffect, useState} from "react";
import AuthModal from "./AuthModal";
import {useNavigate} from "react-router-dom";

const AuthContext = createContext({});

const AuthContextProvider = ({children}) => {
    const [token, setToken] = useState(null);
    const [shouldShowModal, setShouldShowModal] = useState(true);

    const navigate = useNavigate();

    useEffect(() => {
        const timer = setTimeout(() => {
            if (token !== null) {
                setToken(null);
                setShouldShowModal(true);
            }
        }, 3600000);
        return () => clearTimeout(timer);
    }, [token]);

    const onSubmit = (token) => {
        setToken(token);
        setShouldShowModal(false);
    };

    const onCancel = () => {
        setShouldShowModal(false);
    };

    if (!shouldShowModal && !token) {
        return (
            <Result
                status="error"
                title="Authentication Failed"
                subTitle="A Github token is required to view this page"
                extra={[
                    <Button
                        type="link"
                        key="home"
                        onClick={() => {
                            navigate("/");
                        }}
                    >
                        Public Section
                    </Button>,
                    <Button
                        key="retry"
                        type="primary"
                        onClick={() => {
                            setShouldShowModal(true);
                        }}
                    >
                        Try Again
                    </Button>,
                ]}
            />
        );
    }

    return (
        <>
            {shouldShowModal && (
                <AuthModal
                    shouldShowModal={shouldShowModal}
                    onSubmit={onSubmit}
                    onCancel={onCancel}
                />
            )}
            <AuthContext.Provider value={{token}}>{children}</AuthContext.Provider>
        </>
    );
};

export const useAuthContext = () => {
    const context = useContext(AuthContext);
    if (context === undefined) {
        throw new Error("useAuthContext must be used within a AuthContextProvider");
    }
    return context;
};

export default AuthContextProvider;

exports这个文件里有两个。第一个是useAuthContext钩子。该钩子将用于检索保存在 中的令牌Context。第二个是AuthContextProvider组件。该组件负责呈现身份验证表单(在页面加载时或令牌在 1 小时后“过期”时)。

如果用户单击身份验证表单上的“取消”,它还会呈现错误页面。该组件采用 JSX 元素(名为children)作为 prop,并用上下文提供程序将其包装起来 — 从而使子元素能够访问令牌的值。

添加主从布局
为了显示存储库和要点,将使用主从布局。将呈现项目列表,单击其中一项将在列表旁边显示有关所选项目的更多信息。

在Components文件夹中,创建一个名为ListItem.jsx的新文件,并向其中添加以下代码。

import { useEffect, useState } from "react";
import { Avatar, Card, Skeleton } from "antd";

const ListItem = ({ item, onSelect, selectedItem, title }) => {
  const [loading, setLoading] = useState(true);
  const [gridStyle, setGridStyle] = useState({
    margin: "3%",
    width: "94%",
  });

  useEffect(() => {
    const isSelected = selectedItem?.id === item.id;
    setGridStyle({
        margin: "3%",
        width: "94%",
      ...(isSelected && { backgroundColor: "lightblue" }),
    });

  }, [selectedItem]);

  const onClickHandler = () => {
    onSelect(item);
  };

  useEffect(() => {
    setTimeout(() => {
      setLoading(false);
    }, 3000);
  }, []);

  return (
    <Card.Grid hoverable={true} style={gridStyle} onClick={onClickHandler}>
      <Skeleton loading={loading} avatar active>
        <Card.Meta
          avatar={<Avatar src={item.owner.avatar_url} />}
          title={title}
          description={`Authored by ${item.owner.login}`}
        />
      </Skeleton>
    </Card.Grid>
  );
};

export default ListItem;

该组件使用 AntD Card组件呈现列表中的单个项目。卡片的标题作为组件道具提供。除了标题之外,该组件还接收其他三个属性:

该onSelect道具用于通知父项该卡已被点击
item对应于将在卡上呈现的要点或存储库
selectedItem组件使用它来确定用户是否单击了呈现的项目;在这种情况下,浅蓝色背景将添加到卡片样式中。
接下来,在组件文件夹中创建一个名为MasterDetail.jsx的新文件,并向其中添加以下代码。

import {useState} from "react";
import {Affix, Card, Col, Row, Typography} from "antd";
import ListItem from "./ListItem";

const MasterDetail = ({title, items, getItemDescription, detailLayout}) => {
    const [selectedItem, setSelectedItem] = useState(null);

    return (<>
            <Row justify="center">
                <Col>
                    <Typography.Title level={3}>{title}</Typography.Title>
                </Col>
            </Row>
            <Row>
                <Col span={6}>
                    <Affix offsetTop={20}>
                        <div
                            id="scrollableDiv"
                            style={{
                                height: "80vh", overflow: "auto", padding: "0 5px",
                            }}
                        >
                            <Card bordered={false} style={{boxShadow: "none"}}>
                                {items.map((item, index) => (<ListItem
                                        key={index}
                                        item={item}
                                        onSelect={setSelectedItem}
                                        selectedItem={selectedItem}
                                        title={getItemDescription(item)}
                                    />))}
                            </Card>
                        </div>
                    </Affix>
                </Col>
                <Col span={18}>{selectedItem && detailLayout(selectedItem)}</Col>
            </Row>
        </>);
};

export default MasterDetail;

该组件负责呈现一列中的项目列表以及另一列中所选项目的详细信息。要渲染的项目作为组件的道具提供。

除此之外,getItemDescription()prop 是一个获取用户头像下显示内容的函数;这是存储库名称或要点描述。

propdetailLayout()是父组件提供的函数,它根据提供的项目返回详细信息部分的 JSX 内容。这允许 Gists 和存储库在使用相同的子组件进行渲染时具有完全不同的布局。

添加存储库相关组件
接下来,在组件文件夹中,创建一个名为Repository的新文件夹来保存与存储库相关的组件。然后,创建一个名为RepositoryDe​​tails.jsx的新文件并向其中添加以下代码。

import {useEffect, useState} from "react";
import {Avatar, Card, Divider, List, Spin, Timeline, Typography} from "antd";
import {GetMoreInformationFromURL} from "../../../wailsjs/go/main/App";

const UserGrid = ({users}) => (<List
    grid={{gutter: 16, column: 4}}
    dataSource={users}
    renderItem={(item, index) => (<List.Item key={index} style={{marginTop: "5px"}}>
        <Card.Meta
            avatar={<Avatar src={item.avatar_url}/>}
            title={item.login}
        />
    </List.Item>)}
/>);

const RepositoryDetails = ({repository, token = ""}) => {
    const [commits, setCommits] = useState([]);
    const [contributors, setContributors] = useState([]);
    const [stargazers, setStargazers] = useState([]);
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {
        const getRepositoryDetails = async () => {
            setIsLoading(true);
            const stargazers = await GetMoreInformationFromURL(repository.stargazers_url, token);
            const commits = await GetMoreInformationFromURL(repository.commits_url.replace(/{\/[a-z]*}/, ""), token);
            const contributors = await GetMoreInformationFromURL(repository.contributors_url, token);
            setCommits(commits);
            setContributors(contributors);
            setStargazers(stargazers);
            setIsLoading(false);
        };
        getRepositoryDetails();
    }, [repository]);

    return (<Card
        title={repository.name}
        bordered={false}
        style={{
            margin: "1%",
        }}
    >
        {repository.description}
        <Divider/>
        <Spin tip="Loading" spinning={isLoading}>
            <Typography.Title level={5} style={{margin: 10}}>
                Contributors
            </Typography.Title>
            <UserGrid users={contributors}/>
            <Divider/>
            <Typography.Title level={5} style={{marginBottom: 15}}>
                Stargazers
            </Typography.Title>
            <UserGrid users={stargazers}/>
            <Divider/>
            <Typography.Title level={5} style={{marginBottom: 15}}>
                Commits
            </Typography.Title>
            <Timeline mode="alternate">
                {
                    commits.map((commit, index) => (
                        <Timeline.Item key={index}>{commit.commit?.message}</Timeline.Item>)
                    )
                }
            </Timeline>
        </Spin>
    </Card>);
};

export default RepositoryDetails;

接下来,创建用于渲染公共存储库的组件。在Components/Repository文件夹中,创建一个名为PublicRepositories.jsx的新文件,并向其中添加以下代码。

import {useEffect, useState} from "react";
import {GetPublicRepositories} from "../../../wailsjs/go/main/App";
import RepositoryDetails from "./RepositoryDetails";
import MasterDetail from "../MasterDetail";
import {message} from "antd";

const PublicRepositories = () => {
    const [repositories, setRepositories] = useState([]);
    const [messageApi, contextHolder] = message.useMessage();

    useEffect(() => {
        const getRepositories = async () => {
            GetPublicRepositories()
                .then((repositories) => {
                    setRepositories(repositories);
                })
                .catch((error) => {
                    messageApi.open({
                        type: "error", content: error,
                    });
                });
        };
        getRepositories();
    }, []);

    const title = "Public Repositories";
    const getItemDescription = (repository) => repository.name;
    const detailLayout = (repository) => (<RepositoryDetails repository={repository}/>);

    return (<>
            {contextHolder}
            <MasterDetail
                title={title}
                items={repositories}
                getItemDescription={getItemDescription}
                detailLayout={detailLayout}
            />
        </>);
};

export default PublicRepositories;

该组件进行调用以从 GitHub API 检索公共存储库。它使用app.goGetPublicRepositories()中声明的函数来执行此操作,该函数由 Wails 自动绑定到前端。

以这种方式导出的函数是异步的并返回Promise。使用MasterDetail和RepositoryDetails组件,将相应地呈现返回的响应。

接下来,在Repository文件夹中创建另一个名为PrivateRepositories.jsx的文件,并向其中添加以下代码。

import { useEffect, useState } from "react";
import { useAuthContext } from "../context/AuthContext";
import { GetRepositoriesForAuthenticatedUser } from "../../../wailsjs/go/main/App";
import RepositoryDetails from "./RepositoryDetails";
import MasterDetail from "../MasterDetail";
import { message } from "antd";

const PrivateRepositories = () => {
  const { token } = useAuthContext();
  const [repositories, setRepositories] = useState([]);
  const [messageApi, contextHolder] = message.useMessage();

  useEffect(() => {
    const getRepositories = async () => {
      if (token) {
      GetRepositoriesForAuthenticatedUser(token)
        .then((repositories) => {
          setRepositories(repositories);
        })
        .catch((error) => {
          messageApi.open({
            type: "error",
            content: error,
          });
        });
      }
    };
    getRepositories();
  }, [token]);

  const title = "Private Repositories";
  const getItemDescription = (repository) => repository.name;
  const detailLayout = (repository) => (
    <RepositoryDetails repository={repository} token={token}/>
  );

  return (
    <>
      {contextHolder}
      <MasterDetail
        title={title}
        items={repositories}
        getItemDescription={getItemDescription}
        detailLayout={detailLayout}
      />
    </>
  );
};

export default PrivateRepositories;

该组件与 组件非常相似PublicRepositories,但有两个关键点。首先,该组件将用 包装AuthContextProvider,这使得可以通过useAuthContext钩子检索保存的令牌。其次,它使用另一个绑定函数GetRepositoriesForAuthenticatedUser()来获取提供令牌的用户的存储库。

添加Gist相关组件
接下来,在组件文件夹中,创建一个名为Gist的新文件夹来保存与 Gist 相关的组件。然后,在该新文件夹中创建一个名为GistDetails.jsx的新文件并向其中添加以下代码。

import { Carousel, Col, Row, Spin, Typography } from "antd";
import React, { useEffect, useState } from "react";
import "prismjs/themes/prism-okaidia.min.css";
import Prism from "prismjs";
import { GetGistContent } from "../../../wailsjs/go/main/App";

const GistDetails = ({ gist }) => {
  const [snippets, setSnippets] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    Prism.highlightAll();
  }, [snippets]);

  useEffect(() => {
    const getSnippets = async () => {
      setIsLoading(true);
      const snippets = await Promise.all(
        Object.values(gist.files).map(async (file) => {
          const fileContent = await GetGistContent(file.raw_url, "");
          return {
            language: file.language?.toLowerCase() || "text",
            content: fileContent,
          };
        })
      );
      setSnippets(snippets);
      setIsLoading(false);
    };
    getSnippets();
  }, [gist]);

  return (
    <Spin tip="Loading" spinning={isLoading}>
      <Row justify="center">
        <Col>
          {gist.description && (
            <Typography.Text strong>{gist.description}</Typography.Text>
          )}
        </Col>
      </Row>
      <div>
        <Carousel
          autoplay
          style={{ backgroundColor: "#272822", height: "100%" }}
        >
          {snippets.map((snippet, index) => (
            <pre key={index}>
              <code className={`language-${snippet.language}"`}>
                {snippet.content}
              </code>
            </pre>
          ))}
        </Carousel>
      </div>
    </Spin>
  );
};

export default GistDetails;

该组件呈现文件中给定要点的代码。每个 Gist 响应都带有一个files密钥。这是一个包含 Gist 所有文件的对象。每个文件对象都包含文件原始内容的 URL 以及与文件关联的语言。该组件使用该函数检索所有文件GetGistContent()并将它们呈现在轮播中。Prism用于呈现 IDE 中的代码。

接下来,在 Gist 文件夹中,创建一个名为PublicGists.jsx的文件并向其中添加以下代码。

import { useEffect, useState } from "react";
import GistDetails from "./GistDetails";
import { GetPublicGists } from "../../../wailsjs/go/main/App";
import MasterDetail from "../MasterDetail";
import { message } from "antd";

const PublicGists = () => {
  const [gists, setGists] = useState([]);
  const [messageApi, contextHolder] = message.useMessage();

  useEffect(() => {
    const getGists = async () => {
      GetPublicGists()
        .then((gists) => {
          setGists(gists);
        })
        .catch((error) => {
          messageApi.open({
            type: "error",
            content: error,
          });
        });
    };
    getGists();
  }, []);

  const title = "Public Gists";
  const getItemDescription = (gist) =>
    gist.description || "No description provided";
  const detailLayout = (gist) => <GistDetails gist={gist} />;

  return (
    <>
      {contextHolder}
      <MasterDetail
        title={title}
        items={gists}
        getItemDescription={getItemDescription}
        detailLayout={detailLayout}
      />
    </>
  );
};

export default PublicGists;

正如公共存储库的渲染一样,app.goGetPublicGists()中声明的函数用于从 Github API 检索公共 Gist 并将其传递给组件,以及获取 Gist 描述和显示有关该 Gist 的更多信息的函数。选择时要点。MasterDetail

接下来,在Gist文件夹中创建一个名为PrivateGists.jsx的新文件,并向其中添加以下代码。

import { useEffect, useState } from "react";
import { useAuthContext } from "../context/AuthContext";
import { GetGistsForAuthenticatedUser } from "../../../wailsjs/go/main/App";
import MasterDetail from "../MasterDetail";
import GistDetails from "./GistDetails";
import { message } from "antd";

const PrivateGists = () => {
  const [gists, setGists] = useState([]);
  const { token } = useAuthContext();
  const [messageApi, contextHolder] = message.useMessage();

  useEffect(() => {
    const getGists = async () => {
      if (token) {
        GetGistsForAuthenticatedUser(token)
          .then((gists) => {
            setGists(gists);
          })
          .catch((error) => {
            messageApi.open({
              type: "error",
              content: error,
            });
          });
      }
    };
    getGists();
  }, [token]);

  const title = "Private Gists";
  const getItemDescription = (gist) =>
    gist.description || "No description provided";
  const detailLayout = (gist) => <GistDetails gist={gist} />;

  return (
    <>
      {contextHolder}
      <MasterDetail
        title={title}
        items={gists}
        getItemDescription={getItemDescription}
        detailLayout={detailLayout}
      />
    </>
  );
};
export default PrivateGists;

该组件将用一个AuthContextProvider组件包装,从而使其能够访问所提供的令牌。使用令牌,通过函数对 GitHub API 进行异步调用GetGistsForAuthenticatedUser()。然后将结果MasterDetail与其他所需的 props 一起传递给组件以进行适当的渲染。

最后要构建的 Gist 相关组件是创建新 Gist 的表单。为此,请在Gist文件夹中创建一个名为CreateGist.jsx的新文件,并向其中添加以下代码。

import { useAuthContext } from "../context/AuthContext";
import { Button, Card, Divider, Form, Input, message, Switch } from "antd";
import { DeleteTwoTone, PlusOutlined } from "@ant-design/icons";
import { CreateNewGist } from "../../../wailsjs/go/main/App";
import { useNavigate } from "react-router-dom";

const CreateGist = () => {
  const { token } = useAuthContext();
  const [messageApi, contextHolder] = message.useMessage();
  const navigate = useNavigate();

  const onFinish = async (values) => {
    const { description, files, isPublic } = values;

    const gist = {
      description,
      public: !!isPublic,
      files: files.reduce(
        (accumulator, { filename, content }) =>
          Object.assign(accumulator, {
            [filename]: { content },
          }),
        {}
      ),
    };

    CreateNewGist(gist, token)
      .then((gist) => {
        messageApi.open({
          type: "success",
          content: `Gist ${gist.id} created successfully`,
        });
        navigate("/gists/private");
      })
      .catch((error) => {
        messageApi.open({
          type: "error",
          content: error,
        });
      });
  };

  const onFinishFailed = (errorInfo) => {
    console.log("Failed:", errorInfo);
  };

  return (
    <>
      {contextHolder}
      <Card title="Create a new Gist">
        <Form
          name="gist"
          onFinish={onFinish}
          onFinishFailed={onFinishFailed}
          autoComplete="off"
        >
          <Form.Item name="description">
            <Input placeholder="Gist description..." />
          </Form.Item>
          <Form.Item
            label="Make gist public"
            valuePropName="checked"
            name="isPublic"
          >
            <Switch />
          </Form.Item>
          <Form.List
            name="files"
            rules={[
              {
                validator: async (_, files) => {
                  if (!files || files.length < 1) {
                    return Promise.reject(
                      new Error("At least 1 file is required to create a Gist")
                    );
                  }
                },
              },
            ]}
          >
            {(fields, { add, remove }, { errors }) => (
              <>
                {fields.map((field) => (
                  <div key={field.key}>
                    <Form.Item
                      shouldUpdate={(prevValues, curValues) =>
                        prevValues.area !== curValues.area ||
                        prevValues.sights !== curValues.sights
                      }
                    >
                      {() => (
                        <div>
                          <Divider />
                          <Form.Item
                            {...field}
                            name={[field.name, "filename"]}
                            rules={[
                              {
                                required: true,
                                message: "Missing filename",
                              },
                            ]}
                            noStyle
                          >
                            <Input
                              placeholder="Filename including extension..."
                              style={{ width: "90%", marginRight: "5px" }}
                            />
                          </Form.Item>

                          <DeleteTwoTone
                            style={{
                              fontSize: "30px",
                              verticalAlign: "middle",
                            }}
                            twoToneColor="#eb2f96"
                            onClick={() => remove(field.name)}
                          />
                        </div>
                      )}
                    </Form.Item>
                    <Form.Item
                      {...field}
                      name={[field.name, "content"]}
                      rules={[
                        {
                          required: true,
                          message: "Missing content",
                        },
                      ]}
                    >
                      <Input.TextArea rows={20} placeholder="Gist content" />
                    </Form.Item>
                  </div>
                ))}
                <Form.Item
                  wrapperCol={{
                    offset: 10,
                  }}
                >
                  <Button
                    type="dashed"
                    onClick={() => add()}
                    icon={<PlusOutlined />}
                  >
                    Add file
                  </Button>
                  <Form.ErrorList errors={errors} />
                </Form.Item>
              </>
            )}
          </Form.List>
          <Form.Item
            wrapperCol={{
              offset: 10,
            }}
          >
            <Button type="primary" htmlType="submit">
              Submit
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </>
  );
};

export default CreateGist;

创建新 Gist 的请求包含三个字段:

description:如果提供的话,这将描述要点中的代码旨在实现的目标。该字段是可选的,并在表单中由输入字段表示
public:这是必填字段,决定 Gist 是否具有公共访问权限。在您创建的表单中,这由默认设置为关闭的开关表示。这意味着除非用户另有指定,否则创建的要点将是秘密的,并且仅对拥有其链接的用户可用。
files:这是另一个必填字段。它是一个对象,对于对象中的每个条目,键是文件的名称(包括扩展名),值是文件的内容。
这以您创建的动态列表的形式表示,其中每个列表项都包含文件名的文本字段和文件内容的文本区域。通过单击“添加文件”按钮,您可以添加多个文件。您还可以删除文件。请注意,您将需要至少有一个文件,如果没有,将显示一条错误消息。
当表单正确填写并提交后,该onFinish()函数用于创建一个符合app.goGist中声明的结构的对象,并调用接收器函数。CreateNewGist()

因为该组件是用 包装的AuthContextProvider,所以可以根据函数的需要检索保存的令牌并与 Gist 一起传递。收到成功响应后,应用程序将重定向到经过身份验证的用户的要点列表。

将各个部分放在一起
添加导航
所有单独的组件就位后,接下来要添加的是导航 - 用户可以在应用程序中移动的一种方式。要添加此内容,请在组件文件夹中创建一个名为NavBar.jsx的新文件,并向其中添加以下代码。

import { LockOutlined, UnlockOutlined } from "@ant-design/icons";
import { Layout, Menu } from "antd";
import { Link } from "react-router-dom";
import logo from "../assets/images/logo-universal.png";

function getItem(label, key, icon, children, type) {
  return {
    key,
    icon,
    children,
    label,
    type,
  };
}
const items = [
  getItem("Public Actions", "sub1", <UnlockOutlined />, [
    getItem(
      "Repositories",
      "g1",
      null,
      [
        getItem(
          <Link to={"repositories/public"}>View all repositories</Link>,
          "1"
        ),
      ],
      "group"
    ),
    getItem(
      "Gists",
      "g2",
      null,
      [getItem(<Link to={"gists/public"}>View all gists</Link>, "3")],
      "group"
    ),
  ]),
  getItem("Private Actions", "sub2", <LockOutlined />, [
    getItem(
      "Repositories",
      "g3",
      null,
      [
        getItem(
          <Link to={"repositories/private"}>View my repositories</Link>,
          "5"
        ),
      ],
      "group"
    ),
    getItem(
      "Gists",
      "g4",
      null,
      [
        getItem(<Link to={"gists/private"}>View my gists</Link>, "6"),
        getItem(<Link to={"gist/new"}>Create new gist</Link>, "7"),
      ],
      "group"
    ),
  ]),
];

const NavBar = () => {
  return (
    <Layout.Header theme="light" style={{ background: "white" }}>
      <div
        className="logo"
        style={{
          float: "left",
          marginRight: "200px",
          padding: "1%",
        }}
      >
        <Link to="/">
          <img src={logo} style={{ width: "50px" }} />
        </Link>
      </div>
      <Menu
        defaultSelectedKeys={["1"]}
        mode="horizontal"
        items={items}
        style={{
          position: "relative",
        }}
      />
    </Layout.Header>
  );
};

export default NavBar;

该组件在窗口顶部呈现一个导航栏,其中包含两个主要项目 - Public Actions和Private Actions。然后,每个项目都有子项目,这些子项目是最终将呈现与子项目关联的组件的链接。完成此操作后,您可以将路由添加到您的应用程序中。

添加路由
在frontend/src文件夹中,创建一个名为routes.jsx的新文件,并向其中添加以下代码。

import App from "./App";

import CreateGist from "./components/Gist/CreateGist";
import PrivateGists from "./components/Gist/PrivateGists";
import PublicGists from "./components/Gist/PublicGists";

import PrivateRepositories from "./components/Repository/PrivateRepositories";
import PublicRepositories from "./components/Repository/PublicRepositories";
import AuthContextProvider from "./components/context/AuthContext";

const routes = [
  {
    path: "/",
    element: <App />,
    children: [
      { index: true, element: <PublicRepositories /> },
      {
        path: "repositories/public",
        element: <PublicRepositories />,
      },
      {
        path: "gists/public",
        element: <PublicGists />,
      },
      {
        path: "gist/new",
        element: (
          <AuthContextProvider>
            <CreateGist />
          </AuthContextProvider>
        ),
      },
      {
        path: "repositories/private",
        element: (
          <AuthContextProvider>
            <PrivateRepositories />
          </AuthContextProvider>
        ),
      },
      {
        path: "gists/private",
        element: (
          <AuthContextProvider>
            <PrivateGists />
          </AuthContextProvider>
        ),
      },
    ],
  },
];

export default routes;

在这里,您指定了应用程序中的路由以及要为每个路径呈现的组件。除此之外,您还包装了需要用户为组件提供令牌的组件AuthContextProvider。

接下来,打开App.jsx并更新文件的代码以匹配以下内容。

import NavBar from "./components/NavBar";
import { FloatButton, Layout } from "antd";
import { Outlet } from "react-router-dom";

const { Content } = Layout;

const App = () => {
  return (
    <Layout
      style={{
        minHeight: "100vh",
      }}
    >
      <NavBar />
      <Layout className="site-layout">
        <Content
          style={{
            background: "white",
            padding: "0 50px",
          }}
        >
          <div
            style={{
              padding: 24,
            }}
          >
            <Outlet />
            <FloatButton.BackTop />
          </div>
        </Content>
      </Layout>
    </Layout>
  );
};

export default App;

在这里,您已经包含了NavBar之前声明的组件。您还声明了一个Outlet由 提供的组件react-router-dom来渲染子路由元素。

最后更新main.jsx中的代码以匹配以下内容。

import React from 'react'
import {createRoot} from 'react-dom/client'
import { createHashRouter, RouterProvider } from 'react-router-dom'
import routes from './routes'

const container = document.getElementById('root')

const root = createRoot(container)

const router = createHashRouter(routes, {basename:'/'})

root.render(
    <React.StrictMode>
        <RouterProvider router={router}/>
    </React.StrictMode>
)

HashRouter是官方推荐的路由方法。这是通过createHashRouter()函数创建的。使用routes您之前声明的对象,所有路由器对象都会传递到此组件以呈现您的应用程序并启用其余 API。完成此操作后,您的应用程序将在加载后呈现索引页面。

测试应用程序是否有效
您已经使用Wails成功构建了您的第一个应用程序。再次运行应用程序,并通过从项目的顶级文件夹运行以下命令来试用它。

wails dev

默认情况下,当应用程序加载时,您将看到一个公共存储库列表。使用导航菜单,您可以通过单击相应的菜单项来查看公共(和私有)存储库和要点。

当您选择私有存储库或私有 Gist 的菜单项时,将显示一个弹出窗口,询问您的 GitHub 令牌,如下所示。

在这里插入图片描述
粘贴您的个人访问令牌 (PAT) 并单击“保存”。然后将呈现您的存储库(或 Gists,视情况而定)。您将能够在应用程序的私人部分中导航,而无需在几分钟内重新输入令牌。

这就是如何使用 Go 和 Wails 构建跨平台桌面应用程序

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

北漂燕郊杨哥

您的支持是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值