initial commit
This commit is contained in:
@@ -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>
|
||||
@@ -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" }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user