温故知新,Blazor遇见大写人民币翻译机(ChineseYuanParser),践行WebAssembly SPA的实践之路
背景
在之前《温故知新,.Net Core遇见Blazor(FluentUI),属于未来的SPA框架》中我们已经初步了解了Blazor的相关概念,并且根据官方的指引完成了《创建我的第一个Blazor应用》、《生成Blazor待办事项列表应用》、《结合ASP.NET Core SignalR和Blazor实现聊天室应用》三个基础应用的实践探索,接下来我们继续探索如果通过Blazor的相关技术来完成一个独立的SPA应用。
什么是大写人民币翻译机(ChineseYuanParser)
大写人民币翻译机(ChineseYuanParser
),是一款结合Blazor
和WebAssembly
技术联合打造并且运行在.Net 5.0
运行时的数字金额转大写人民币金额的应用,适用于差旅报销时填写报销单需要将阿拉伯数字报销金额翻译成大写人民币金额的场景。
借鉴和引用
该项目为一个演示项目,旨在实践和练习Blazor技术,其原版来自阿迪的RMBCapitalization-Blazor和技术文章《Blazor WASM 实现人民币大写转换器》
运行效果
基于Azure静态网站应用服务(Azure Static Web Apps
) 免费预览实现的一个临时发布:https://rmbcc.ledesign.org, 随时可能因为订阅原因失效。
如果想了解Azure静态网站应用服务(Azure Static Web Apps
),可以查看另外一个文章:尝鲜一试,Azure静态网站应用服务(Azure Static Web Apps) 免费预览,协同Github自动发布静态SPA
开始创作
接下来,我将一步步拆解实现该应用的细节,这个过程中,我们也可以收获很多关于Blazor
、WebAssembly
、.Net 6.0
、Javascript
、Bootstrap
的知识点。
前置清单
创建名为”ChineseYuanParser”的Blazor WebAssembly应用
dotnet new blazorwasm -o ChineseYuanParser --no-https
或者
dotnet new blazorwasm -o ChineseYuanParser --no-https --pwa
选择好你要存档项目的根目录,然后在这个目录中右键打开Windows Terminal
进入,通过DotNet-Cli
命令new
来创建一个模板类型为blazorwasm
、输出名为ChineseYuanParser
的应用,中文译为大写人民币翻译机
,并且我们标记不需要强制https
,还是添加--pwa
来创建符合PWA规则的模板。
创建成功后,在终端中,可直接走命令打开Visual Studio Code加载当前项目
cd ChineseYuanParser
code .
接下来,我们找到wwwroot\index.html
,修改主页面的Title
,为中文的大写人民币翻译机
。
我们走通过DotNet-Cli
命令run
看下初始的效果,这里加一个watch
参数,可以做到修改后自动热重载。
dotnet watch run
适当剪裁,搭建应用的基本面板
根据模板直接创建的项目会自带一些页面,这对我们要做的实际应用来说,其实是多余的,我们先来一波减法。
1. 删掉Pages
目录中除Index
以外的页面,我们只需要保留一个Index.razor
即可。
2. 删掉Index.razor
中除路由路径以外的信息,其他的都需要我们重新来过。
3. 在Index.razor
的同级目录新建一个空白的Index.razor.css
样式文件,根据命名规则,它会作用于Index
页面,这为后续给这个页面增加定制化的样式提供一个基础。
4. 删掉Shared
目录中除MainLayout
以外的组件,我们只需要保留一个MainLayout.razor
即可,这里多说一句,默认模板带的左侧导航就是存在于NavMenu.razor
中,而对MainLayout.razor
的引用其实是在App.razor
中。
5. 删除MainLayout.razor
中自带的html
内容,添加我们应用需要的,这里因为内置Bootstrap
的需要,基于Gird
网格原理,需要将网格内容放在一个.container class
内,以便获得对齐和内边距支持,所以这里我们在@Body
的父级放一个带container class
的Div
元素。
Bootstrap提供了一套响应式、移动设备优先的流式网格系统,随着屏幕或视口(viewport)尺寸的增加,系统会自动分为最多12列。Bootstrap包含了一个响应式的、移动设备优先的、不固定的网格系统,可以随着设备或视口大小的增加而适当地扩展到12列。它包含了用于简单的布局选项的预定义类,也包含了用于生成更多语义布局的功能强大的混合类。Bootstrap网格系统(GridSystem)的工作原理:行必须放置在.container class内,以便获得适当的对齐(alignment)和内边距(padding)。
@inherits LayoutComponentBase
<div class="container">
@Body
</div>
6. 删除wwwroot
下的sample-data
演示数据,这是模板创建自带的,已经完全没用了。
7. 移除wwwroot\css\app.css
中的多余自带样式,只保留blazor-error-ui
相关的部分,其余的都可以删掉了,同时添加我们需要的样式效果,这里添加的样式主要是为了打造一个灰色背景,应用区域为白色悬浮的效果。
:root {
--transparent-dark-1: rgba(0,0,0,.108);
--transparent-dark-2: rgba(0,0,0,.125);
--transparent-dark-3: rgba(0,0,0,.132);
--transparent-dark-4: rgba(0,0,0,.175);
--gray-1: #f2f2f2;
--gray-2: #eee;
}
body {
background: #F2F2F2;
}
.box {
background-color: white;
padding: 1.8rem;
box-shadow: 0 1.6px 3.6px 0 var(--transparent-dark-3),0 .3px .9px 0 var(--transparent-dark-1);
border-radius: 3px;
}
8. 在Pages\Index.razor
中,添加我们承载应用内容的主体骨架,它有一个点睛之笔,也就是标题,把我们的应用名字突出来,接着我们把我们基于的技术栈表达出来,这里用到了一个@Environment.Version
用来读取当前.Net Core
的版本号信息,在尾部,我们打上自己的作者链接和名称。
对于
.NETCore 2.x
和.NET5+
,Environment.Version
属性返回.net
运行时版本号。
@page "/"
<div class="main box mt-4">
<h1 class="text-center">
大写人民币翻译机
</h1>
<div class="text-center">
<small>Blazor WASM By .Net @Environment.Version</small>
</div>
<hr />
<div>
</div>
</div>
<div class="mt-3 text-center author">
<a href="https://www.cnblogs.com/taylorshi" target="_blank">Taylor Shi</a>
</div>
9. 查看基础效果,一个应用的基本底子就出来,这就像女孩子化妆一样,我们先要打个好底子。
按需组合,搭建应用的功能面板
1. 构建翻译机左侧顶部翻译结果和按键功能区面板。
在前面的div
块上添加class="row"
,然后基于我们的视觉规划效果,构建左侧顶部翻译结果和按键功能区。
<!-- 左侧功能区 -->
<div class="col-md-8">
<!-- 展示及动作 -->
<section>
<!-- 转换结果展示 -->
<div class="cap-result border bg-light mb-2 p-3">
<h3>
@ParseResult
</h3>
</div>
<!-- 数字金额输入框 -->
<div class="row">
<div class="col-md-8" style="margin-bottom: 10px;">
<input type="text" class="form-control" placeholder="请输入数字金额" @bind-value="DigitalAmount" @bind-value:event="oninput" />
</div>
<div class="col-md-4" style="margin-bottom: 10px;">
<div class="row">
<button class="col-md-3 btn btn-success myButton" @onclick="CopyResult" >复制</button>
<button class="col-md-3 btn btn-primary myButton" @onclick="ReadResult" >朗读</button>
<button class="col-md-3 btn btn-danger myButton" @onclick="ClearResult" >清除</button>
</div>
</div>
</div>
</section>
</div>
.myButton {
margin-left: 10px;
max-height: 38px;
min-width: 79px;
max-width: 147px;
}
在转换结果展示区域,我们直接展示ParseResult
结果,还拥有一个输入框,输入框绑定了变量DigitalAmount
,这里有个技巧是,因为输入框输入内容我们需要及时的做出翻译结果,所以我们需要一个监听输入的方式,这里采用了@bind-value:event="oninput"
来做,同时我们设计了三个按钮在输入框旁边,分别是:复制按钮,绑定事件CopyResult
、朗读按钮,绑定事件ReadResult
、清除按钮,绑定事件ClearResult
。
2. 响应左侧顶部实时翻译和翻译结果展示的实现。
先看最简单的解析结果。
/// <summary>
/// 解析结果
/// </summary>
/// <value></value>
public string ParseResult { get; set; }
接下来,看DigitalAmount
的实现,这是整个应用功能的关键,因为我们希望在它值变更的时候,马上得出翻译结果,那么这里主要是采用它的set方法来实现。
/// <summary>
/// 数字金额
/// </summary>
private string _digitalAmount;
public string DigitalAmount
{
get => _digitalAmount;
set
{
_digitalAmount = value;
// 输入值为空不做处理
if(!string.IsNullOrEmpty(value) && !string.IsNullOrWhiteSpace(value))
{
// 最大值:99999999999.99
if(!Equals(value, ".") && double.Parse(value) > 99999999999.99)
{
return;
}
// 小数点后最多两位
if(value.Contains("."))
{
// 如果只有一个点,特殊处理成0.
if(!Equals(value, "."))
{
// 按.拆分,且只允许存在一个.
var spiltValues = value.Split(\'.\');
if(spiltValues.Length != 2)
{
return;
}
// 小数部分长度不能超过2位
var decimalValue = spiltValues.LastOrDefault();
if(!string.IsNullOrEmpty(decimalValue) && decimalValue.Length > 2)
{
return;
}
}
else
{
value = "0.";
_digitalAmount = value;
}
}
// 将01234 格式化成1234
if(value.StartsWith("0") && !value.Contains("."))
{
var intValue = int.Parse(value);
value = intValue.ToString();
_digitalAmount = value;
}
// 转换
ParseResult = RMBConverter.RMBToCap(_digitalAmount);
}
else
{
ParseResult = string.Empty;
}
}
}
在DigitalAmount
的set实现里面,我们先规避了空值和最大值,然后我们处理了小数位最多支持2
位小数,接下来我们将首位是0
的情况做了处理。
最终,才迎来关键的一个动作,也就是ParseResult = RMBConverter.RMBToCap(_digitalAmount)
代码,说白了,就是我们把拿到的金额丢进这个方法中,最终翻译得到我们要的大写人民币值,这也就是整个翻译机核心功能。
关于RMBConverter.RMBToCap
的实现,这个不用多说,都是借鉴了网上已经很成熟的代码实现,所以就直接贴代码了。
public class RMBConverter
{
public static string RMBToCap(string input)
{
// Constants:
var MAXIMUM_NUMBER = 99999999999.99;
// Predefine the radix characters and currency symbols for output:
var CN_ZERO = "零";
var CN_ONE = "壹";
var CN_TWO = "贰";
var CN_THREE = "叁";
var CN_FOUR = "肆";
var CN_FIVE = "伍";
var CN_SIX = "陆";
var CN_SEVEN = "柒";
var CN_EIGHT = "捌";
var CN_NINE = "玖";
var CN_TEN = "拾";
var CN_HUNDRED = "佰";
var CN_THOUSAND = "仟";
var CN_TEN_THOUSAND = "万";
var CN_HUNDRED_MILLION = "亿";
var CN_SYMBOL = "人民币";
var CN_DOLLAR = "元";
var CN_TEN_CENT = "角";
var CN_CENT = "分";
var CN_INTEGER = "整";
if (double.Parse(input) > MAXIMUM_NUMBER)
{
throw new ArgumentOutOfRangeException(nameof(input), "金额必须小于一百亿元");
}
string integral;
string decimalPart;
var parts = input.Split(\'.\');
if (parts.Length > 1)
{
integral = parts[0];
decimalPart = parts[1];
if (decimalPart == string.Empty)
{
decimalPart = "00";
}
if (decimalPart.Length == 1)
{
decimalPart += "0";
}
// Cut down redundant decimal digits that are after the second.
decimalPart = decimalPart.Substring(0, 2);
}
else
{
integral = parts[0];
decimalPart = string.Empty;
}
// Prepare the characters corresponding to the digits:
var digits = new[] { CN_ZERO, CN_ONE, CN_TWO, CN_THREE, CN_FOUR, CN_FIVE, CN_SIX, CN_SEVEN, CN_EIGHT, CN_NINE };
var radices = new[] { "", CN_TEN, CN_HUNDRED, CN_THOUSAND };
var bigRadices = new[] { "", CN_TEN_THOUSAND, CN_HUNDRED_MILLION };
var decimals = new[] { CN_TEN_CENT, CN_CENT };
string outputCharacters = string.Empty;
if (long.Parse(integral) > 0)
{
var zeroCount = 0;
for (int i = 0; i < integral.Length; i++)
{
var p = integral.Length - i - 1;
var d = integral.Substring(i, 1);
var quotient = p / 4;
var modulus = p % 4;
if (d == "0")
{
zeroCount++;
}
else
{
if (zeroCount > 0)
{
outputCharacters += digits[0];
}
zeroCount = 0;
outputCharacters += digits[int.Parse(d)] + radices[modulus];
}
if (modulus == 0 && zeroCount < 4)
{
outputCharacters += bigRadices[quotient];
zeroCount = 0;
}
}
outputCharacters += CN_DOLLAR;
}
// Process decimal part if there is:
if (decimalPart != string.Empty)
{
for (int i = 0; i < decimalPart.Length; i++)
{
var d = decimalPart.Substring(i, 1);
if (d != "0")
{
outputCharacters += digits[int.Parse(d)] + decimals[i];
}
}
}
// Confirm and return the final output string:
if (outputCharacters == string.Empty)
{
outputCharacters = CN_ZERO + CN_DOLLAR;
}
if (decimalPart == string.Empty)
{
outputCharacters += CN_INTEGER;
}
return outputCharacters;
}
}
3. 响应左侧顶部三个功能按键:复制、朗读、清除。
先说最简单的清除吧,这个很简单,清除就是直接清空DigitalAmount
的值就行了,因为我们在DigitalAmount
的set里面也设计了,如果set为空,那么我们也会把ParseResult
设置为空,所以这样就达到了双清的目的。
/// <summary>
/// 清除结果
/// </summary>
private void ClearResult()
{
DigitalAmount = string.Empty;
}
接下来,复制和朗读功能就比较特殊了,要知道这两个工作,毕竟我们现在是在wasm的里面,也就是应用实际上是跑在浏览器了,所以我们需要借助JS原生的支持来完成。
我们需要在Index.razor
的顶部添加@inject IJSRuntime JavaScriptRuntime
来引入从Blazor对原生Js的调用。
然后我们将朗读和复制的JS写在首页的Index.html
中,这里借助Clipboard.writeText
方法来实现对复制功能的实现,借助speechSynthesis.speak
方法来实现对指定文本的朗读功能。
<script>
window.clipboardCopy = {
copyText: function (text) {
navigator.clipboard.writeText(text).then(function () {
console.log(text);
})
.catch(function (error) {
alert(error);
});
}
};
window.readAloud = {
readText: function (text) {
let utterance = new SpeechSynthesisUtterance(text);
utterance.lang = \'zh-CN\';
speechSynthesis.speak(utterance);
}
}
</script>
完成Index.html
中的JS对应函数支持后,我们回到Index.razor
来通过调用JS来实现对复制和朗读功能的支持,这里用到JavaScriptRuntime.InvokeVoidAsync
的方式来调用JS方法。
/// <summary>
/// 复制结果
/// </summary>
private async Task CopyResult()
{
if (!string.IsNullOrEmpty(ParseResult))
{
await JavaScriptRuntime.InvokeVoidAsync("clipboardCopy.copyText", ParseResult);
}
}
/// <summary>
/// 朗读结果
/// </summary>
private async Task ReadResult()
{
if (!string.IsNullOrEmpty(ParseResult))
{
await JavaScriptRuntime.InvokeVoidAsync("readAloud.readText", ParseResult);
}
}
4. 构建翻译机左侧快捷数字输入面板
先贴代码再解读,这里主要是界面处理的逻辑。
<!-- 快捷键 -->
<section>
<!-- 快捷键1-9 -->
<div class="row">
@for (var i = 1; i <= 9; i++)
{
var num = i;
<div class="col-4">
<button class="btn btn-light border key" @onclick="() => ShortCutInvoked(num.ToString())">@num</button>
</div>
}
</div>
<!-- 快捷键0和. -->
<div class="row">
<div class="col-8">
<button class="btn btn-light border key" @onclick=\'() => ShortCutInvoked("0")\'>0</button>
</div>
<div class="col-4">
<button class="btn btn-light border key" @onclick=\'() => ShortCutInvoked(".")\'>.</button>
</div>
</div>
</section>
针对数字1-9,我们很好处理,构建一个Gird网格结构就行,每行支持3个按钮,这里我们需要用到for
的写法,需要注意的就是var num = i;
推荐这个写法。
我们在所有数字按键中统一绑定@onclick="() => ShortCutInvoked(num.ToString())"
,来支撑按键动作,其实逻辑也很简单,就是把新输入的数值,追加到原来的字符串后面就行了。
/// <summary>
/// 快捷键触发事件
/// </summary>
/// <param name="num"></param>
private void ShortCutInvoked(string num)
{
DigitalAmount += num;
}
.key {
font-family: "Consolas";
font-size: 250%;
width: 100%;
min-height: 90px;
margin-bottom: 10px;
}
针对数字0
和.
按键需要特殊处理,因为这里只有两个元素了,所以采用2:1
的比例来分配Gird的空间。
5. 构建翻译机右侧翻译参考表面板
在翻译机中,为了更加直观的表达每一个数字会被翻译成什么样的大写人民币,这里我们在整个界面的右侧放一个参考表。
这个看起来不难,首先我们需要准备一个字典ReferList
。
/// <summary>
/// 参照列表
/// </summary>
/// <value></value>
public Dictionary<string, string> ReferList { get; set; } = new Dictionary<string, string>
{
{
"零","0"
},
{
"壹","1"
},
{
"贰","2"
},
{
"叁","3"
},
{
"肆","4"
},
{
"伍","5"
},
{
"陆","6"
},
{
"柒","7"
},
{
"捌","8"
},
{
"玖","9"
},
{
"拾","10"
},
{
"佰","百"
},
{
"仟","千"
},
{
"万","万"
},
{
"亿","亿"
},
};
然后我们遍历这个字典,来构建我们的参考表,这里我们可以用foreach
来遍历ReferList
,以便可以取到它的key
和value
。
<!-- 右侧参照表 -->
<div class="col-md-4">
<div class="card">
<div class="card-header">
参照表
</div>
<div class="card-body">
<!-- 快捷键1-9 -->
<div class="row">
@foreach (var item in ReferList)
{
<div class="col-4">
<button class="btn btn-light border refer">
<div class="text-bottom">
@item.Key<small>(@item.Value)</small>
</div>
</button>
</div>
}
</div>
</div>
</div>
</div>
.refer {
font-family: "Consolas";
font-size: 150%;
width: 100%;
min-height: 80px;
min-width: 60px;
margin-bottom: 10px;
}
其实到这里呢,我们的主要是任务已经完成了,完成了整个左侧和右侧功能区的实现,啦啦啦。
看下效果吧。
定制启动页
Blazor
的启动页默认是个很简单的Loading
字样,这要是应用写大了或者用户的网络慢一点,就真的挺难看的,好在我们其实可以也很容易在wwwroot\index.html
找到它,并且对它完成自己的定制,当然效果这东西取决于你的审美和前端技术功底了。
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">