initial commit

This commit is contained in:
2026-06-03 20:24:57 -07:00
commit e2ad15fa64
37 changed files with 3092 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<title>洛星瓷歌单</title>
<meta name="description" content="洛星瓷直播点歌歌单,支持歌名和标签搜索。" />
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["lxc-songlist.styles.css"]" />
<ImportMap />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
@inherits LayoutComponentBase
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout Class="porcelain-app">
<MudAppBar Class="porcelain-appbar" Elevation="0" Dense="true">
<MudButton Href="/" Class="brand-button" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.MusicNote">
洛星瓷歌单
</MudButton>
<MudSpacer />
<MudHidden Breakpoint="Breakpoint.SmAndDown">
<MudButton Href="/" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Search">
搜歌
</MudButton>
<AuthorizeView>
<Authorized>
<MudButton Href="/admin" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.AdminPanelSettings">
管理
</MudButton>
<MudButton Href="/admin/logout" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Logout">
退出
</MudButton>
</Authorized>
<NotAuthorized>
<MudButton Href="/admin/login" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Login">
管理登录
</MudButton>
</NotAuthorized>
</AuthorizeView>
</MudHidden>
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudMenu Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Dense="true">
<MudMenuItem Href="/" Icon="@Icons.Material.Filled.Search">搜歌</MudMenuItem>
<AuthorizeView>
<Authorized>
<MudMenuItem Href="/admin" Icon="@Icons.Material.Filled.AdminPanelSettings">管理</MudMenuItem>
<MudMenuItem Href="/admin/logout" Icon="@Icons.Material.Filled.Logout">退出</MudMenuItem>
</Authorized>
<NotAuthorized>
<MudMenuItem Href="/admin/login" Icon="@Icons.Material.Filled.Login">管理登录</MudMenuItem>
</NotAuthorized>
</AuthorizeView>
</MudMenu>
</MudHidden>
</MudAppBar>
<MudMainContent Class="porcelain-main">
<div class="decor decor-a"></div>
<div class="decor decor-b"></div>
<div class="content-shell">
@Body
</div>
</MudMainContent>
</MudLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
private readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight
{
Primary = "#5b8ecf",
Secondary = "#9fd8ec",
Tertiary = "#d8edf7",
AppbarBackground = "rgba(248, 252, 255, 0.78)",
AppbarText = "#214a72",
Background = "#f6fbff",
Surface = "rgba(255, 255, 255, 0.86)",
DrawerBackground = "#f8fcff",
TextPrimary = "#18344f",
TextSecondary = "#55718c",
Info = "#6baed6",
Success = "#4ea38c",
Warning = "#d89a42",
Error = "#c9566a"
},
LayoutProperties = new LayoutProperties
{
DefaultBorderRadius = "8px"
},
Typography = new Typography
{
Default = new DefaultTypography
{
FontFamily = ["Inter", "Noto Sans SC", "Microsoft YaHei", "sans-serif"]
},
H1 = new H1Typography { FontWeight = "700" },
H2 = new H2Typography { FontWeight = "700" },
H3 = new H3Typography { FontWeight = "700" }
}
};
}
+29
View File
@@ -0,0 +1,29 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
::deep .brand-button {
font-weight: 700;
letter-spacing: 0;
}
::deep .porcelain-appbar {
border-bottom: 1px solid rgba(121, 169, 212, 0.26);
backdrop-filter: blur(18px);
}
+31
View File
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
+157
View File
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
+63
View File
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
+284
View File
@@ -0,0 +1,284 @@
@page "/admin"
@attribute [Authorize]
@inject SongCatalogService Catalog
@inject SiteSettingsService SiteSettings
@inject ISnackbar Snackbar
<PageTitle>歌单管理</PageTitle>
<section class="admin-page">
<div class="page-heading compact">
<div>
<MudText Typo="Typo.h1">歌单管理</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary">添加歌曲、隐藏曲目、清理标签和调整点歌文案。</MudText>
</div>
<MudButton Href="/admin/logout" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Logout">
退出
</MudButton>
</div>
<MudGrid Spacing="3">
<MudItem xs="12" md="7">
<MudPaper Class="admin-panel" Elevation="0">
<div class="panel-title-row">
<MudText Typo="Typo.h5">歌曲</MudText>
<MudChip T="string" Color="Color.Info" Variant="Variant.Outlined">@_songs.Count 首</MudChip>
</div>
<div class="admin-add-row">
<MudTextField T="string"
Value="_newSongTitle"
ValueChanged="@((string value) => _newSongTitle = value)"
Label="新增歌名"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Disabled="@string.IsNullOrWhiteSpace(_newSongTitle)"
OnClick="CreateSong">
添加
</MudButton>
</div>
@if (_songs.Count == 0)
{
<MudText Class="admin-muted" Color="Color.Secondary">还没有歌曲。之后可以从这里手动添加,或直接导入数据库。</MudText>
}
else
{
<div class="admin-list">
@foreach (var song in _songs)
{
<MudPaper Class="@($"admin-list-item {(song.IsHidden ? "is-hidden" : string.Empty)}")" Elevation="0">
<MudTextField T="string"
Value="@song.Title"
ValueChanged="@((string value) => song.Title = value)"
Label="歌名"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudCheckBox T="bool"
Value="@song.IsHidden"
ValueChanged="@((bool value) => song.IsHidden = value)"
Label="隐藏" />
<div class="song-tags admin-song-tags">
@foreach (var tag in song.Tags)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info">@tag</MudChip>
}
</div>
<div class="admin-actions">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="@(() => SaveSong(song))">
保存
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" OnClick="@(() => DeleteSong(song))">
删除
</MudButton>
</div>
</MudPaper>
}
</div>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="5">
<MudPaper Class="admin-panel" Elevation="0">
<MudText Typo="Typo.h5">站点设置</MudText>
<MudTextField T="string"
Value="_requestTemplate"
ValueChanged="@((string value) => _requestTemplate = value)"
Label="点歌文案模板"
HelperText="使用 {title} 代表歌名"
Variant="Variant.Outlined" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveRequestTemplate">
保存设置
</MudButton>
</MudPaper>
<MudPaper Class="admin-panel" Elevation="0">
<div class="panel-title-row">
<MudText Typo="Typo.h5">标签</MudText>
<MudChip T="string" Color="Color.Info" Variant="Variant.Outlined">@_tags.Count 个</MudChip>
</div>
@if (_tags.Count == 0)
{
<MudText Class="admin-muted" Color="Color.Secondary">暂无标签。</MudText>
}
else
{
<div class="tag-admin-list">
@foreach (var tag in _tags)
{
<MudPaper Class="tag-admin-item" Elevation="0">
<MudTextField T="string"
Value="@tag.Name"
ValueChanged="@((string value) => tag.Name = value)"
Label="@($"标签,{tag.SongCount} 首")"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudTextField T="string"
Value="@tag.MergeTarget"
ValueChanged="@((string value) => tag.MergeTarget = value)"
Label="合并到"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<div class="admin-actions">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="@(() => RenameTag(tag))">
改名
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.CallMerge" Disabled="@string.IsNullOrWhiteSpace(tag.MergeTarget)" OnClick="@(() => MergeTag(tag))">
合并
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Variant="Variant.Outlined" OnClick="@(() => DeleteTag(tag))" />
</div>
</MudPaper>
}
</div>
}
</MudPaper>
</MudItem>
</MudGrid>
</section>
@code {
private readonly List<AdminSongState> _songs = [];
private readonly List<AdminTagState> _tags = [];
private string _newSongTitle = string.Empty;
private string _requestTemplate = "点歌《{title}》";
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
var songs = await Catalog.GetAdminSongsAsync();
var tags = await Catalog.GetAdminTagsAsync();
_requestTemplate = await SiteSettings.GetRequestTemplateAsync();
_songs.Clear();
_songs.AddRange(songs.Select(AdminSongState.From));
_tags.Clear();
_tags.AddRange(tags.Select(AdminTagState.From));
}
private async Task CreateSong()
{
await RunAdminAction(async () =>
{
await Catalog.CreateSongAsync(_newSongTitle);
_newSongTitle = string.Empty;
Snackbar.Add("歌曲已添加", Severity.Success);
});
}
private async Task SaveSong(AdminSongState song)
{
await RunAdminAction(async () =>
{
await Catalog.UpdateSongAsync(song.Id, song.Title, song.IsHidden);
Snackbar.Add("歌曲已保存", Severity.Success);
});
}
private async Task DeleteSong(AdminSongState song)
{
await RunAdminAction(async () =>
{
await Catalog.DeleteSongAsync(song.Id);
Snackbar.Add("歌曲已删除", Severity.Success);
});
}
private async Task SaveRequestTemplate()
{
await RunAdminAction(async () =>
{
await SiteSettings.SaveRequestTemplateAsync(_requestTemplate);
Snackbar.Add("设置已保存", Severity.Success);
}, reload: false);
}
private async Task RenameTag(AdminTagState tag)
{
await RunAdminAction(async () =>
{
await Catalog.RenameTagAsync(tag.Id, tag.Name);
Snackbar.Add("标签已更新", Severity.Success);
});
}
private async Task MergeTag(AdminTagState tag)
{
await RunAdminAction(async () =>
{
await Catalog.MergeTagAsync(tag.Id, tag.MergeTarget);
Snackbar.Add("标签已合并", Severity.Success);
});
}
private async Task DeleteTag(AdminTagState tag)
{
await RunAdminAction(async () =>
{
await Catalog.DeleteTagAsync(tag.Id);
Snackbar.Add("标签已删除", Severity.Success);
});
}
private async Task RunAdminAction(Func<Task> action, bool reload = true)
{
try
{
await action();
if (reload)
{
await LoadAsync();
}
}
catch (InvalidOperationException ex)
{
Snackbar.Add(ex.Message, Severity.Warning);
}
}
private sealed class AdminSongState
{
public int Id { get; init; }
public string Title { get; set; } = string.Empty;
public bool IsHidden { get; set; }
public IReadOnlyList<string> Tags { get; init; } = [];
public static AdminSongState From(AdminSongItem item)
{
return new AdminSongState
{
Id = item.Id,
Title = item.Title,
IsHidden = item.IsHidden,
Tags = item.Tags
};
}
}
private sealed class AdminTagState
{
public int Id { get; init; }
public string Name { get; set; } = string.Empty;
public string MergeTarget { get; set; } = string.Empty;
public int SongCount { get; init; }
public static AdminTagState From(AdminTagItem item)
{
return new AdminTagState
{
Id = item.Id,
Name = item.Name,
SongCount = item.SongCount
};
}
}
}
+41
View File
@@ -0,0 +1,41 @@
@page "/admin/login"
@inject NavigationManager Navigation
<PageTitle>管理登录</PageTitle>
<section class="admin-login-page">
<MudPaper Class="admin-login-panel" Elevation="0">
<MudText Typo="Typo.h4">管理登录</MudText>
<MudText Color="Color.Secondary">输入管理员密码后可以维护歌单和标签。</MudText>
@if (Error == "1")
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">密码不正确。</MudAlert>
}
<form method="post" action="/admin/sign-in" class="native-form">
<input type="hidden" name="returnUrl" value="@ResolvedReturnUrl" />
<label class="native-field">
<span>管理员密码</span>
<input type="password" name="password" required autocomplete="current-password" />
</label>
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Login"
FullWidth="true">
登录
</MudButton>
</form>
</MudPaper>
</section>
@code {
[SupplyParameterFromQuery(Name = "error")]
public string? Error { get; set; }
[SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; }
private string ResolvedReturnUrl => string.IsNullOrWhiteSpace(ReturnUrl) ? "/admin" : ReturnUrl;
}
+36
View File
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
+201
View File
@@ -0,0 +1,201 @@
@page "/"
@inject SongCatalogService Catalog
@inject SiteSettingsService SiteSettings
@inject IJSRuntime Js
@inject ISnackbar Snackbar
<PageTitle>洛星瓷歌单</PageTitle>
<section class="song-page">
<div class="page-heading">
<div>
<MudText Typo="Typo.h1">洛星瓷歌单</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary">快速查找会唱的歌,复制点歌文案,也可以给歌曲补充标签。</MudText>
</div>
<MudPaper Class="asset-slot character-slot" Elevation="0">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" />
<span>角色图占位</span>
</MudPaper>
</div>
<MudPaper Class="search-panel" Elevation="0">
<MudTextField T="string"
Value="_search"
ValueChanged="OnSearchChanged"
Immediate="true"
DebounceInterval="180"
Placeholder="搜索歌名或标签"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Clearable="true" />
@if (_selectedTag is not null)
{
<div class="active-filter">
<MudChip T="string" Color="Color.Primary" Variant="Variant.Filled" OnClose="ClearTagFilter">
标签:@_selectedTag
</MudChip>
</div>
}
@if (_allTags.Count > 0)
{
<div class="tag-cloud" aria-label="标签筛选">
@foreach (var tag in _allTags.Take(32))
{
<MudChip T="string"
Color="@(_selectedTag == tag ? Color.Primary : Color.Info)"
Variant="@(_selectedTag == tag ? Variant.Filled : Variant.Outlined)"
OnClick="@(() => SelectTag(tag))">
@tag
</MudChip>
}
</div>
}
</MudPaper>
@if (_isLoading)
{
<div class="loading-row">
<MudProgressCircular Indeterminate="true" Color="Color.Primary" />
</div>
}
else if (_songs.Count == 0)
{
<MudPaper Class="empty-state" Elevation="0">
<MudIcon Icon="@Icons.Material.Filled.LibraryMusic" Size="Size.Large" />
<MudText Typo="Typo.h6">歌单还是空的</MudText>
<MudText Color="Color.Secondary">之后导入歌曲后,这里会显示可搜索的曲目。</MudText>
</MudPaper>
}
else
{
<div class="song-grid">
@foreach (var song in _songs)
{
<MudPaper Class="song-card" Elevation="0">
<div class="song-card-main">
<MudText Typo="Typo.h6">@song.Title</MudText>
<div class="song-tags">
@foreach (var tag in song.Tags)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info" OnClick="@(() => SelectTag(tag))">
@tag
</MudChip>
}
</div>
</div>
<div class="song-card-actions">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ContentCopy"
OnClick="@(() => CopyRequest(song.Title))">
复制点歌
</MudButton>
</div>
<div class="viewer-tag-row">
<MudTextField T="string"
Value="@song.NewTag"
ValueChanged="@((string value) => song.NewTag = value)"
Placeholder="添加标签"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
<MudIconButton Icon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Variant="Variant.Filled"
Disabled="@string.IsNullOrWhiteSpace(song.NewTag)"
OnClick="@(() => AddTag(song))" />
</div>
</MudPaper>
}
</div>
}
</section>
@code {
private readonly List<SongCardState> _songs = [];
private readonly List<string> _allTags = [];
private string _search = string.Empty;
private string? _selectedTag;
private string _requestTemplate = "点歌《{title}》";
private bool _isLoading = true;
protected override async Task OnInitializedAsync()
{
_requestTemplate = await SiteSettings.GetRequestTemplateAsync();
await LoadAsync();
}
private async Task OnSearchChanged(string value)
{
_search = value;
await LoadAsync();
}
private async Task SelectTag(string tag)
{
_selectedTag = _selectedTag == tag ? null : tag;
await LoadAsync();
}
private async Task ClearTagFilter()
{
_selectedTag = null;
await LoadAsync();
}
private async Task LoadAsync()
{
_isLoading = true;
var songs = await Catalog.SearchSongsAsync(_search, _selectedTag);
var tags = await Catalog.GetVisibleTagsAsync();
_songs.Clear();
_songs.AddRange(songs.Select(SongCardState.From));
_allTags.Clear();
_allTags.AddRange(tags);
_isLoading = false;
}
private async Task CopyRequest(string title)
{
var request = SiteSettingsService.FormatRequest(_requestTemplate, title);
await Js.InvokeVoidAsync("navigator.clipboard.writeText", request);
Snackbar.Add($"已复制:{request}", Severity.Success);
}
private async Task AddTag(SongCardState song)
{
try
{
await Catalog.AddViewerTagAsync(song.Id, song.NewTag);
Snackbar.Add("标签已添加", Severity.Success);
await LoadAsync();
}
catch (InvalidOperationException ex)
{
Snackbar.Add(ex.Message, Severity.Warning);
}
}
private sealed class SongCardState
{
public int Id { get; init; }
public string Title { get; init; } = string.Empty;
public IReadOnlyList<string> Tags { get; init; } = [];
public string NewTag { get; set; } = string.Empty;
public static SongCardState From(SongListItem item)
{
return new SongCardState
{
Id = item.Id,
Title = item.Title,
Tags = item.Tags
};
}
}
}
+8
View File
@@ -0,0 +1,8 @@
@page "/not-found"
@layout MainLayout
<MudPaper Class="empty-state" Elevation="0">
<MudIcon Icon="@Icons.Material.Filled.TravelExplore" Size="Size.Large" />
<MudText Typo="Typo.h5">没有找到这个页面</MudText>
<MudButton Href="/" Variant="Variant.Filled" Color="Color.Primary">回到歌单</MudButton>
</MudPaper>
+12
View File
@@ -0,0 +1,12 @@
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>
+9
View File
@@ -0,0 +1,9 @@
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(Navigation.ToBaseRelativePath(Navigation.Uri));
Navigation.NavigateTo($"/admin/login?returnUrl=/{returnUrl}", forceLoad: true);
}
}
+16
View File
@@ -0,0 +1,16 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using lxc_songlist
@using lxc_songlist.Components
@using lxc_songlist.Components.Layout
@using lxc_songlist.Components.Shared
@using lxc_songlist.Services