Compare commits
29 Commits
c446012199
...
feat/typst
| Author | SHA1 | Date | |
|---|---|---|---|
|
9061092b64
|
|||
|
8964e760ee
|
|||
|
7faca1af6d
|
|||
|
3f1711e789
|
|||
|
455b716b15
|
|||
|
0c0e721e8f
|
|||
|
dfb66b4301
|
|||
|
05f99f4b79
|
|||
|
5fef951d36
|
|||
|
c9f6d638b9
|
|||
|
6733bbbd2a
|
|||
|
e10c8e7e75
|
|||
|
45f15c9bd9
|
|||
|
a1b5af5b0c
|
|||
|
d8e4931d63
|
|||
|
462fbb28ac
|
|||
|
6ea14b186a
|
|||
|
fa01b74f09
|
|||
|
dd81e9a6f4
|
|||
|
35f069f40a
|
|||
|
80e48a2043
|
|||
|
1be39327aa
|
|||
|
c050d1b790
|
|||
|
56374a4e6b
|
|||
|
58ba4b2a2f
|
|||
|
009e86b553
|
|||
|
d1ec3a51d1
|
|||
|
dab866f13a
|
|||
| 94421168c6 |
@@ -12,7 +12,10 @@ indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[project.json]
|
||||
[{project.json,appsettings.json,appsettings.*.json}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{yaml,yml}]
|
||||
indent_size = 2
|
||||
|
||||
# C# and Visual Basic files
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
name: Build blog docker image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
Build-Blog-Image:
|
||||
runs-on: archlinux
|
||||
steps:
|
||||
- uses: https://mirrors.rrricardo.top/actions/checkout.git@v4
|
||||
name: Check out code
|
||||
with:
|
||||
lfs: true
|
||||
- name: Build project
|
||||
run: |
|
||||
cd YaeBlog
|
||||
dotnet publish
|
||||
- name: Build docker image
|
||||
run: |
|
||||
cd YaeBlog
|
||||
podman build . -t registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest --build-arg COMMIT_ID=$(git rev-parse --short=10 HEAD)
|
||||
- name: Workaround to make sure podman login succeed
|
||||
run: |
|
||||
mkdir /root/.docker
|
||||
- name: Login aliyun docker registry
|
||||
uses: https://mirrors.rrricardo.top/actions/podman-login.git@v1
|
||||
with:
|
||||
registry: registry.cn-beijing.aliyuncs.com
|
||||
username: 初冬的朝阳
|
||||
password: ${{ secrets.ALIYUN_PASSWORD }}
|
||||
auth_file_path: /etc/containers/auth.json
|
||||
- name: Push docker image
|
||||
run: podman push registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
Build-Blog-Image:
|
||||
runs-on: archlinux
|
||||
steps:
|
||||
- name: Check out code.
|
||||
uses: http://github-mirrors.infra.svc.cluster.local/actions/checkout.git@v4
|
||||
with:
|
||||
lfs: true
|
||||
- name: Build project.
|
||||
run: |
|
||||
git submodule update --init
|
||||
podman pull mcr.azure.cn/dotnet/aspnet:10.0
|
||||
pwsh build.ps1 build
|
||||
- name: Workaround to make sure podman-login working.
|
||||
run: |
|
||||
mkdir -p /root/.docker
|
||||
- name: Login tencent cloud docker registry.
|
||||
uses: http://github-mirrors.infra.svc.cluster.local/actions/podman-login.git@v1
|
||||
with:
|
||||
registry: ccr.ccs.tencentyun.com
|
||||
username: 100044380877
|
||||
password: ${{ secrets.TENCENT_REGISTRY_PASSWORD }}
|
||||
auth_file_path: /etc/containers/auth.json
|
||||
- name: Push docker image.
|
||||
run: podman push ccr.ccs.tencentyun.com/jackfiled/blog:latest
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -184,6 +184,7 @@ DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
out/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "third-party/BlazorSvgComponents"]
|
||||
path = third-party/BlazorSvgComponents
|
||||
url = https://git.rrricardo.top/jackfiled/BlazorSvgComponents.git
|
||||
13
YaeBlog.slnx
13
YaeBlog.slnx
@@ -7,8 +7,19 @@
|
||||
<File Path=".editorconfig" />
|
||||
<File Path=".gitattributes" />
|
||||
<File Path=".gitignore" />
|
||||
<File Path="build.ps1" />
|
||||
<File Path="LICENSE" />
|
||||
<File Path="README.md" />
|
||||
</Folder>
|
||||
<Project Path="YaeBlog/YaeBlog.csproj" />
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/YaeBlog.Abstractions/YaeBlog.Abstractions.csproj" />
|
||||
<Project Path="src/YaeBlog.Tests/YaeBlog.Tests.csproj" />
|
||||
<Project Path="src/YaeBlog.Typst/YaeBlog.Typst.csproj" />
|
||||
<Project Path="src/YaeBlog/YaeBlog.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/third-party/" />
|
||||
<Folder Name="/third-party/BlazorSvgComponents/" />
|
||||
<Folder Name="/third-party/BlazorSvgComponents/src/">
|
||||
<Project Path="third-party/BlazorSvgComponents/src/BlazorSvgComponents/BlazorSvgComponents.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Abstraction;
|
||||
|
||||
public interface IEssayScanService
|
||||
{
|
||||
public Task<BlogContents> ScanContents();
|
||||
|
||||
public Task SaveBlogContent(BlogContent content, bool isDraft = true);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using System.CommandLine.Binding;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Commands.Binders;
|
||||
|
||||
public sealed class BlogOptionsBinder : BinderBase<IOptions<BlogOptions>>
|
||||
{
|
||||
protected override IOptions<BlogOptions> GetBoundValue(BindingContext bindingContext)
|
||||
{
|
||||
bindingContext.AddService<IOptions<BlogOptions>>(_ =>
|
||||
{
|
||||
FileInfo settings = new(Path.Combine(Environment.CurrentDirectory, "appsettings.json"));
|
||||
if (!settings.Exists)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load YaeBlog configurations.");
|
||||
}
|
||||
|
||||
using StreamReader reader = settings.OpenText();
|
||||
using JsonDocument document = JsonDocument.Parse(reader.ReadToEnd());
|
||||
JsonElement root = document.RootElement;
|
||||
JsonElement optionSection = root.GetProperty(BlogOptions.OptionName);
|
||||
|
||||
BlogOptions? result = optionSection.Deserialize<BlogOptions>();
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load YaeBlog configuration in appsettings.json.");
|
||||
}
|
||||
|
||||
return new OptionsWrapper<BlogOptions>(result);
|
||||
});
|
||||
|
||||
return bindingContext.GetRequiredService<IOptions<BlogOptions>>();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.CommandLine.Binding;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Models;
|
||||
using YaeBlog.Services;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace YaeBlog.Commands.Binders;
|
||||
|
||||
public sealed class EssayScanServiceBinder : BinderBase<IEssayScanService>
|
||||
{
|
||||
protected override IEssayScanService GetBoundValue(BindingContext bindingContext)
|
||||
{
|
||||
bindingContext.AddService<IEssayScanService>(provider =>
|
||||
{
|
||||
DeserializerBuilder deserializerBuilder = new();
|
||||
deserializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
|
||||
deserializerBuilder.IgnoreUnmatchedProperties();
|
||||
|
||||
SerializerBuilder serializerBuilder = new();
|
||||
serializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
|
||||
|
||||
IOptions<BlogOptions> options = provider.GetRequiredService<IOptions<BlogOptions>>();
|
||||
ILogger<EssayScanService> logger = provider.GetRequiredService<ILogger<EssayScanService>>();
|
||||
|
||||
return new EssayScanService(serializerBuilder.Build(), deserializerBuilder.Build(), options, logger);
|
||||
});
|
||||
|
||||
return bindingContext.GetRequiredService<IEssayScanService>();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.CommandLine.Binding;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Services;
|
||||
|
||||
namespace YaeBlog.Commands.Binders;
|
||||
|
||||
public sealed class ImageCompressServiceBinder : BinderBase<ImageCompressService>
|
||||
{
|
||||
protected override ImageCompressService GetBoundValue(BindingContext bindingContext)
|
||||
{
|
||||
bindingContext.AddService(provider =>
|
||||
{
|
||||
IEssayScanService essayScanService = provider.GetRequiredService<IEssayScanService>();
|
||||
ILogger<ImageCompressService> logger = provider.GetRequiredService<ILogger<ImageCompressService>>();
|
||||
|
||||
return new ImageCompressService(essayScanService, logger);
|
||||
});
|
||||
|
||||
return bindingContext.GetRequiredService<ImageCompressService>();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.CommandLine.Binding;
|
||||
|
||||
namespace YaeBlog.Commands.Binders;
|
||||
|
||||
public sealed class LoggerBinder<T> : BinderBase<ILogger<T>>
|
||||
{
|
||||
protected override ILogger<T> GetBoundValue(BindingContext bindingContext)
|
||||
{
|
||||
bindingContext.AddService(_ => LoggerFactory.Create(builder => builder.AddConsole()));
|
||||
bindingContext.AddService<ILogger<T>>(provider =>
|
||||
{
|
||||
ILoggerFactory factory = provider.GetRequiredService<ILoggerFactory>();
|
||||
return factory.CreateLogger<T>();
|
||||
});
|
||||
|
||||
return bindingContext.GetRequiredService<ILogger<T>>();
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Commands.Binders;
|
||||
using YaeBlog.Components;
|
||||
using YaeBlog.Extensions;
|
||||
using YaeBlog.Models;
|
||||
using YaeBlog.Services;
|
||||
|
||||
namespace YaeBlog.Commands;
|
||||
|
||||
public sealed class YaeBlogCommand
|
||||
{
|
||||
private readonly RootCommand _rootCommand = new("YaeBlog Cli");
|
||||
|
||||
public YaeBlogCommand()
|
||||
{
|
||||
AddServeCommand(_rootCommand);
|
||||
AddWatchCommand(_rootCommand);
|
||||
AddListCommand(_rootCommand);
|
||||
AddNewCommand(_rootCommand);
|
||||
AddPublishCommand(_rootCommand);
|
||||
AddScanCommand(_rootCommand);
|
||||
AddCompressCommand(_rootCommand);
|
||||
}
|
||||
|
||||
public Task<int> RunAsync(string[] args)
|
||||
{
|
||||
return _rootCommand.InvokeAsync(args);
|
||||
}
|
||||
|
||||
private static void AddServeCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command serveCommand = new("serve", "Start http server.");
|
||||
rootCommand.AddCommand(serveCommand);
|
||||
|
||||
serveCommand.SetHandler(async context =>
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddControllers();
|
||||
builder.AddYaeBlog();
|
||||
builder.AddServer();
|
||||
|
||||
WebApplication application = builder.Build();
|
||||
|
||||
application.UseStaticFiles();
|
||||
application.UseAntiforgery();
|
||||
application.UseYaeBlog();
|
||||
|
||||
application.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
application.MapControllers();
|
||||
|
||||
CancellationToken token = context.GetCancellationToken();
|
||||
await application.RunAsync(token);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddWatchCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command command = new("watch", "Start a blog watcher that re-render when file changes.");
|
||||
rootCommand.AddCommand(command);
|
||||
|
||||
command.SetHandler(async context =>
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddControllers();
|
||||
builder.AddYaeBlog();
|
||||
builder.AddWatcher();
|
||||
|
||||
WebApplication application = builder.Build();
|
||||
|
||||
application.UseStaticFiles();
|
||||
application.UseAntiforgery();
|
||||
application.UseYaeBlog();
|
||||
|
||||
application.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
application.MapControllers();
|
||||
|
||||
CancellationToken token = context.GetCancellationToken();
|
||||
await application.RunAsync(token);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddNewCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command newCommand = new("new", "Create a new blog file and image directory.");
|
||||
rootCommand.AddCommand(newCommand);
|
||||
|
||||
Argument<string> filenameArgument = new(name: "blog name", description: "The created blog filename.");
|
||||
newCommand.AddArgument(filenameArgument);
|
||||
|
||||
newCommand.SetHandler(async (file, blogOption, _, essayScanService) =>
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
if (contents.Posts.Any(content => content.BlogName == file))
|
||||
{
|
||||
Console.WriteLine("There exists the same title blog in posts.");
|
||||
return;
|
||||
}
|
||||
|
||||
await essayScanService.SaveBlogContent(new BlogContent(
|
||||
new FileInfo(Path.Combine(blogOption.Value.Root, "drafts", file + ".md")),
|
||||
new MarkdownMetadata { Title = file, Date = DateTime.Now },
|
||||
string.Empty, true, [], []));
|
||||
|
||||
Console.WriteLine($"Created new blog '{file}.");
|
||||
}, filenameArgument, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(),
|
||||
new EssayScanServiceBinder());
|
||||
}
|
||||
|
||||
private static void AddListCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command command = new("list", "List all blogs");
|
||||
rootCommand.AddCommand(command);
|
||||
|
||||
command.SetHandler(async (_, _, essyScanService) =>
|
||||
{
|
||||
BlogContents contents = await essyScanService.ScanContents();
|
||||
|
||||
Console.WriteLine($"All {contents.Posts.Count} Posts:");
|
||||
foreach (BlogContent content in contents.Posts.OrderBy(x => x.BlogName))
|
||||
{
|
||||
Console.WriteLine($" - {content.BlogName}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"All {contents.Drafts.Count} Drafts:");
|
||||
foreach (BlogContent content in contents.Drafts.OrderBy(x => x.BlogName))
|
||||
{
|
||||
Console.WriteLine($" - {content.BlogName}");
|
||||
}
|
||||
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder());
|
||||
}
|
||||
|
||||
private static void AddScanCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command command = new("scan", "Scan unused and not found images.");
|
||||
rootCommand.AddCommand(command);
|
||||
|
||||
Option<bool> removeOption =
|
||||
new(name: "--rm", description: "Remove unused images.", getDefaultValue: () => false);
|
||||
command.AddOption(removeOption);
|
||||
|
||||
command.SetHandler(async (_, _, essayScanService, removeOptionValue) =>
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
List<BlogImageInfo> unusedImages = (from content in contents
|
||||
from image in content.Images
|
||||
where image is { IsUsed: false }
|
||||
select image).ToList();
|
||||
|
||||
if (unusedImages.Count != 0)
|
||||
{
|
||||
Console.WriteLine("Found unused images:");
|
||||
Console.WriteLine("HINT: use '--rm' to remove unused images.");
|
||||
}
|
||||
|
||||
foreach (BlogImageInfo image in unusedImages)
|
||||
{
|
||||
Console.WriteLine($" - {image.File.FullName}");
|
||||
}
|
||||
|
||||
if (removeOptionValue)
|
||||
{
|
||||
foreach (BlogImageInfo image in unusedImages)
|
||||
{
|
||||
image.File.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("Used not existed images:");
|
||||
|
||||
foreach (BlogContent content in contents)
|
||||
{
|
||||
foreach (FileInfo file in content.NotfoundImages)
|
||||
{
|
||||
Console.WriteLine($"- {file.Name} in {content.BlogName}");
|
||||
}
|
||||
}
|
||||
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder(), removeOption);
|
||||
}
|
||||
|
||||
private static void AddPublishCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command command = new("publish", "Publish a new blog file.");
|
||||
rootCommand.AddCommand(command);
|
||||
|
||||
Argument<string> filenameArgument = new(name: "blog name", description: "The published blog filename.");
|
||||
command.AddArgument(filenameArgument);
|
||||
|
||||
command.SetHandler(async (blogOptions, _, essayScanService, filename) =>
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
BlogContent? content = (from blog in contents.Drafts
|
||||
where blog.BlogName == filename
|
||||
select blog).FirstOrDefault();
|
||||
|
||||
if (content is null)
|
||||
{
|
||||
Console.WriteLine("Target blog does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置发布的时间
|
||||
content.Metadata.Date = DateTime.Now;
|
||||
|
||||
// 将选中的博客文件复制到posts
|
||||
await essayScanService.SaveBlogContent(content, isDraft: false);
|
||||
|
||||
// 复制图片文件夹
|
||||
DirectoryInfo sourceImageDirectory =
|
||||
new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName));
|
||||
DirectoryInfo targetImageDirectory =
|
||||
new(Path.Combine(blogOptions.Value.Root, "posts", content.BlogName));
|
||||
|
||||
if (sourceImageDirectory.Exists)
|
||||
{
|
||||
targetImageDirectory.Create();
|
||||
foreach (FileInfo file in sourceImageDirectory.EnumerateFiles())
|
||||
{
|
||||
file.CopyTo(Path.Combine(targetImageDirectory.FullName, file.Name), true);
|
||||
}
|
||||
|
||||
sourceImageDirectory.Delete(true);
|
||||
}
|
||||
|
||||
// 删除原始的文件
|
||||
FileInfo sourceBlogFile = new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName + ".md"));
|
||||
sourceBlogFile.Delete();
|
||||
}, new BlogOptionsBinder(),
|
||||
new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder(), filenameArgument);
|
||||
}
|
||||
|
||||
private static void AddCompressCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command command = new("compress", "Compress png/jpeg image to webp image to reduce size.");
|
||||
rootCommand.Add(command);
|
||||
|
||||
Option<bool> dryRunOption = new("--dry-run", description: "Dry run the compression task but not write.",
|
||||
getDefaultValue: () => false);
|
||||
command.AddOption(dryRunOption);
|
||||
|
||||
command.SetHandler(ImageCommandHandler,
|
||||
new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new LoggerBinder<ImageCompressService>(),
|
||||
new EssayScanServiceBinder(), new ImageCompressServiceBinder(), dryRunOption);
|
||||
}
|
||||
|
||||
private static async Task ImageCommandHandler(IOptions<BlogOptions> _, ILogger<EssayScanService> _1,
|
||||
ILogger<ImageCompressService> _2,
|
||||
IEssayScanService _3, ImageCompressService imageCompressService, bool dryRun)
|
||||
{
|
||||
await imageCompressService.Compress(dryRun);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="YaeBlog.styles.css"/>
|
||||
<link rel="icon" href="images/favicon.ico"/>
|
||||
<link rel="stylesheet" href="globals.css"/>
|
||||
<link rel="stylesheet" href="tailwind.g.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,30 +0,0 @@
|
||||
<div class="flex flex-col text-center py-2">
|
||||
<div>
|
||||
<p class="text-md">
|
||||
2021 - @(DateTimeOffset.Now.Year) ©
|
||||
<Anchor Address="https://rrricardot.top" Text="Ricardo Ren"/>
|
||||
,由
|
||||
<Anchor Address="https://dotnet.microsoft.com" Text="@DotnetVersion"/>
|
||||
驱动。
|
||||
</p>
|
||||
<p class="text-md">
|
||||
Build Commit #
|
||||
<Anchor Address="@BuildCommitUrl" Text="@BuildCommitId"/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-md">
|
||||
<Anchor Address="https://beian.miit.gov.cn" Text="蜀ICP备2022004429号-1" NewPage="true"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private string DotnetVersion => $".NET {Environment.Version}";
|
||||
|
||||
private string BuildCommitId => Environment.GetEnvironmentVariable("COMMIT_ID") ?? "local_build";
|
||||
|
||||
private string BuildCommitUrl => $"https://git.rrricardo.top/jackfiled/YaeBlog/commit/{BuildCommitId}";
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using Markdig;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace YaeBlog.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddMarkdig(this IServiceCollection collection)
|
||||
{
|
||||
MarkdownPipelineBuilder builder = new();
|
||||
|
||||
builder.UseAdvancedExtensions();
|
||||
|
||||
collection.AddSingleton<MarkdownPipeline>(_ => builder.Build());
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddYamlParser(this IServiceCollection collection)
|
||||
{
|
||||
DeserializerBuilder deserializerBuilder = new();
|
||||
deserializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
|
||||
deserializerBuilder.IgnoreUnmatchedProperties();
|
||||
collection.AddSingleton(deserializerBuilder.Build());
|
||||
|
||||
SerializerBuilder serializerBuilder = new();
|
||||
serializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
|
||||
collection.AddSingleton(serializerBuilder.Build());
|
||||
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using AngleSharp;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Services;
|
||||
using YaeBlog.Models;
|
||||
using YaeBlog.Processors;
|
||||
|
||||
namespace YaeBlog.Extensions;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder AddYaeBlog(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<BlogOptions>(builder.Configuration.GetSection(BlogOptions.OptionName));
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
builder.Services.AddMarkdig();
|
||||
builder.Services.AddYamlParser();
|
||||
builder.Services.AddSingleton<AngleSharp.IConfiguration>(_ => Configuration.Default);
|
||||
builder.Services.AddSingleton<IEssayScanService, EssayScanService>();
|
||||
builder.Services.AddSingleton<RendererService>();
|
||||
builder.Services.AddSingleton<IEssayContentService, EssayContentService>();
|
||||
builder.Services.AddTransient<ImagePostRenderProcessor>();
|
||||
builder.Services.AddTransient<HeadlinePostRenderProcessor>();
|
||||
builder.Services.AddTransient<EssayStylesPostRenderProcessor>();
|
||||
builder.Services.AddTransient<BlogOptions>(provider =>
|
||||
provider.GetRequiredService<IOptions<BlogOptions>>().Value);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplicationBuilder AddServer(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddHostedService<BlogHostedService>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplicationBuilder AddWatcher(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddTransient<BlogChangeWatcher>();
|
||||
builder.Services.AddHostedService<BlogHotReloadService>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@attribute [StreamRendering]
|
||||
|
||||
<main class="container mx-auto flex flex-col min-h-screen">
|
||||
<div class="grid grid-cols-3 mx-3">
|
||||
<div class="md:col-span-2 col-span-3 h-20 flex items-center">
|
||||
<a href="/blog/">
|
||||
<span class="text-blue-600 text-2xl">Ricardo's Blog</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-1 col-span-3 h-20 flex items-center">
|
||||
<div class="flex flex-row w-full px-2 gap-3 md:justify-center justify-end">
|
||||
<div>
|
||||
<a href="/blog/archives/">
|
||||
<span class="text-xl text-blue-600">归档</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/blog/tags/">
|
||||
<span class="text-xl text-blue-600">标签</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/about/" target="_blank">
|
||||
<span class="text-xl text-blue-600">关于</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/friends/" target="_blank">
|
||||
<span class="text-xl text-blue-600">友链</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-2 flex-grow">
|
||||
@Body
|
||||
</div>
|
||||
|
||||
<Foonter/>
|
||||
</main>
|
||||
@@ -1,61 +0,0 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public class BlogEssay : IComparable<BlogEssay>
|
||||
{
|
||||
public required string Title { get; init; }
|
||||
|
||||
public required string FileName { get; init; }
|
||||
|
||||
public required bool IsDraft { get; init; }
|
||||
|
||||
public required DateTime PublishTime { get; init; }
|
||||
|
||||
public required string Description { get; init; }
|
||||
|
||||
public required uint WordCount { get; init; }
|
||||
|
||||
public required string ReadTime { get; init; }
|
||||
|
||||
public List<string> Tags { get; } = [];
|
||||
|
||||
public required string HtmlContent { get; init; }
|
||||
|
||||
public BlogEssay WithNewHtmlContent(string newHtmlContent)
|
||||
{
|
||||
var essay = new BlogEssay
|
||||
{
|
||||
Title = Title,
|
||||
FileName = FileName,
|
||||
IsDraft = IsDraft,
|
||||
PublishTime = PublishTime,
|
||||
Description = Description,
|
||||
WordCount = WordCount,
|
||||
ReadTime = ReadTime,
|
||||
HtmlContent = newHtmlContent
|
||||
};
|
||||
essay.Tags.AddRange(Tags);
|
||||
|
||||
return essay;
|
||||
}
|
||||
|
||||
public int CompareTo(BlogEssay? other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 草稿文章应当排在前面
|
||||
if (IsDraft != other.IsDraft)
|
||||
{
|
||||
return IsDraft ? -1 : 1;
|
||||
}
|
||||
|
||||
return other.PublishTime.CompareTo(PublishTime);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Title}-{PublishTime}";
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public class BlogOptions
|
||||
{
|
||||
public const string OptionName = "Blog";
|
||||
|
||||
/// <summary>
|
||||
/// 博客markdown文件的根目录
|
||||
/// </summary>
|
||||
public required string Root { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 博客正文的广而告之
|
||||
/// </summary>
|
||||
public required string Announcement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 博客的起始年份
|
||||
/// </summary>
|
||||
public required int StartYear { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 博客的友链
|
||||
/// </summary>
|
||||
public required List<FriendLink> Links { get; set; }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 友链模型类
|
||||
/// </summary>
|
||||
public class FriendLink
|
||||
{
|
||||
/// <summary>
|
||||
/// 友链名称
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 友链的简单介绍
|
||||
/// </summary>
|
||||
public required string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 友链地址
|
||||
/// </summary>
|
||||
public required string Link { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址
|
||||
/// </summary>
|
||||
public required string AvatarImage { get; set; }
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public class MarkdownMetadata
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
|
||||
public DateTime? Date { get; set; }
|
||||
|
||||
public List<string>? Tags { get; set; }
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
@page "/about"
|
||||
|
||||
<PageTitle>
|
||||
关于
|
||||
</PageTitle>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
<h1 class="text-4xl">关于</h1>
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<span class="italic">把字刻在石头上!(・’ω’・)</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col p-2">
|
||||
<div class="flex flex-col p-2">
|
||||
<div class="pb-2">
|
||||
<h3 class="text-2xl">关于我</h3>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
计算机科学与技术在读大学生,明光村幼儿园附属大学所属。正处于读书和失业的叠加态。
|
||||
一般在互联网上使用<span class="italic">初冬的朝阳</span>或者<span class="italic">jackfiled</span>的名字活动。
|
||||
<span class="line-through">都是ICP备案过的人了,网名似乎没有太大的用处(</span>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
主要是一个C#程序员,目前也在尝试写一点Rust。
|
||||
总体上对于编程语言的态度是“<span>大家都是我的翅膀.jpg</span>”。
|
||||
前后端分离的项目本当上手。
|
||||
常常因为现实的压力而写一些C/C++。
|
||||
<span class="line-through">对于Java和Go的评价很低。</span>
|
||||
日常使用ArchLinux。
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
100%社恐。日常生活是宅在电脑前面自言自语。
|
||||
兴趣活动是读书和看番,目前在玩原神和三角洲。
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
常常被人批评没有梦想,这里就随便瞎编一下。
|
||||
成为嵌入式工程师,修好桌面上的<a href="https://www.bilibili.com/video/BV1VA411p7MD">HoloCubic</a>。
|
||||
完成第一个不是课程设计的个人开源项目。
|
||||
遇到能够搭伙过日子的人也算是一大梦想,虽然社恐人根本不知道从何开始的说,
|
||||
<span class="line-through">什么时候天上才能掉美少女?</span>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
公开的联系渠道是<a href="mailto:shicangjuner@outlook.com">电子邮件</a>。
|
||||
也可以试试在各大平台搜索上面提到的名字。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col p-2">
|
||||
<div class="pb-2">
|
||||
<h3 class="text-2xl">关于本站</h3>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
本站肇始于2021年下半年,在开始的两年中个人网站和博客是分别的两个网站,个人网站是裸HTML写的,博客是用
|
||||
<a href="https://hexo.io">Hexo</a>渲染的。
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
2024年,我们决定使用.NET技术完全重构两个网站,合二为一。虽然目前这个版本还是一个半成品,但是我们一定会努力的~(确信。
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
2025年,我们将使用的样式库从Bootstrap迁移到Tailwind CSS,将现代的前端技术同Blazor结合起来。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>
|
||||
Ricardo's Index
|
||||
</PageTitle>
|
||||
|
||||
<div class="mx-20">
|
||||
<div class="grid grid-cols-3 py-4">
|
||||
<div class="col-span-3 md:col-span-1 p-5 p-lg-0">
|
||||
<img src="images/avatar.png" alt="Ricardo's Avatar" class="h-auto max-w-full">
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 md:col-span-2">
|
||||
<div class="flex flex-col px-3 gap-y-3">
|
||||
<div class="">
|
||||
<div class="text-3xl font-bold">初冬的朝阳 (Ricardo Ren)</div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<p class="text-lg">a.k.a jackfiled</p>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<p class="text-lg italic">世界很大,时间很长。</p>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<p class="text-lg">
|
||||
平平无奇的计算机科学与技术学徒,连微小的贡献都没做。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-5">
|
||||
<p class="text-lg">恕我不能亲自为您沏茶(?),还是非常欢迎您能够来到我的主页。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-lg py-1">
|
||||
如果您想四处看看,了解一下屏幕对面的人,可以在我的 <Anchor Address="/blog/" Text="博客"/> 看看。
|
||||
如果您对于明光村幼儿园某附属技校的计算机教学感兴趣,您可以移步到
|
||||
<Anchor Address="https://jackfiled.github.io/wiki/" Text="我的学习笔记"/>,
|
||||
<span class="fs-5 text-decoration-line-through">虽然这笔记我自己也木有看过。</span>
|
||||
如果您想批判一下我的代码,在
|
||||
<Anchor Address="https://github.com/jackfiled/" Text="Github"/> 和
|
||||
<Anchor Address="https://git.rrricardo.top/jackfiled/" Text="Gitea"/>
|
||||
都可以找到。
|
||||
</p>
|
||||
<p class="text-lg py-1">
|
||||
如果您真的很闲,也可以四处搜寻一下,也许存在着一些不为人知的彩蛋。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
using YaeBlog.Commands;
|
||||
|
||||
YaeBlogCommand command = new();
|
||||
await command.RunAsync(args);
|
||||
@@ -1,40 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ImageFlow.NativeRuntime.ubuntu-x86_64" Version="2.1.0-rc11" Condition="$([MSBuild]::IsOsPlatform('Linux'))"/>
|
||||
<PackageReference Include="ImageFlow.NativeRuntime.osx-arm64" Version="2.1.0-rc11" Condition="$([MSBuild]::IsOsPlatform('OSX'))"/>
|
||||
<PackageReference Include="ImageFlow.Net" Version="0.13.2"/>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1"/>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.0"/>
|
||||
<PackageReference Include="Markdig" Version="0.38.0"/>
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
</Exec>
|
||||
|
||||
<Error Condition="$(ErrorCode) != 0" Text="Pnpm is not installed which is required for build."/>
|
||||
|
||||
<Message Importance="normal" Text="Installing pakages using pnpm..."/>
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild" Condition="'$(_IsPublishing)' == 'yes'">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o $(IntermediateOutputPath)tailwind.g.css"/>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="$(IntermediateOutputPath)tailwind.g.css" Visible="false" TargetPath="wwwroot/tailwind.g.css"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Tailwind": {
|
||||
"InputFile": "wwwroot/input.css",
|
||||
"OutputFile": "wwwroot/output.css"
|
||||
},
|
||||
"Blog": {
|
||||
"Root": "source",
|
||||
"Announcement": "博客锐意装修中,敬请期待!测试阶段如有问题还请海涵。",
|
||||
"StartYear": 2021,
|
||||
"Links": [
|
||||
{
|
||||
"Name": "Ichirinko",
|
||||
"Description": "这是个大哥",
|
||||
"Link": "https://ichirinko.top",
|
||||
"AvatarImage": "https://ichirinko-blog-img-1.oss-cn-shenzhen.aliyuncs.com/Pic_res/img/202209122110798.png"
|
||||
},
|
||||
{
|
||||
"Name": "志田千陽",
|
||||
"Description": "日出多值得",
|
||||
"Link": "https://zzachary.top/",
|
||||
"AvatarImage": "https://zzachary.top/img/ztqy_hub928259802d192ff5718c06370f0f2c4_48203_300x0_resize_q75_box.jpg"
|
||||
},
|
||||
{
|
||||
"Name": "不会写程序的晨旭",
|
||||
"Description": "一个普通大学生",
|
||||
"Link": "https://chenxutalk.top",
|
||||
"AvatarImage": "https://www.chenxutalk.top/img/photo.png"
|
||||
},
|
||||
{
|
||||
"Name": "万木长风",
|
||||
"Description": "世界渲染中...",
|
||||
"Link": "https://ryohai.fun",
|
||||
"AvatarImage": "https://ryohai.fun/icon.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
blog:
|
||||
image: registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.blog.rule=Host(`rrricardo.top`) || Host(`www.rrricardo.top`)"
|
||||
- "traefik.http.services.blog.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.blog.tls=true"
|
||||
- "traefik.http.routers.blog.tls.certresolver=myresolver"
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: High Performance Computing 25 SP Quantum Computing
|
||||
date: 2025-06-12T19:26:24.6668760+08:00
|
||||
tags:
|
||||
- 高性能计算
|
||||
- 学习资料
|
||||
---
|
||||
|
||||
|
||||
<!--more-->
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,15 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url(fonts/fa-regular-400.woff2) format("woff2"), url(fonts/fa-regular-400.ttf) format("truetype")
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: block;
|
||||
src: url(fonts/fa-solid-900.woff2) format("woff2"), url(fonts/fa-solid-900.ttf) format("truetype")
|
||||
}
|
||||
154
build.ps1
Executable file
154
build.ps1
Executable file
@@ -0,0 +1,154 @@
|
||||
#!pwsh
|
||||
|
||||
[cmdletbinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0, HelpMessage = "Specify the build target")]
|
||||
[ValidateSet("publish", "compress", "build", "dev", "new", "watch", "serve")]
|
||||
[string]$Target,
|
||||
[string]$Essay,
|
||||
[switch]$Compress,
|
||||
[string]$Root = "source"
|
||||
)
|
||||
|
||||
begin {
|
||||
if (($Target -eq "tailwind") -or ($Target -eq "build"))
|
||||
{
|
||||
# Handle tailwind specially.
|
||||
return
|
||||
}
|
||||
|
||||
# Set the content root.
|
||||
$fullRootPath = Join-Path $(Get-Location) $Root
|
||||
if (-not (Test-Path $fullRootPath))
|
||||
{
|
||||
Write-Error "Content root $fullRootPath not existed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Use content from" $fullRootPath
|
||||
$env:BLOG__ROOT=$fullRootPath
|
||||
|
||||
Write-Host "Building $Target..."
|
||||
|
||||
if ($Target -eq "publish")
|
||||
{
|
||||
if ($Essay -eq "")
|
||||
{
|
||||
Write-Error "No publish target, please add with --essay argument."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($Target -eq "new")
|
||||
{
|
||||
if ($Essay -eq "")
|
||||
{
|
||||
Write-Error "No new name, please add with --essay argument."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Set to the current location.
|
||||
Push-Location src/YaeBlog
|
||||
}
|
||||
|
||||
process {
|
||||
function Compress-Image
|
||||
{
|
||||
Write-Host "Compress image assets..."
|
||||
dotnet run -- compress --dry-run
|
||||
$confirm = Read-Host "Really compress images? (y/n)"
|
||||
if ($confirm -notmatch "^[yY]$")
|
||||
{
|
||||
Write-Host "Not compress images."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Do compress image..."
|
||||
dotnet run -- compress
|
||||
|
||||
dotnet run -- scan
|
||||
$confirm = Read-Host "Really delete unused images? (y/n)"
|
||||
if ($confirm -notmatch "^[yY]$")
|
||||
{
|
||||
Write-Host "Not delete images."
|
||||
return
|
||||
}
|
||||
Write-Host "Do delete unused images.."
|
||||
dotnet run -- scan --rm
|
||||
}
|
||||
|
||||
function Build-Image
|
||||
{
|
||||
$commitId = git rev-parse --short=10 HEAD
|
||||
dotnet publish ./src/YaeBlog/YaeBlog.csproj -o out
|
||||
Write-Host "Succeed to build blog appliocation."
|
||||
podman build . -t ccr.ccs.tencentyun.com/jackfiled/blog --build-arg COMMIT_ID=$commitId `
|
||||
-f ./src/YaeBlog/Dockerfile
|
||||
Write-Host "Succeed to build ccr.ccs.tencentyun.com/jackfiled/blog image."
|
||||
}
|
||||
|
||||
function Start-Develop {
|
||||
Write-Host "Start tailwindcss and dotnet watch servers..."
|
||||
$pnpmProcess = Start-Process pnpm "tailwindcss -i wwwroot/tailwind.css -o obj/Debug/net10.0/ClientAssets/tailwind.g.css -w" `
|
||||
-PassThru
|
||||
|
||||
try
|
||||
{
|
||||
Write-Host "Started pnpm process exit? " $pnpmProcess.HasExited
|
||||
Start-Process dotnet "watch -- serve" -PassThru | Wait-Process
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($pnpmProcess.HasExited)
|
||||
{
|
||||
Write-Error "pnpm process has exited!"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Kill tailwindcss and dotnet watch servers..."
|
||||
$pnpmProcess | Stop-Process
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
switch ($Target)
|
||||
{
|
||||
"publish" {
|
||||
Write-Host "Publish essay $Essay..."
|
||||
dotnet run -- publish $Essay
|
||||
|
||||
if ($Compress)
|
||||
{
|
||||
Compress-Image
|
||||
}
|
||||
break
|
||||
}
|
||||
"compress" {
|
||||
Compress-Image
|
||||
break
|
||||
}
|
||||
"build" {
|
||||
Build-Image
|
||||
break
|
||||
}
|
||||
"dev" {
|
||||
Start-Develop
|
||||
break
|
||||
}
|
||||
"new" {
|
||||
dotnet run -- new $Essay
|
||||
}
|
||||
"watch" {
|
||||
dotnet run -- watch
|
||||
break
|
||||
}
|
||||
"serve" {
|
||||
dotnet run -- serve
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
end {
|
||||
Pop-Location
|
||||
}
|
||||
5
global.json
Normal file
5
global.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
174
source/posts/2025-final.md
Normal file
174
source/posts/2025-final.md
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: 2025年终总结
|
||||
date: 2026-05-08T01:22:18.6904350+08:00
|
||||
updateTime: 2026-05-08T01:22:18.8859180+08:00
|
||||
tags:
|
||||
- 杂谈
|
||||
- 年终总结
|
||||
---
|
||||
|
||||
|
||||
|
||||
越来越晚的年终总结是本站不得不品的一大特色,在这样下去2026年的年终总结要到2028年才能面世了(绝望)。不过2025年确实是非常丰富多彩的一年,这一年的经历是如此的多样,以至于真正需要提笔写下来的时候反而不知道可以写什么。
|
||||
|
||||
<!--more-->
|
||||
|
||||
### 毕业
|
||||
|
||||
在2025年的6月,我从明光村幼儿园附属大学的括号学院毕业了。
|
||||
|
||||
毕业本身似乎并不是什么特别需要记录的事情,不过是一篇论文、两张证书和几次合照,现在回想起来只有一种如堕梦中的感觉。
|
||||
|
||||
虽然对于其他的事情都已经几乎淡忘,但是因为我提前自愿(?)选修了研究生课程《高性能计算》,而这门课的期末考试日期甚至在毕业典礼之后。这不得不使得我在毕业季各种事务缠身的情况下还得抽出时间准备考试,而这个《高性能计算》课程的内容又多又杂,实在是又难学又难背。这使得我对于这个毕业季影响最深刻的事情不是什么毕业合照,而是SMP和CUDA!
|
||||
|
||||
不过说都说到这里了,顺便回忆一下我的四年大学生活吧。
|
||||
|
||||
### 我的大学
|
||||
|
||||
还记得2021年的暑假,我还在和一个名叫Microsoft Visual Studio的神秘软件搏斗,并不知道这个软件中某个叫作.NET SDK的东西会成为这四年中的一个重要组成部分,当时还创作了本博客的[第一篇文章](https://rrricardo.top/blog/essays/question-in-install-vs-2019)。
|
||||
|
||||
2021年的9月第一次来到校园,不得不说该附属大学的偏远校区还是很符合我对于大学校园的刻板印象的。标准的四人间、高大的图书馆,迷宫一样的教学楼设计充分满足了我对于大学生活的一切想象,甚至到主校区需要坐一个半小时地铁也是刻板印象的一部分。
|
||||
|
||||
不过这样的“幸福大学生活”只持续了四个月。在2022年9月份就回到了宇宙中心的南边,中国最宽公路的东边,我们伟大的明光村。很高情商地说,在这个校区中学习和生活,可以**随时随地**地品味到这个学校深厚的历史底蕴。例如我现在居住的宿舍,在20年前也居住过我的导师。课间在窗边踱步,你可以欣赏到墙上的照片里,脚下的教学楼在1956年落成时的雄姿。
|
||||
|
||||
还是谈谈在这四年中我所修过的那些课程吧。本来在2025年6月毕业之后,我打算写一篇文章,结合我四年中的经历,详细分析一下我所经历的计算机科学本科教学。但是迟迟没有时间落笔,就在这里简单评述一下。
|
||||
|
||||

|
||||
|
||||
首先是培养方案中归类为“数学与自然科学”的课程:《高等数学A》和《线性代数》是必修课,其中《高等数学A》因为代课老师江彦是一个**认真负责**的好老师,这里给到一个夯。《线性代数》则是因为课程设计和老师只能给到一个NPC,说实话本来想给到一个拉完了,但是考虑到这门课算是计算机科学比较重要的数学基础,还是给到NPC。
|
||||
|
||||
两门概率论课程我选的是《概率论与随机过程》,同样因为代课老师鞠红杰给到一个顶级,而且随机过程本来就是一个非常有趣的研究课题,这门课的课程论文就是博客中的[原神抽卡研究](https://rrricardo.top/blog/essays/genshin-gacha-1)。至于《组合数学》、《运筹学》、《数学建模与模拟》和《矩阵理论与方法》四选一,我选择的是《数学建模与模拟》,这个课程只能给到NPC,听了和没听一样,老师脾气还不太好。《大学物理C》同样只能给到一个NPC,虽然我对于物理还是挺感兴趣的~~还参加了大学生物理竞赛~~,但是这实在和计算机学科关系不太大,安排这个不如多安排一些通信和电子的内容。
|
||||
|
||||
然后是培养方案中“学科基础”大类课程,这个课程组中的课程都是必修课程。《计算导论与程序设计》是大一上学期的唯一一门专业课,也是计算机科学的入门课,我会说它很好的完成了这个任务,虽然还有不少的优化空间,这里给到一个顶级。
|
||||
|
||||
《电路与电子学基础》我直接给到一个顶级,老师非常风趣幽默,教学内容不能说是和计算机科学直接相关,但也算是计算机的基石了。《离散数学》这里是一个拉完了,从理论上来说离散数学的教学内容可以算是实际上的计算机科学核心,但是正是这种极端重要性更加凸显出了这个课程拉完了的程度,而且还是中英文混合教学,我的评价是没有金刚钻就别揽瓷器活。
|
||||
|
||||
《数字逻辑与数字系统》,还可以,归类到人上人,课程内容本身比较重要,算是之后的计算机组成原理的先修课程。《形式语言与自动机》,这门课的代课老师石川老师算是北邮的风云人物,其他的东西不评价,但是他在这门课程上的表现还是值得肯定的,这里归到人上人。
|
||||
|
||||
下面正式进入计算机科学中的真·核心课程,即培养方案中的“专业基础”课程组。首先是《数据结构》,因为考试要求手写代码直接给到一个拉完了,我得承认这个有我自己的一些偏见。《算法设计与分析》本来想以同样的理由给到一个拉完了,~~但是考虑到我的成绩比《数据结构》高~~,这里给到一个NPC。《计算机系统基础》课程,使用的教材是大名鼎鼎的*Computer System: A Programmer Perspective* ,虽然课程并没有完全覆盖CSAPP中所有内容,但是还是给到一个夯!
|
||||
|
||||
《操作系统》,按道理讲也是计算机科学中的核心课程,但是课程内容我现在就记得一个信号量控制抢水果和挑水,老师也非常莫名其妙,直接给到拉完了,正所谓学完这门课你也不懂操作系统。然后是《编译原理与技术》,可以算是计算机科学中最难的课程之一,但是因为课程核心都放在了编译器的前端部分,教学语言还是老掉牙的Pascal,只能给到一个人上人。
|
||||
|
||||
《计算机组成原理》,考虑老师直接在课程推销王道考研,直接给到拉完了。《计算机系统结构》,算是《计算机组成原理》的进阶课,老师讲得也非常不错,和之前的《计算机组成原理》相比简直是天差地别,直接给到一个顶级,如果实验部分改进一下,和《计算机组成原理课程设计》结合一下,就可以直接给到夯了。《计算机网络》直接给到夯,算是课程与现实结合最紧密的课程,和《离散数学》同样都是中英文混合教学,只能说人与人之间亦有差距。《数据库系统原理》获得夯的理由和《计算机网络》比较一致,都是同现实紧密关联的课程,任课老师教学非常不错,也是中英文混合教学。
|
||||
|
||||
《软件工程》和《现代交换原理》则是难兄难弟,两门都是拉完了:《软件工程》被称为计算机科学中的政治,我认为与其死记硬背一些非常抽象的概念、参加两次考试不如多写几行代码来得体会深刻。而且这门课的实践部分也非常抽象,要求几个小组联合验收:而且这些小组的前后端之间需要可以随机组合。虽然这个要求可以说是非常的软件工程,但是和你合作的哥们可能并不是特别拟人,只能唉唉唉。至于《现代交换原理》,第一次看到这门课的时候我很怀疑这是否是打印错误,虽然代课老师挺不错的,但是它唯一的问题就是不该出现在计算机科学的培养方案上。退一步说就算学校设计培养方案的时候希望计算机科学的同学也懂一点通信,也是应该设计简单一点的通信原理而不是设计一门不知所云的“古代交换原理”课程(毕竟课程的主体部分是电话交换机)。
|
||||
|
||||
然后是专业课选修课组部分。在“网络&开发技术模块”,我选修的课程是《下一代Internet技术与协议》、《移动互联网技术及应用》和《Python程序设计》三门课。其中《下一代Internet技术与协议》是中规中矩的选修课程,只能归到NPC一档,课程的主要内容就是讲IPv6协议栈,考虑到现在IPv6协议栈已经在大规模的普及推进,建议把课程名称中的“下一代”去掉。《移动互联网技术及应用》就是教写Android应用,考虑到这门课的老师是北邮少数敢于在课程上打开IDE写代码的老师,出于对这位老师的敬佩,这里给到人上人。《Python程序设计》也是一门平平无奇的选修课,同时是出于对踢球骨折了还拄着拐杖来上课的老师之敬佩,这里给到人上人。
|
||||
|
||||
在“大数据技术模块”,我选修的课程是《大数据技术基础》和《信息与知识获取》。其中的《大数据技术基础》的实验部分非常抽象,要求使用容器技术模拟出多个节点来搭建大数据系统,比如Hadoop和Spark,还需要在华为的ARM云服务器上进行实验,与其叫作是《大数据技术基础》不如改名为《Linux系统运维基础》。不过考虑到这是我本科期间唯一一门愿意给100分的课程,这门还是给到人上人。《信息与知识获取》则是“大海呀,你全部都是水~”,这里给到NPC。
|
||||
|
||||
在“技术拓展模块”,我选修的课程是《人工智能原理》和《程序设计实践》。这两门都给到NPC,《人工智能原理》是因为它讲授的原理有点太古老了,连深度神经网络都没有涉及,还需要考试,只能给到NPC。《程序设计实践》则是普通的水课,项目开源在[github](https://github.com/jackfiled/Katheryne)上,看上去这个题目也是祖传题目了。
|
||||
|
||||
然后我们可以来谈论一下最激动人心的实践课课组。首先是《物理实验A》,由于是线上教学做实验,直接给到一个拉完了。《计算导论与程序设计课程设计》(不太可能会有“普通人”在大一的下学期就选择《程序设计竞赛基础》吧)是大一下学期开设,课程设计的题目是公交车的调度,对大一新人来说还是挺合适的,这里给到一个人上人。面向对象的程序设计实践我选择的是Java,这个只能说拉完了,Java感觉没学到什么有用的,最后交作业的时候还要求提交一大堆软件工程的UML图。
|
||||
|
||||
《计算机组成原理课程设计》和《数字逻辑与数字系统课程设计》二选一我选择是《计算机组成原理课程设计》,题目是设计一个单周期的CPU,非常好课程直接给到夯!唯一的问题似乎因为小学期时间安排的问题,导致最后只有三四天的时间来完成整个作业,这种课程就应该设计为一个必修实验,而且最好需要设计一个完整的五段流水线CPU。
|
||||
|
||||
《操作系统课程设计》和《编译原理课程设计》二选一我选择了《编译原理课程设计》,这更多是一个历史和“人民”的选择,当时我们课程班100多个人,最后只有一个小组大概8个人选择了《操作系统课程设计》。不过《编译原理课程设计》确实可以给到夯!首先是老师非常不错,然后课程设计的内容也确实可以说是循序渐进。唯一的问题是设计的编译器是从Pascal翻译到C,和编译原理课程本身的衔接也不是特别紧密,尤其是考虑到编译原理课程本身自带的两个实验就是词法分析和语法分析,课程设计还把重点放在前端上就有点不太好了。最好是把题目修改到C到某门汇编语言,例如RISC-V,然后和《编译原理》课程本身的实验衔接设计,课程设计的重点就可以放在代码生成和代码优化上面了。
|
||||
|
||||
至于最后的三选二选修课,在《数据结构课程设计》、《计算机网络课程设计》和《数据库系统原理课程设计》三门中我选择的前两门。《数据结构课程设计》有一个非常奇葩的要求,不能使用自带的数据结构实现(比如说标准库中的列表、哈希表等),而是要求自行实现,但是在最后验收时并没有突出这一点,这里只能给到一个人上人,算是实践课程中比较低的评价了。《计算机网络课程设计》要求设计一个DNS Relay服务器,比较典型的计算机网络课程设计的要求,唯一的问题是要求必须要用C语言完成,只能给到一个顶级。这里我感觉老师做出这个限制的主要理由是希望大家多钻研一下和网络相关的高并发设计,但是直接限制到C语言级别有点强人所难了。
|
||||
|
||||
最后锐评一下我选过的公选课和体育课,在我的培养方案中,我需要选修两门以上的公选课(人文艺术类型)和四门体育课(其中一门必修的《体育基础》)。我为了满足培养方案选修的公选课是《人工智能与社会发展》和《显示技术发展与游戏应用》两门,普通的水课没什么好说的,给到NPC。不过在培养方案之外我还选修了一门《基于Arduino的开源手机设计》,非常好的选修课,课程内容是基于ESP8266设计一个支持2G和Wi-Fi的按键手机。所有上课的同学都可以免费获得一个板子,值得选修,这里给到一个夯。
|
||||
|
||||
至于体育课,因为我本人体育苦手,评价略有失真之处,仅供参考。首先是在**线上**进行的《体育基础》课,直接给到NPC,至于《健美》和《乒乓球》,考虑到我学得不好应该是我的问题,给到一个顶级。至于最后的《太极拳》因为体育苦手的缘故,简直就是我等的福音,给到夯!
|
||||
|
||||
### 实习与工作
|
||||
|
||||
在毕业之后不能躲过的话题自然就是工作,虽然正式的找工作离我还有~~三年~~两年半的时间。尤其是考虑到我们伟大的学校并不打算为保研的同学提供宿舍,在6月被赶出校门之后,一个比较正确的选择就是找一份实习。
|
||||
|
||||
大概从2025年的5月份我就开始在BOSS直聘上找实习,首选的工作意向就是和我现在研究方向相关联的编译器、高性能计算和AI Infra方向,考虑到这些方向的工作岗位数量如同食堂番茄蛋花汤中的蛋花,我也填上了后端方向作为备选。不过找工作真的好痛苦啊:从事后统计来看,在那半个月的时间里我大概投出去了60多份简历,其中只有个位数的HR回复了消息,只约上了一场面试。这便是今天的主角——理想汽车。
|
||||
|
||||
说起来也奇怪,招聘的这个岗位,理想汽车的图编译器开发,并不是我自己找上门去的,而是对方的HR主动要走了我的简历。下面就简单记录一下理想汽车的图编译器开发实习生的面经吧,不过说实话这已经是接近一年之前的事情了,如果有错漏之处还请谅解。
|
||||
|
||||
上来还是自我介绍起手,并介绍自己简历上的一些项目。因为我当时没有任何的实习经历,简历上主要的项目其实是本科的毕业设计。不过因为这个毕业设计题目取得非常高大上,但是实现上非常一坨,所以拷打的过程个人感觉漏洞百出。
|
||||
|
||||
然后是一些基础的编译器知识,比如说你是否知道什么是静态单赋值形式,MLIR中的IR是否是SSA等等,还结合MLIR问了很多MLIR中的细节开发问题,如果你是否定义过Dialect,是否写过operation的parser和printer等等。还有MLIR中的pass分为什么,是否了解这些pass是如何运行的,有一些非常细节的问题我当时就直接回答不知道了(捂脸)。
|
||||
|
||||
中间还拷打了一些`C++`开发的知识,比如说CRTP这种在MLIR中非常常用的模板范式,不过我当时因为对`C++`开发还不是特别熟悉,将中间的`static_cast`说成了`dynamic_cast`,非常的尴尬。面试官还问了问我研究生和本科的主修课程。
|
||||
|
||||
编程的题目是手撕堆排序,但是面试官找了半天都没有找到测试的题目,只让我口述了算法的过程,不过我当时过度紧张(毕竟是人生的第一场面试),什么归并快排桶排堆排的都丢到九霄云外去了,只能阿巴阿巴地说一些建堆、排序之内的车轱辘话,幸好面试官也没有特别纠结这一块。反问的环节我是询问了一下我们工作的对象,理想汽车自研汽车芯片的情况,也就是现在即将发布的马赫100芯片。
|
||||
|
||||
幸好最终的结果还是比较好的,顺利拿到了offer并入职理想。不过当时暑假期间的工资是按照本科生算的,只有230一天,考虑到当时我在北京还需要租房居住,差点付费上班(幸好还有第二份兼职)。而且当时部门正处于芯片即将上车的集中开发阶段,8月份还把我们一起打包送到杭州去上班,血亏一个月房租,~~不过住一个月的酒店还是蛮爽的,谢谢想哥~~。
|
||||
|
||||
不过说来我还是非常感谢在理想的这段实习经历的。在进入理想实习之前,虽然我已经在导师的手下干了一年,但是感觉对于编译器,尤其是这种面向NPU的AI编译器,总感觉还是有一种雾里看花的感觉。但是在实习过程中,实际接触了编译器的开发和优化流程,瞬间感觉过程论文和课本中的编译器活了过来。同样也是在这段经历中,我对于MLIR的了解和认识也是飞速增长。
|
||||
|
||||
在理想的实习经历之外,~~为了赚房租~~为了进一步提高自己的能力,同时在为我在开源世界中开辟一块立锥之地,我报名并参加了2025年的开源之夏(OSPP)项目,中选的题目是[RustSBI原型系统引导生态完善](https://summer-ospp.ac.cn/org/prodetail/256590172?lang=zh&list=pro)。不过这次OSPP的经历我打算单出一篇文章来分享(挖坑x1),这里就不多赘述了。
|
||||
|
||||
### 研究生
|
||||
|
||||
讲道理,在9月份开学之后我就回到明光村幼儿园附属大学继续攻读我的硕士学位了,这段研究生时光也占据了2025年度三分之一的长度。但是我在撰写本年终总结的时候却对于这段时光一点回忆也没有,真是令人感到好奇!
|
||||
|
||||
### 第一台NAS
|
||||
|
||||
上面拖拖拉拉地讲了一堆令人悲伤的话题,还是一转聊一聊一些开心的话题吧。
|
||||
|
||||
经过若干个月的精心筹备,在2025年的一季度,我终于组建了我的个人NAS,虽然这台NAS里面目前只有1块4T 3.5英寸硬盘和几块从笔记本上拆下来的2.5英寸硬盘,显得我精心挑选的8 STAT接口主板和8个3.5英寸硬盘位的机箱显得很呆。不过还是很开心,谁叫组装好硬件就遇上了AI导致的硬件大涨价呢。
|
||||
|
||||

|
||||
|
||||
这里也是简单列一下硬件配置:
|
||||
|
||||
1. CPU:Intel Xeon E3-1245v5
|
||||
2. 内存:镁光DDR4 ECC 16G 2400 x2
|
||||
3. 主板:超微X11SSH-F
|
||||
4. 电源:全汉 蓝暴经典PLUS 450W
|
||||
5. 机箱:联宇 见方L
|
||||
6. CPU散热器:利民AXP90-X36
|
||||
|
||||
这里我也打算后面单独出一篇文章分享设计和配置这个NAS的点点滴滴(挖坑x2),这里就简单吐槽一件令我非常绷不住的事。这里选择E3-1245v5这颗带有核显的CPU简直就是我最大的错误,我设计的时候考虑的是这颗核显可以极大地加快NAS上各种媒体的解码速度。但是E3-1245v5这颗CPU带的核显简直就是垃圾中的垃圾:它不支持H265/HEVC 10 bit的解码,简直就是屁用没用!
|
||||
|
||||
而且,这颗CPU在秋季开学之后就挂掉了,还贡献了我的第一张Linux蓝屏~~扫码即可查看当时的Kernel日志~~:
|
||||
|
||||

|
||||
|
||||
我不得不购入了第二块CPU,也就是现在机器上使用的Intel Xeon E3-1240v5。谢天谢地,这颗CPU到目前还工作正常,而它比它带核显的兄弟足足便宜了三分之二。
|
||||
|
||||
### 第一台台式机
|
||||
|
||||
除了这台,我还在暑假的时候组装了我人生的第一台台式机:
|
||||
|
||||

|
||||
|
||||
这里也简单列举一下这台主机的配置:
|
||||
|
||||
- CPU: AMD 锐龙 5 9600X
|
||||
- 内存:光威龙武弈 DDR5 长鑫颗粒 6000 CL36 16G x2
|
||||
- 主板:华硕TX B650EM WIFI W
|
||||
- GPU:ONIX Intel ARC B580 LUMI 12G
|
||||
- 电源:鑫谷 GM650W 金牌全模组
|
||||
- CPU散热器:九州风神 玄冰400V5 晴雪白
|
||||
- 机箱散热器:酷冷至尊莫比乌斯120白色 x2
|
||||
- 立牌:psplive 2025校园主题亚克力立牌-[李豆沙_Channel](https://space.bilibili.com/1703797642)
|
||||
|
||||
组装这台主机的时间非常巧妙,我是在7月初的时候下定决心组装这台台式机,这几乎是在电子产品大涨价之前最后一个上车的时间窗口了。
|
||||
|
||||
关于我选择的这些配置我也想简单讨论一下。主要的争议点可能是我选择的显卡,当时我的决赛圈里面几乎就是了两张显卡:NVIDIA的RTX 5060和Intel的Arc B580。最终驱动我做出决定的因素主要是预算,RTX 5060的价格基本上都在2500元以上,而我拿下的这块Intel Arc B580只花了我1800元。虽然当时网上对于Intel显卡驱动的批评甚嚣尘上,但是我决定对Intel这家老牌的半导体厂商保持基本的信任(至少到目前为止,Intel还没有辜负我的信任)。而且考虑到我实际上也是一个Linux用户,NVIDIA的Linux驱动是个什么样子是不言自明的,至少Intel的Linux支持情况要好得多。
|
||||
|
||||
而且在我目前的日常游玩的所有游戏中这块显卡都表现正常,例如《三角洲行动》在我目前的2K分辨率下可以取得90~110FPS的水平。《原神》和《戴森球计划》这种对于显卡需求比较低的游戏更是轻松拿下。
|
||||
|
||||
### 玫瑰色生活
|
||||
|
||||
2025年也是一个旅行颇多的年份。
|
||||
|
||||
首先是趁着毕业的时候,去北京一个著名的小众冷门(这是否矛盾?)景点——中央礼品文物管理中心参观,算是一个非常有首都特色的景点了。然后是忙碌的8月份,在北京和杭州之间飞来飞去,很幸运地是在其中的一次坐到了国产大飞机C919前往杭州:
|
||||
|
||||

|
||||
|
||||
还前往了内蒙古的鄂尔多斯参加全国高性能计算学术年会CCF HPC China 2025。学术上的东西在下人微言轻不做评价,不过至少HPC China在茶歇上比之前去参加了CNCC 2024要慷慨的多。其他的内容可以参见同行者的[文章](https://www.chenxutalk.top/posts/life/travel/neimenggu/)。
|
||||
|
||||
正所谓“读万卷书,行万里路”。在2025年还读了下面这几本书:
|
||||
|
||||
- [*GOSICK*](https://mzh.moegirl.org.cn/GOSICK),樱庭一树著,不算是一般意义上的轻小说,而是比较严肃的推理小说,好看!
|
||||
- 《浪潮之巅》,吴军创作的科技产业发展史的书籍,对于计算机相关领域从业者来说可以说是必读的经典书籍了。
|
||||
|
||||
还看了不少的电影:一部是看上去比较冷门的电影《商海通牒》,原名*Margin Call*,记录的是2008年到2009年发生金融危机时一家华尔街投行中发生的故事。另一部则是学校组织放映的《窗外是蓝星》,记录的是神舟十三号乘组首次在中国空间站执行在轨驻留六个月任务的故事。岁月如梭啊,现在已经是神舟二十一号乘组了。
|
||||
|
||||
本来在年终总结的末尾,按照传统还是应该放一下B站的观看时长,但是在撰写本文时B站似乎已经下架了2025年度报告的查看页面了(悲)。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
source/posts/2025-final/IMG_20250301_164145.webp
LFS
Normal file
BIN
source/posts/2025-final/IMG_20250301_164145.webp
LFS
Normal file
Binary file not shown.
BIN
source/posts/2025-final/IMG_20250824_183058.webp
LFS
Normal file
BIN
source/posts/2025-final/IMG_20250824_183058.webp
LFS
Normal file
Binary file not shown.
BIN
source/posts/2025-final/IMG_20251021_160338.webp
LFS
Normal file
BIN
source/posts/2025-final/IMG_20251021_160338.webp
LFS
Normal file
Binary file not shown.
BIN
source/posts/2025-final/IMG_20260506_201126.webp
LFS
Normal file
BIN
source/posts/2025-final/IMG_20260506_201126.webp
LFS
Normal file
Binary file not shown.
BIN
source/posts/2025-final/image-20260503175234105.webp
LFS
Normal file
BIN
source/posts/2025-final/image-20260503175234105.webp
LFS
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
224
source/posts/aspnetcore-swa.md
Normal file
224
source/posts/aspnetcore-swa.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
title: ASP.NET Core中的静态Web资产
|
||||
date: 2026-01-04T16:36:36.5629759+08:00
|
||||
tags:
|
||||
- 技术笔记
|
||||
- dotnet
|
||||
- ASP.NET Core
|
||||
---
|
||||
|
||||
|
||||
Web服务器应该如何扫描与提供静态Web文件,尤其是在考虑到缓存、压缩的情况下,还需要正确的处理开发环境和部署环境之间的差异?让我们来看看ASP.NET Core是如何处理这个问题的。以及如何将通过其他工具(例如`pnpm`)生成的前端资产文件集成到ASP.NET Core中。
|
||||
|
||||
<!--more-->
|
||||
|
||||
### 引言——Blazor开发中的静态Web资源
|
||||
|
||||
Blazor是ASP.NET Core中~~新推出的~~Web应用程序开发框架,通过一系列精巧的设计实现了使用HTML和C#编写运行在浏览器中的应用程序,避免了使用丑陋的JavaScript。但是现代的前端开发生态几乎都建立在JavaScript之上,尤其是考虑在JavaScript在很长的一段时间都是浏览器唯一支持的脚本语言,在Blazor项目开发的过程中必然会遇到一些只能编写JavaScript才能解决的问题。同时,一系列的现代前端工具,例如[tailwindcss](https://tailwindcss.com/),提供了更加优秀的前端开发体验,但是这些都基于NodeJS和NPM等前端工具。以上的前端生态引入了一个问题,如何在MSBuild驱动的Blazor应用构建流程中自然地运行前端工具链和ASP.NET Core支持的服务器部署生成的静态资源?
|
||||
|
||||
Blazor目前提供了一个入口简洁但是功能丰富的静态Web资源提供功能。在使用默认应用目录的情况下,项目将会提供一个`wwwroot`文件夹,这个文件夹中的内容将可以从`/`直接寻址。为了提升前端静态文件的使用体验,该文件夹下的文件将会经过一个复杂的管道:
|
||||
|
||||
- 在构建扫描到这些资产文件之后,MSBuild将会给静态文件加上内容指纹,以防止重复使用旧文件。资源还会被压缩,以减少资产交付的时间。
|
||||
- 在运行时,所发现的资产文件将会作为终结点公开,并添加上合适的缓存头和内容类型头。在设置`ETtag`,`Last-Modified`和`Content-Type`头之后,浏览器将可以合理的缓存这些静态文件直到应用更新。
|
||||
|
||||
该静态文件功能还需要适应应用程序的部署状态:
|
||||
|
||||
- 在开发时,或者说运行`dotnet run`和`dotnet build`时,该功能需要将对应的静态文件终结点URL映射到磁盘上存储的实际静态文件上,就像它们实际上就在`wwwroot`文件夹中一样。考虑到实际上开发过程中会用到Blazor内部的资产文件`blazor.web.js`,引用项目中的资产文件等等,这实际上一个相当复杂的检测-映射流程。
|
||||
- 在发布时,或者说运行`dotnet publish`时,该功能需要收集所有需要的静态文件并复制到最终发布文件夹的`wwwroot`文件夹之下。
|
||||
|
||||
### Microsoft.AspNetCore.ClientAssets
|
||||
|
||||
在默认的应用模板下,如果需要使用其他的现代前端工具生成静态资产文件,最简单的方法就是手动或者编写MSBuild目标(Target)生成资产文件并放在`wwwroot`文件夹中。但是这个方法存在着如下几个问题:
|
||||
|
||||
- 开发者需要编写 MSBuild 目标(targets)来调用他们的工具。
|
||||
- 开发者通常没有在构建过程的恰当时机执行其自定义目标。
|
||||
- 开发者将工具生成的输出文件放入应用的 wwwroot 文件夹中,这会导致这些文件在下一次构建时被误认为是输入文件。
|
||||
- 开发者没有为这些工具正确声明输入和输出依赖,导致即使输出文件已是最新,工具仍会重复运行,从而增加构建耗时。
|
||||
|
||||
面对这些问题,M$提供了一个Alpha状态的库`Microsoft.AspNetCore.ClientAssets`来解决这个问题。不幸的是,这个库已经因为年久失修(上一次[更新](https://github.com/aspnet/AspLabs/pull/572)是在3年前,引入对于.NET 7的支持),在.NET 9引入新的静态资产部署管线之后,使用会直接报错了。
|
||||
|
||||
^^ 相关的Issues链接:[#38445](https://github.com/dotnet/aspnetcore/issues/38445),[#62925](https://github.com/dotnet/aspnetcore/issues/62925)
|
||||
|
||||
为了良好地解决如上的问题,我们需要首先了解一下ASP.NET Core中静态资产文件的构建和部署过程。
|
||||
|
||||
### StaticWebAssetsSdk
|
||||
|
||||
在.NET中,构建静态资产文件的相关代码在[dotnet/sdk](https://github.com/dotnet/sdk)仓库中,称作`StaticWebAssetsSdk`。
|
||||
|
||||
静态 Web 资源会接管应用程序 wwwroot 文件夹中的内容项,并全面管理这些内容。在开发过程中,系统会生成一个 JSON 清单(manifest),其中包含以下信息:
|
||||
|
||||
- 版本号(version number):标识清单格式的版本。
|
||||
|
||||
- 清单内容的哈希值(hash):用于判断清单内容是否发生变化。
|
||||
|
||||
- 库的包 ID(library package id):用于区分当前项目与其他项目所提供的资源。
|
||||
|
||||
- 库的资源基础路径(asset base path):在将其他库的路径应用“发现模式”时,用于确定要添加的基础路径。
|
||||
|
||||
- 清单模式(manifest mode):定义来自特定项目的资源在构建和发布时应如何处理。
|
||||
|
||||
- 相关项目清单及其哈希值的列表:用于判断自清单生成以来,项目引用是否发生变化,或是否有清单被更新。
|
||||
|
||||
- “发现模式”(discovery patterns)列表:用于在清单构建完成后,有选择性地在运行时提供某些资源。例如,可以使用如下模式:
|
||||
|
||||
```json
|
||||
{ "Path": "<Project>/Pages", "BasePath": "_content/Something", "Pattern": "**/*.js" }
|
||||
```
|
||||
|
||||
表示仅提供该目录下扩展名为`js`的文件。如果有人添加了图片或其他文件,它们将不会被提供。(这一点很重要,因为这些文件并不符合任何资源规则,也不会包含在发布输出目录中。)
|
||||
|
||||
- 构建/发布过程中生成的静态 Web 资源列表。
|
||||
|
||||
系统会生成两套清单:**构建清单(build manifest)** 和 **发布清单(publish manifest)**:
|
||||
|
||||
- **构建清单**在构建过程中生成,用于开发阶段,使资源表现得如同它们属于应用程序本身。
|
||||
- **发布清单**在发布过程中生成,记录了发布阶段对资源执行的所有转换操作。
|
||||
|
||||
资源可以在构建阶段或发布阶段定义,并可在任意阶段被标记为“仅构建”或“仅发布”。例如,你可以有两个文件:一个用于开发,一个用于发布,但它们都需要通过相同的 URL 路径提供服务。`service-worker.js` 就是一个典型例子。
|
||||
|
||||
**构建时清单**由项目中发现的资源以及来自被引用项目和包的资源共同组成。
|
||||
**发布清单**则以构建清单为基础,过滤掉仅用于构建的文件,并包含在发布过程中对这些文件执行的所有转换(如链接、打包、压缩等)。
|
||||
|
||||
这种机制使得在发布阶段可以执行如链接(Linking)、打包(Bundling)、压缩(Compression)等优化操作。被引用的项目也会生成自己的发布清单,其内容会在发布过程中与当前项目的清单合并。同时,在发布过程中,我们仍会保留被引用项目的原始构建清单,以便应用程序可以选择忽略被引用项目的发布资源,并对整个依赖传递闭包中的资源执行全局优化。例如,一个类库在发布时可能生成一个压缩后的 JS 包,而主应用可以选择不使用多个独立的包,而是收集所有原始构建阶段的资源,生成一个统一的包。通常情况下,构建清单和发布清单内容相同,除非存在仅在发布阶段才应用的转换。
|
||||
|
||||
每份清单中会列出在构建/发布过程中生成或计算出的所有资源及其属性。这些属性包括:
|
||||
|
||||
- **Identity**:资源的唯一标识(文件的完整路径)。
|
||||
- **SourceType**:资源类型('Discovered', 'Computed', 'Project', 'Package')。
|
||||
- **ContentRoot**:开发阶段资源暴露的原始路径。
|
||||
- **BasePath**:资源暴露的基础路径。
|
||||
- **RelativePath**:资源的相对路径。
|
||||
- **AssetKind**:资源用途('Build', 'Publish', 'All'),由 `CopyToOutputDirectory` / `CopyToPublishDirectory` 推断得出。
|
||||
- **AssetMode**:资源作用范围('CurrentProject', 'Reference', 'All')。
|
||||
- **AssetRole**:资源角色('Primary', 'Related', 'Alternative')。
|
||||
- **AssetMergeSource**:当资源被嵌入到其他 TFM(目标框架)时的来源。
|
||||
- **AssetMergeBehavior**:当同一 TFM 中出现资源冲突时的合并行为。
|
||||
- **RelatedAsset**:当前资源所依赖的主资源的 Identity。
|
||||
- **AssetTraitName**:区分相关或替代资源与主资源的特征名称(如语言、编码格式等)。
|
||||
- **AssetTraitValue**:该特征的具体值。
|
||||
- **CopyToBuildDirectory**:与 Content 项一致(如 PreserveNewest、Always)。
|
||||
- **CopyToPublishDirectory**:与 Content 项一致。
|
||||
- **OriginalItemSpec**:定义该资源的原始项规范。
|
||||
|
||||
关于资源在不同场景下的使用(作为主项目的一部分,或作为被引用项目的一部分),有三种可能的选项:
|
||||
|
||||
- **All**:资源在所有情况下都应被使用。
|
||||
- **Root**:资源仅在当前项目作为主项目构建时使用。
|
||||
- **Reference**:资源仅在当前项目被其他项目引用时使用。
|
||||
|
||||
例如,CSS 隔离(CSS isolation)生成的两个包:
|
||||
|
||||
- `<<Project>>.styles.css` 是 **Root** 资源,仅在作为主项目时使用。
|
||||
- `<<Project>>.lib.bundle.css` 是 **Reference** 资源,仅在被其他项目引用时使用。
|
||||
|
||||
除了上述三种使用模式,项目还需定义其在构建和发布过程中如何处理清单中的文件。对此有三种模式:
|
||||
|
||||
- **Default**:项目在发布时将所有内容复制到发布输出目录,但当被其他项目引用时不做任何操作,而是期望引用方负责处理静态 Web 资源的发布。
|
||||
→ 通常用于类库(class libraries)。
|
||||
- **Root**:项目被视为静态 Web 资源的“根”,即使被引用,其资源也应像主项目一样被处理(例如,不复制传递依赖资源,而只复制 Root 资源)。
|
||||
→ 用于如 Blazor WebAssembly 托管项目(被 ASP.NET Core 主机项目引用,但资源应视为根项目)。
|
||||
- **Isolated**:与 Root 类似,但引用项目完全不知道静态 Web 资源的存在;当前项目会自行在发布时设置处理程序,将资源复制到正确位置。
|
||||
→ 用于如 Blazor 桌面应用,将静态 Web 资源自动纳入 `GetCopyToPublishDirectoryItems`,使引用方无需了解静态 Web 资源机制。
|
||||
|
||||
关于资源类型,静态 Web 资源可分为四类:
|
||||
|
||||
- **Discovered assets**:从项目中已有项(如 Content、None 等)中发现的资源。
|
||||
- **Computed assets**:在构建过程中生成、需要在构建时复制到最终位置的资源。
|
||||
- **Project**:来自被引用项目的资源。当合并被引用项目的清单时,其 Discovered 和 Computed 资源会转换为 Project 类型。
|
||||
- **Package**:来自被引用 NuGet 包的资源。
|
||||
|
||||
关于资源角色(Asset Role),有三种:
|
||||
|
||||
- **Primary(主资源)**:表示可通过其相对路径直接访问的资源。大多数资源属于此类。
|
||||
- **Related(相关资源)**:表示与另一个资源相关,但两者都可通过各自的相对路径独立访问。
|
||||
- **Alternative(替代资源)**:表示是另一个资源的替代形式,例如预压缩版本或不同格式版本。通常应通过与主资源相同的相对路径提供(具体实现由运行时决定)。静态 Web 资源层仅记录这种关系。
|
||||
|
||||
对于 Related 和 Alternative 资源,其 `RelatedAsset` 属性指向其所依赖的主资源。这种依赖链可多层嵌套,以表示一个资源的多种表示形式。静态 Web 资源仅记录这些信息,具体如何使用由 MSBuild 目标决定。
|
||||
|
||||
`AssetTraitName` 和 `AssetTraitValue` 用于区分相关/替代资源与其主资源。例如:
|
||||
|
||||
- 对于全球化程序集,可记录程序集的文化(culture);
|
||||
- 对于压缩资源,可记录编码方式(如 gzip、brotli)。
|
||||
|
||||
下图展示了在构建过程中被调用的MSBuild Target:
|
||||
|
||||

|
||||
|
||||
Sdk提供了一些重要的MSBuild Task供程序员调用:
|
||||
|
||||
- `DefineStaticWebAssets`:该Task扫描提供了一系列候选的资产文件并构建一个*标准化的*静态资产对象;
|
||||
- `DefineStaticWebAssetEndpoints`:该Task以上一个任务输出的静态资产对象为输入,输出每个静态资产文件的Web终结点;
|
||||
|
||||
在构建中过程中`GenerateStaticWebAssetsManifest`和`GenerateStaticWebAssetsDevelopmentManifest`、`GenerateStaticWebAssetEndpointsManifest`等几个任务会产生一个重要的清单文件,这些文件通常存放在*obj*文件夹中,名称为`staticwebassets.*.json`。其中一个较为重要的清单文件是`staticwebassets.development.json`,其存储了所有的静态资产文件和对应的存储目录。这个文件在构建的过程中会被复制到输出目录`bin`中,名称为`$(PackageId).staticwebassets.runtime.json`。这个文件将会在生产模式下被静态资产中间件读取,作为建立静态文件终结点到实际物理文件的索引。这个文件也为需要调试`StaticWebAssetsSdk`的程序员提供了重要的调试信息,是解决ASP.NET Core中静态资产问题的不二法门。
|
||||
|
||||
### 解决方案
|
||||
|
||||
现在已经充分了解了`StaticWebAssetsSdk`,可以来设计在MSBuild中集成前端工具并生成最终静态资产文件的管线了。
|
||||
|
||||
首先来研究过程的步骤,`npm`或者其类似物也使用类似于MSBuild的先还原再构建两步,首先需要安装程序中使用到的包,然后在运行构建指令构建对应的静态文件,构建完成之后还需要将构建产物交给MSBuild中的静态资产处理管线进行进一步的处理。因此设计如下的三个步骤:
|
||||
|
||||
1. `RestoreClientAssets`,这个Target需要运行`npm install`或者类似的指令安装依赖包;
|
||||
2. `BuildClientAssets`,这个Target运行`npm run build`或者类似的指令构建项目;
|
||||
3. `DefineClientAssets`,这个Target调用`DefineStaticWebAssets`等Task声明静态资产文件。
|
||||
|
||||
确定好生成步骤之后,声明一下会在生成过程中会用到的,可以提供给用户自定义的属性。安装和构建的相应软件包肯定是需要提供给用户自定义的。在一般情况下,前端工具链把将静态文件生成到`dist`文件夹中。为了符合MSBuild的惯例,这里将中间静态文件生成到*obj*文件夹下的`ClientAssets`文件夹中。为了实现这一点,构建过程中的指令就需要支持一个指定生成目录的参数,这个参数也作为一个属性暴露给用户可以自定义。这里就形成了下面三个提供给用户自定义的参数。
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<ClientAssetsRestoreCommand Condition="'$(ClientAssesRestoreCommand)' == ''">pnpm install</ClientAssetsRestoreCommand>
|
||||
<ClientAssetsBuildCommand Condition="'$(ClientAssetsBuildCommand)' == ''">pnpm run build</ClientAssetsBuildCommand>
|
||||
<ClientAssetsBuildOutputParameter Condition="'$(ClientAssetsBuildOutputParameter)' == ''">--output</ClientAssetsBuildOutputParameter>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
最终就是完成的构建原始代码了。第一个运行的构建目标`RestoreClientAssets`将会在`DispatchToInnerBuilds`任务运行之后运行,这个目标是MSBuild构建管线中一个不论是针对单架构生成还是多架构生成都只会运行一次的目标,这样在项目需要同时编译到.NET 8和.NET 10的情况下,仍然只会运行前端的安装命令一次。`BuildClientAssets`目标紧接着`RestoreClientAssets`目标的运行而运行,并将所有生成的前端文件添加到`_ClientAssetsBuildOutput`项中。最终的`DefineClientAssets`目标在负责解析项目中的所有静态文件的目标`ResolveWebAssetsConfiguration`运行之前运行,调用`DefineStaticWebAssets`和`DefineStaticWebAssetEndpoints`将前面生成的所有前端静态文件添加到MSBuild的静态文件处理管线中。
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<_RestoreClientAssetsBeforeTargets Condition="'$(TargetFramework)' == ''">DispatchToInnerBuilds</_RestoreClientAssetsBeforeTargets>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="RestoreClientAssets" BeforeTargets="$(_RestoreClientAssetsBeforeTargets)">
|
||||
<Message Importance="high" Text="Running $(ClientAssetsRestoreCommand)"/>
|
||||
<Exec Command="$(ClientAssetsRestoreCommand)"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="BuildClientAssets" DependsOnTargets="RestoreClientAssets" BeforeTargets="AssignTargetPaths">
|
||||
<PropertyGroup>
|
||||
<_ClientAssetsOutputFullPath>$([System.IO.Path]::GetFullPath('$(IntermediateOutputPath)ClientAssets'))</_ClientAssetsOutputFullPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<MakeDir Directories="$(_ClientAssetsOutputFullPath"/>
|
||||
<Exec Command="$(ClientAssetsBuildCommand) -- $(ClientAssetsBuildOutputParameter) $(_ClientAssetsOutputFullPath)"/>
|
||||
|
||||
<ItemGroup>
|
||||
<_ClientAssetsBuildOutput Include="$(IntermediateOutputPath)ClientAssets\**"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<Target Name="DefineClientAssets" AfterTargets="BuildClientAssets" DependsOnTargets="ResolveWebAssetsConfiguration">
|
||||
<ItemGroup>
|
||||
<FileWrites Include="@(_ClientAssetsBuildOutput)"/>
|
||||
</ItemGroup>
|
||||
|
||||
<DefineStaticWebAssets
|
||||
CandidateAssets="@(_ClientAssetsBuildOutput)"
|
||||
SourceId="$(PackageId)"
|
||||
SourceType="Computed"
|
||||
ContentRoot="$(_ClientAssetsOutputFullPath)"
|
||||
BasePath="$(StaticWebAssetBasePath)"
|
||||
>
|
||||
<Output TaskParameter="Assets" ItemName="StaticWebAsset"/>
|
||||
<Output TaskParameter="Assets" ItemName="_ClientAssetsStaticWebAsset"/>
|
||||
</DefineStaticWebAssets>
|
||||
|
||||
<DefineStaticWebAssetEndpoints
|
||||
CandidateAssets="@(_ClientAssetsStaticWebAsset)"
|
||||
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
|
||||
>
|
||||
<Output TaskParameter="Endpoints" ItemName="StaticWebAssetEndpoint" />
|
||||
</DefineStaticWebAssetEndpoints>
|
||||
</Target>
|
||||
```
|
||||
|
||||
为了测试如下的代码,可以在项目中新建一个`Directory.Build.targets`文件,将上述的内容复制进去进行测试,当然别忘了用`<Project>`标签包裹这一切。
|
||||
BIN
source/posts/aspnetcore-swa/image-20251231225433184.webp
LFS
Normal file
BIN
source/posts/aspnetcore-swa/image-20251231225433184.webp
LFS
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user