深度定制Okta登录组件:从前端代码到品牌重塑实战

本文详细介绍了如何利用Okta Identity Engine和Sign-In Widget Gen3技术,通过HTML、CSS、JavaScript代码深度定制品牌登录页面,包括设计令牌配置、DOM元素操作、CSS变量管理和布局重构等高级技术实现。

伸展您的想象力,打造愉悦的登录体验

当您选择Okta作为IAM(身份与访问管理)提供商时,您可以获得的一项重要功能是自定义Okta托管的登录组件,这是我们为达到最高级别身份安全性所推荐的方法。它是一个可定制的JavaScript组件,提供了一个现成的登录界面,您可以立即将其用作Web应用程序的一部分。

Okta Identity Engine 利用身份验证策略来驱动身份验证挑战,登录组件支持各种身份验证因素,从基本的用户名和密码登录到更高级的场景,例如多因素身份验证、生物识别、通行密钥、社交登录、账户注册、账户恢复等。在底层,它与Okta的API交互,因此您无需自己构建或管理复杂的身份验证逻辑。这一切都为您处理好了!

使用Okta登录组件(尤其是第三代标准版)的一个好处是,借助设计令牌,定制工作可以通过配置完成,您无需编写CSS来设置小部件元素的样式。

使Okta登录组件样式匹配您的品牌

在本教程中,我们将为一个虚构的待办事项应用程序定制登录组件。我们将进行以下更改:

  • 替换字体选择
  • 定义边框、错误和焦点颜色
  • 从登录组件中移除元素,例如水平线,并添加自定义元素
  • 将控件移到站点开头并添加背景面板

不做任何更改时,当您尝试登录Okta账户时,会看到类似这样的界面:

在本教程结束时,您的登录界面将看起来像这样 🎉

我们将使用SIW Gen3以及新的建议,通过设计令牌来自定义表单元素和样式。

目录

  • 使Okta登录组件样式匹配您的品牌
  • 自定义您的Okta托管登录页面
  • 理解Okta托管登录组件的默认代码
  • 自定义Okta登录组件内的UI元素
  • 使用CSS自定义属性组织登录组件定制
  • 使用自定义调色板扩展登录组件主题
  • 向登录组件添加自定义HTML元素
  • 覆盖Okta登录组件元素样式
  • 更改Okta托管登录页面的布局
  • 自定义您的Gen3 Okta托管登录组件

前提条件

要遵循本教程,您需要:

  • 一个启用Identity Engine的Okta账户,例如Integrator免费账户。我们使用的组织中的SIW版本是7.36。
  • 您自己的域名
  • 对HTML、CSS和JavaScript有基本的了解
  • 心中有一个品牌设计理念。请随意发挥您的创造力!

让我们开始吧!

自定义您的Okta托管登录页面

开始之前,您必须将Okta组织配置为使用您的自定义域名。自定义域名支持代码定制,使我们能够自定义的不仅仅是默认的徽标、背景、网站图标和两种颜色。以管理员身份登录,打开Okta管理控制台,导航到自定义 > 品牌,然后选择创建品牌 +

按照自定义域名和电子邮件开发者文档在新品牌上设置自定义域名。如果您愿意,也可以遵循这篇文章

一旦您拥有一个带有自定义域名的有效品牌,请选择该品牌进行配置。

首先,导航到设置,然后选择使用第三代以启用SIW Gen3。保存您的选择。

⚠️ 注意 本文中的代码依赖于使用SIW Gen3。它在SIW Gen2上无法工作。

导航到主题。您会看到一个看起来类似这样的默认品牌页面:

让我们开始使其更符合我们心中的主题。更改主色和辅助色,然后用您偏好的选项替换徽标和网站图标图像。

要更改任一颜色,请单击文本字段并输入每种颜色的十六进制代码。我们选择大胆且多彩的方法,因此将使用 #ea3eda 作为主色,#ffa738 作为辅助色,并为品牌上传徽标和网站图标图像。选择保存

现在通过导航到品牌的登录URL查看您的登录页面。经过您的配置,登录组件看起来比默认视图更有趣了,但我们还可以让事情变得更加令人兴奋。

让我们深入主要任务——自定义注册页面。在主题选项卡上:

  1. 在下拉菜单中选择登录页面
  2. 选择自定义按钮
  3. 页面设计选项卡上,选择代码编辑器切换开关以查看HTML页面

注意:只有配置了自定义域名,您才能启用代码编辑器。

理解Okta托管登录组件的默认代码

如果您熟悉基本的HTML、CSS和JavaScript,登录代码看起来是标准的,尽管在某些方面有点不寻常。有两个主要的代码块我们应该检查:页面<body>标签的顶部和<script>标签中的登录配置。

第一个看起来像这样:

1
<div id="okta-login-container"></div>

第二个看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var config = OktaUtil.getSignInWidgetConfig();

// 渲染Okta登录组件
var oktaSignIn = new OktaSignIn(config);
oktaSignIn.renderEl({ el: '#okta-login-container' },
  OktaUtil.completeLogin,
  function(error) {
    // 记录配置组件时发生的错误。
    // 删除或用您自己的自定义错误处理程序替换此函数。
    console.log(error.message, error);
  }
);

让我们仔细看看这段代码是如何工作的。在HTML中,有一个指定的父元素,OktaSignIn实例使用它来将SIW渲染为子节点。这意味着当页面加载时,您会在DOM中看到 <div id="okta-login-container"></div>,其子元素包含用于SIW功能的HTML元素。SIW处理策略定义的所有身份验证和用户注册过程,使我们能够完全专注于定制。

要创建SIW,我们需要传入配置。配置包括主题元素和标签消息等属性。方法 renderEl() 标识用于渲染SIW的HTML元素。我们传入了 #okta-login-container 作为标识符。

#okta-login-container 是一个CSS选择器。虽然任何正确的CSS选择器都有效,但我们建议您使用元素的ID。元素ID在HTML文档中必须是唯一的,因此这是最安全和最简单的方法。

自定义Okta登录组件内的UI元素

既然我们已经基本了解了Okta登录组件的工作原理,让我们开始自定义代码。我们将从自定义SIW内的元素开始。要在Gen3中操作Okta SIW的DOM元素,我们使用afterTransform方法。afterTransform方法允许我们为单个或所有表单移除或更新元素。

在代码编辑器视图中找到编辑按钮,它使代码编辑器可编辑并像一个轻量级IDE一样工作。

<script>标签内的oktaSignIn.renderEl()方法下方添加:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
oktaSignIn.afterTransform('identify', ({ formBag }) => {
  const title = formBag.uischema.elements.find(ele => ele.type === 'Title');
  if (title) {
    title.options.content = "登录并创建任务";
  }

  const help = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'help');
  const unlock = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'unlock');
  const divider = formBag.uischema.elements.find(ele => ele.type === 'Divider');
  formBag.uischema.elements = formBag.uischema.elements.filter(ele => ![help, unlock, divider].includes(ele));
});

afterTransform钩子仅在"identify"表单之前运行。我们可以使用FormBag查找和定位UI元素。afterTransform钩子是在渲染组件之前操作SIW内DOM元素的更简化方法。例如,我们可以在渲染之前按类型搜索元素并将其从视图中过滤掉,这比在SIW渲染后操作DOM元素性能更高。在此代码片段中,我们过滤掉了"解锁账户"元素和分隔符等元素。

让我们看看效果如何。按保存草稿发布

导航到您品牌的登录URL以查看您所做的更改。与默认状态相比,我们不再看到徽标下方的水平线或"帮助"链接。账户解锁元素也不再可用。

我们已经探索了如何自定义组件元素。现在,让我们添加一些特色。

使用CSS自定义属性组织登录组件定制

从本质上讲,我们是在样式化一个HTML文档。这意味着我们操作SIW定制的方式与操作任何HTML页面相同,代码组织原则仍然适用。我们可以将自定义值定义为CSS自定义属性(也称为CSS变量)。

使用CSS变量定义样式可以使我们的代码保持DRY(不要重复自己)。甚至可以将重用样式值的设置扩展到Okta托管的登录页面之外。如果您的组织公开托管将品牌颜色定义为CSS自定义属性的样式表,您可以使用那里定义的颜色并链接您的样式表。

在进行代码编辑之前,确定您要用于定制的字体。我们找到了要使用的标题和正文字体。

打开品牌的SIW代码编辑器,选择编辑进行更改。

将字体导入HTML。您可以根据偏好使用<link>@import导入字体。我们将<link>指令添加到了HTML的<head>中。

1
2
3
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Poiret+One&display=swap" rel="stylesheet">

找到 <style nonce="{{nonceValue}}"> 标签。在该标签内,使用:root选择器定义您的属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
:root {
    --color-gray: #4f4f4f;
    --color-fuchsia: #ea3eda;
    --color-orange: #ffa738;
    --color-azul: #016fb9;
    --color-cherry: #ea3e84;
    --color-purple: #b13fff;
    --color-black: #191919;
    --color-white: #fefefe;
    --color-bright-white: #fff;
    --border-radius: 4px;
    --font-header: 'Poiret One', sans-serif;
    --font-body: 'Inter Tight', sans-serif;
 }

请随意添加新属性或替换品牌所需的属性值。现在是添加您自己的品牌颜色和定制的好机会!

让我们使用设计令牌和我们的变量来配置SIW。

找到 var config = OktaUtil.getSignInWidgetConfig();。在这行代码之后,使用您的CSS自定义属性设置设计令牌的值。您将使用var()函数来访问您的变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
config.theme = {
  tokens: {
    BorderColorDisplay: 'var(--color-bright-white)',
    PalettePrimaryMain: 'var(--color-fuchsia)',
    PalettePrimaryDark: 'var(--color-purple)',
    PalettePrimaryDarker: 'var(--color-purple)',
    BorderRadiusTight: 'var(--border-radius)',
    BorderRadiusMain: 'var(--border-radius)',
    PalettePrimaryDark: 'var(--color-orange)',
    FocusOutlineColorPrimary: 'var(--color-azul)',
    TypographyFamilyBody: 'var(--font-body)',
    TypographyFamilyHeading: 'var(--font-header)',
    TypographyFamilyButton: 'var(--font-body)',
    BorderColorDangerControl: 'var(--color-cherry)'
  }
}

保存您的更改,发布页面,并查看您品牌的登录URI站点。耶!您看,没有边框轮廓,组件和HTML元素的边框半径改变了,焦点颜色不同了,表单错误时元素轮廓的颜色也不同了。您可以检查HTML元素并查看计算后的样式。或者,如果您愿意,请随意将CSS变量更新为更显眼的值。

当您检查品牌的登录URL站点时,您会注意到字体没有正确加载,并且浏览器的调试控制台中有错误。这是因为您需要配置内容安全策略以允许从外部站点加载资源。CSP是一种安全措施,用于减轻跨站脚本攻击。您可以阅读安全标头最佳实践概述以了解更多关于CSP的信息。

导航到品牌登录页面的设置选项卡。找到内容安全策略并按编辑。添加外部资源的域名。在我们的示例中,我们只从Google Fonts加载资源,因此我们添加了以下两个域:

1
2
*.googleapis.com
*.gstatic.com

保存草稿,然后按发布以查看您的更改。SIW现在显示您选择的字体!

使用自定义调色板扩展SIW主题

在我们的示例中,我们选择性地添加了颜色。SIW设计系统遵循WCAG无障碍标准,并依赖于Material Design调色板。

Okta会根据您的主色生成符合无障碍标准和对比度要求的颜色。查看理解登录组件颜色定制以了解更多关于颜色对比度以及Okta颜色生成的工作原理。您必须向配置提供无障碍颜色。

Material Design通过自定义调色板来支持主题。所有可配置设计令牌的列表显示了所有可用选项,包括用于精确颜色控制的Hue*属性。考虑探索适合您品牌特定需求的调色板定制选项。您可以使用Material调色板生成器,例如谷歌团队的此颜色选择器或允许您输入HEX颜色值的开源Material Design调色板生成器

别忘了注意无障碍性。您可以使用Chrome浏览器中的Lighthouse和WebAIM对比度检查器运行无障碍审核。我们选择的主色不太符合对比度要求。😅

向登录组件添加自定义HTML元素

之前,我们过滤掉了SIW中的HTML元素。我们还可以向SIW添加新的自定义HTML元素。我们将通过添加一个指向Okta开发者博客的链接来实验。找到afterTransform()方法。更新afterTransform()方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
oktaSignIn.afterTransform('identify', ({formBag}) => {
  const title = formBag.uischema.elements.find(ele => ele.type === 'Title');
  if (title) {
    title.options.content = "登录并创建任务";
  }

  const help = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'help');
  const unlock = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'unlock');
  const divider = formBag.uischema.elements.find(ele => ele.type === 'Divider');
  formBag.uischema.elements = formBag.uischema.elements.filter(ele => ![help, unlock, divider].includes(ele));

  const blogLink = {
    type: 'Link',
    contentType: 'footer', 
    options: {
      href: 'https://developer.okta.com/blog',
      label: '阅读我们的博客',
      dataSe: 'blogCustomLink'
    }
  };
  formBag.uischema.elements.push(blogLink);
});

我们创建了一个名为blogLink的新元素,并设置了诸如类型、内容所在位置以及与类型相关的选项等属性。我们还添加了一个dataSe属性,该属性将一个值blogCustomLink添加到HTML的data属性中。这样做可以让我们更容易地为定制或测试目的选择该元素。

当您在登录流程中继续通过"identify"表单后,您将不再看到指向博客的链接。

覆盖Okta登录组件元素样式

在任何可能的情况下,我们都应该使用设计令牌进行定制。在您的样式需求没有可用设计令牌的情况下,您可以回退到手动定义样式。

让我们从添加的元素——博客链接开始。假设我们希望将文本显示为大写。出于无障碍考虑,使用大写定义标签值不是一个好做法。我们应该使用CSS来转换文本。

在样式定义中,找到#login-bg-image-id。在背景图像的样式之后,添加样式以定位blogCustomLink data属性并定义文本转换,如下所示:

1
2
3
a[data-se="blogCustomLink"] {
    text-transform: uppercase;
}

保存并发布页面以查看您的更改。

现在,假设您想要样式化一个Okta提供的HTML元素。请尽可能使用设计令牌,并谨慎进行样式更改,因为这样做会增加脆弱性和安全问题。

以下是一个不应该模仿的样式化Okta提供的HTML元素的糟糕示例,因为它会使文本难以辨认。假设您希望将"下一步"按钮的背景更改为渐变。🌈

检查您想要样式化的SIW元素。我们希望样式化具有data属性okta-sign-in-header的按钮。

blogCustomLink样式之后,添加以下内容:

1
2
3
button[data-se="save"] {
    background: linear-gradient(12deg, var(--color-fuchsia) 0%, var(--color-orange) 100%);
}

保存并发布站点。现在按钮背景变成了渐变。

然而,请谨慎样式化Okta提供的SIW元素。这种方法存在双重危险:

  1. Okta登录组件经过无障碍审核,手动更改样式和行为可能会降低无障碍阈值
  2. Okta登录组件已国际化,手动更改围绕文本布局的样式可能会破坏本地化需求
  3. Okta无法保证data属性或DOM元素保持不变,这可能导致定制中断

在极少数情况下,如果您样式化了Okta提供的SIW元素,您可能需要固定SIW版本,以防止您的定制因底层变更而失效。导航到设置选项卡,找到登录组件版本部分。选择编辑,并选择最新的组件版本,因为它应该与您的代码兼容。在本文中,我们使用的是组件版本7.36。

⚠️ 注意 当您固定组件时,如果不手动更新版本,您将无法获得SIW的最新和最棒的更新。固定版本会阻止最终用户体验的演进和扩展。为了获得最安全的选项,请允许SIW自动更新,并避免使用CSS过度自定义SIW。尽可能使用设计令牌。

更改Okta托管登录页面的布局

到目前为止,我们尚未编辑SIW定制中定义的HTML节点。您可以更改默认的<div>容器的布局以产生重大影响。更改display CSS属性可以产生显著效果,例如使用Flexbox或CSS Grid。在此示例中,我将使用Flexbox。

找到背景图像容器和okta-login-container的div。用以下HTML片段替换这些div元素:

1
2
3
4
5
<div id="login-bg-image-id" class="login-bg-image tb--background">
    <div class="login-container-panel">
        <div id="okta-login-container"></div>
    </div>
</div>

我们将okta-login-container div移到了另一个父容器内,并使其成为背景图像容器的子元素。

找到#login-bg-image样式。添加display: flex;属性。样式应如下所示:

1
2
3
4
 #login-bg-image-id {
     background-image: {{bgImageUrl}};
     display: flex;
}

我们希望样式化okta-login-container的父<div>以设置背景颜色并将SIW在面板上居中。为login-container-panel类添加新样式:

1
2
3
4
5
6
7
8
.login-container-panel {
    background: var(--color-white);
    display: flex;
    justify-content: center;
    align-items: center;
    width: 40%;
    min-width: 400px;
}

保存您的更改并查看登录页面。您觉得新布局怎么样?🎊

⚠️ 注意 Flexbox和CSS Grid是响应式的,但您可能仍需要添加处理响应性或媒体查询的属性以满足您的需求。

您的最终代码可能看起来像这样:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="robots" content="noindex,nofollow" />
    <!-- Styles generated from theme -->
    <link href="{{themedStylesUrl}}" rel="stylesheet" type="text/css">
    <!-- Favicon from theme -->
    <link rel="shortcut icon" href="{{faviconUrl}}" type="image/x-icon">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Poiret+One&display=swap" rel="stylesheet">    

    <title>{{pageTitle}}</title>
    {{{SignInWidgetResources}}}

    <style nonce="{{nonceValue}}">
        :root {
            --font-header: 'Poiret One', sans-serif;
            --font-body: 'Inter Tight', sans-serif;
            --color-gray: #4f4f4f;
            --color-fuchsia: #ea3eda;
            --color-orange: #ffa738;
            --color-azul: #016fb9;
            --color-cherry: #ea3e84;
            --color-purple: #b13fff;
            --color-black: #191919;
            --color-white: #fefefe;
            --color-bright-white: #fff;
            --border-radius: 4px;
        }

        {{ #useSiwGen3 }}

        html {
            font-size: 87.5%;
        }

        {{ /useSiwGen3 }}

        #login-bg-image-id {
            background-image: {{bgImageUrl}};
            display: flex;
        }
   
       .login-container-panel {
            background: var(--color-white);
            display: flex;
            justify-content: center;
            align-items: center;
            width: 40%;
            min-width: 400px;
        }

        a[data-se="blogCustomLink"] {
            text-transform: uppercase;
        }
    </style>
</head>

<body>
   <div id="login-bg-image-id" class="login-bg-image tb--background">
        <div class="login-container-panel">
            <div id="okta-login-container"></div>
        </div>
    </div>



    <!--
   "OktaUtil" defines a global OktaUtil object
   that contains methods used to complete the Okta login flow.
-->
    {{{OktaUtil}}}


    <script type="text/javascript" nonce="{{nonceValue}}">
        // "config" object contains default widget configuration
        // with any custom overrides defined in your admin settings.

        const config = OktaUtil.getSignInWidgetConfig();
        config.theme = {
            tokens: {
                BorderColorDisplay: 'var(--color-bright-white)',
                PalettePrimaryMain: 'var(--color-fuchsia)',
                PalettePrimaryDark: 'var(--color-purple)',
                PalettePrimaryDarker: 'var(--color-purple)',
                BorderRadiusTight: 'var(--border-radius)',
                BorderRadiusMain: 'var(--border-radius)',
                PalettePrimaryDark: 'var(--color-orange)',
                FocusOutlineColorPrimary: 'var(--color-azul)',
                TypographyFamilyBody: 'var(--font-body)',
                TypographyFamilyHeading: 'var(--font-header)',
                TypographyFamilyButton: 'var(--font-body)',
                BorderColorDangerControl: 'var(--color-cherry)'
            }
        }

        // Render the Okta Sign-In Widget
        const oktaSignIn = new OktaSignIn(config);
        oktaSignIn.renderEl({ el: '#okta-login-container' },
            OktaUtil.completeLogin,
            function (error) {
                // Logs errors that occur when configuring the widget.
                // Remove or replace this with your own custom error handler.
                console.log(error.message, error);
            }
        );

        oktaSignIn.afterTransform('identify', ({ formBag }) => {
            const title = formBag.uischema.elements.find(ele => ele.type === 'Title');
            if (title) {
                title.options.content = "登录并创建任务";
            }

            const help = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'help');
            const unlock = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'unlock');
            const divider = formBag.uischema.elements.find(ele => ele.type === 'Divider');
            formBag.uischema.elements = formBag.uischema.elements.filter(ele => ![help, unlock, divider].includes(ele));

            const blogLink = {
                type: 'Link',
                contentType: 'footer',
                options: {
                    href: 'https://developer.okta.com/blog',
                    label: '阅读我们的博客',
                    dataSe: 'blogCustomLink'
                }
            };
            formBag.uischema.elements.push(blogLink);
        });

    </script>


</body>

</html>

您也可以在此博客文章的GitHub仓库中找到代码。通过这些代码更改,您可以将其与应用程序连接以查看端到端的工作情况。您需要更新您的Okta OpenID Connect 应用程序以与域名配合使用。在Okta管理控制台中,导航到应用程序 > 应用程序,找到您自定义应用程序的Okta应用程序。导航到登录选项卡。您会看到一个OpenID Connect ID令牌部分。选择编辑,并选择您品牌的登录URL作为发行者值的自定义URL

您将使用与您品牌的自定义URL匹配的发行者值以及Okta应用程序的客户端ID,用于自定义应用程序的OIDC配置。如果您想尝试但还没有预先构建的应用程序,可以使用我们的一个示例,例如Okta React示例

自定义您的Gen3 Okta托管登录组件

我希望您喜欢为您的品牌定制登录体验。使用Okta托管的登录组件是为您的站点添加身份安全性的最佳、最安全的方式。借助所有可用的配置选项,您可以拥有一个高度自定义的登录体验,使用自定义域名,而无需任何人知道您在使用Okta。

如果您喜欢这篇文章,您很可能会发现以下链接有帮助:

请记住在Twitter上关注我们,并订阅我们的YouTube频道以获取有趣且富有教育意义的内容。我们也希望听到您对想要了解的主题和可能有的问题。在下方给我们留言!下次见!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计