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
+482
View File
@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
+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
+51
View File
@@ -0,0 +1,51 @@
using lxc_songlist.Models;
using Microsoft.EntityFrameworkCore;
namespace lxc_songlist.Data;
public sealed class SongListDbContext(DbContextOptions<SongListDbContext> options) : DbContext(options)
{
public DbSet<Song> Songs => Set<Song>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<SongTag> SongTags => Set<SongTag>();
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Song>(entity =>
{
entity.Property(song => song.Title).HasMaxLength(200).IsRequired();
entity.Property(song => song.NormalizedTitle).HasMaxLength(200).IsRequired();
entity.HasIndex(song => song.NormalizedTitle).IsUnique();
});
modelBuilder.Entity<Tag>(entity =>
{
entity.Property(tag => tag.Name).HasMaxLength(50).IsRequired();
entity.Property(tag => tag.NormalizedName).HasMaxLength(50).IsRequired();
entity.HasIndex(tag => tag.NormalizedName).IsUnique();
});
modelBuilder.Entity<SongTag>(entity =>
{
entity.HasKey(songTag => new { songTag.SongId, songTag.TagId });
entity
.HasOne(songTag => songTag.Song)
.WithMany(song => song.SongTags)
.HasForeignKey(songTag => songTag.SongId)
.OnDelete(DeleteBehavior.Cascade);
entity
.HasOne(songTag => songTag.Tag)
.WithMany(tag => tag.SongTags)
.HasForeignKey(songTag => songTag.TagId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SiteSetting>(entity =>
{
entity.HasKey(setting => setting.Key);
entity.Property(setting => setting.Key).HasMaxLength(80);
entity.Property(setting => setting.Value).HasMaxLength(500);
});
}
}
+163
View File
@@ -0,0 +1,163 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using lxc_songlist.Data;
#nullable disable
namespace lxc_songlist.Migrations
{
[DbContext(typeof(SongListDbContext))]
[Migration("20260604022832_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("lxc_songlist.Models.SiteSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Key");
b.ToTable("SiteSettings");
});
modelBuilder.Entity("lxc_songlist.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsHidden")
.HasColumnType("boolean");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Songs");
});
modelBuilder.Entity("lxc_songlist.Models.SongTag", b =>
{
b.Property<int>("SongId")
.HasColumnType("integer");
b.Property<int>("TagId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("CreatedByViewer")
.HasColumnType("boolean");
b.HasKey("SongId", "TagId");
b.HasIndex("TagId");
b.ToTable("SongTags");
});
modelBuilder.Entity("lxc_songlist.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("lxc_songlist.Models.SongTag", b =>
{
b.HasOne("lxc_songlist.Models.Song", "Song")
.WithMany("SongTags")
.HasForeignKey("SongId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("lxc_songlist.Models.Tag", "Tag")
.WithMany("SongTags")
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Song");
b.Navigation("Tag");
});
modelBuilder.Entity("lxc_songlist.Models.Song", b =>
{
b.Navigation("SongTags");
});
modelBuilder.Entity("lxc_songlist.Models.Tag", b =>
{
b.Navigation("SongTags");
});
#pragma warning restore 612, 618
}
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace lxc_songlist.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SiteSettings",
columns: table => new
{
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Value = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SiteSettings", x => x.Key);
});
migrationBuilder.CreateTable(
name: "Songs",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
NormalizedTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IsHidden = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Songs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SongTags",
columns: table => new
{
SongId = table.Column<int>(type: "integer", nullable: false),
TagId = table.Column<int>(type: "integer", nullable: false),
CreatedByViewer = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SongTags", x => new { x.SongId, x.TagId });
table.ForeignKey(
name: "FK_SongTags_Songs_SongId",
column: x => x.SongId,
principalTable: "Songs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SongTags_Tags_TagId",
column: x => x.TagId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Songs_NormalizedTitle",
table: "Songs",
column: "NormalizedTitle",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SongTags_TagId",
table: "SongTags",
column: "TagId");
migrationBuilder.CreateIndex(
name: "IX_Tags_NormalizedName",
table: "Tags",
column: "NormalizedName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SiteSettings");
migrationBuilder.DropTable(
name: "SongTags");
migrationBuilder.DropTable(
name: "Songs");
migrationBuilder.DropTable(
name: "Tags");
}
}
}
@@ -0,0 +1,160 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using lxc_songlist.Data;
#nullable disable
namespace lxc_songlist.Migrations
{
[DbContext(typeof(SongListDbContext))]
partial class SongListDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("lxc_songlist.Models.SiteSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Key");
b.ToTable("SiteSettings");
});
modelBuilder.Entity("lxc_songlist.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsHidden")
.HasColumnType("boolean");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Songs");
});
modelBuilder.Entity("lxc_songlist.Models.SongTag", b =>
{
b.Property<int>("SongId")
.HasColumnType("integer");
b.Property<int>("TagId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("CreatedByViewer")
.HasColumnType("boolean");
b.HasKey("SongId", "TagId");
b.HasIndex("TagId");
b.ToTable("SongTags");
});
modelBuilder.Entity("lxc_songlist.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("lxc_songlist.Models.SongTag", b =>
{
b.HasOne("lxc_songlist.Models.Song", "Song")
.WithMany("SongTags")
.HasForeignKey("SongId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("lxc_songlist.Models.Tag", "Tag")
.WithMany("SongTags")
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Song");
b.Navigation("Tag");
});
modelBuilder.Entity("lxc_songlist.Models.Song", b =>
{
b.Navigation("SongTags");
});
modelBuilder.Entity("lxc_songlist.Models.Tag", b =>
{
b.Navigation("SongTags");
});
#pragma warning restore 612, 618
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace lxc_songlist.Models;
public sealed class SiteSetting
{
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+13
View File
@@ -0,0 +1,13 @@
namespace lxc_songlist.Models;
public sealed class Song
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string NormalizedTitle { get; set; } = string.Empty;
public bool IsHidden { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public ICollection<SongTag> SongTags { get; set; } = new List<SongTag>();
}
+13
View File
@@ -0,0 +1,13 @@
namespace lxc_songlist.Models;
public sealed class SongTag
{
public int SongId { get; set; }
public Song Song { get; set; } = null!;
public int TagId { get; set; }
public Tag Tag { get; set; } = null!;
public bool CreatedByViewer { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+11
View File
@@ -0,0 +1,11 @@
namespace lxc_songlist.Models;
public sealed class Tag
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string NormalizedName { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public ICollection<SongTag> SongTags { get; set; } = new List<SongTag>();
}
+7
View File
@@ -0,0 +1,7 @@
namespace lxc_songlist.Services;
public sealed class AdminAuthOptions
{
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
+6
View File
@@ -0,0 +1,6 @@
namespace lxc_songlist.Services;
public sealed class SiteOptions
{
public string RequestTemplate { get; set; } = "点歌《{title}》";
}
+105
View File
@@ -0,0 +1,105 @@
using System.Security.Claims;
using lxc_songlist.Components;
using lxc_songlist.Data;
using lxc_songlist.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/admin/login";
options.AccessDeniedPath = "/admin/login";
options.Cookie.Name = "lxc-songlist-admin";
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
builder.Services.AddMudServices();
builder.Services.Configure<AdminAuthOptions>(builder.Configuration.GetSection("Admin"));
builder.Services.Configure<SiteOptions>(builder.Configuration.GetSection("Site"));
builder.Services.AddDbContextFactory<SongListDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<SongCatalogService>();
builder.Services.AddScoped<SiteSettingsService>();
builder.Services.AddSingleton<AdminPasswordService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapPost("/admin/sign-in", async (
HttpContext httpContext,
AdminPasswordService passwordService) =>
{
var form = await httpContext.Request.ReadFormAsync();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
if (!passwordService.Verify(password))
{
return Results.Redirect("/admin/login?error=1");
}
var claims = new[]
{
new Claim(ClaimTypes.Name, "admin"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddDays(14)
});
return Results.Redirect(IsLocalUrl(returnUrl) ? returnUrl : "/admin");
})
.DisableAntiforgery();
app.MapGet("/admin/logout", async (HttpContext httpContext) =>
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/");
})
.RequireAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
static bool IsLocalUrl(string? url)
{
return !string.IsNullOrWhiteSpace(url)
&& Uri.TryCreate(url, UriKind.Relative, out _)
&& url[0] == '/'
&& (url.Length == 1 || (url[1] != '/' && url[1] != '\\'));
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:0;http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
# 洛星瓷歌单
Blazor Server-style song list site for 洛星瓷, built with .NET 10, MudBlazor, EF Core, and PostgreSQL.
## Local Setup
The sandbox home directory is read-only for .NET first-run files, so local commands here use:
```bash
DOTNET_CLI_HOME=/tmp/dotnet-home
```
Database created during implementation:
- database: `lxc_songlist`
- role: `lxc_songlist`
- development password: `change-me`
Apply migrations:
```bash
DOTNET_CLI_HOME=/tmp/dotnet-home dotnet ef database update
```
Run locally:
```bash
DOTNET_CLI_HOME=/tmp/dotnet-home ASPNETCORE_ENVIRONMENT=Development dotnet run --urls http://127.0.0.1:5000
```
Development admin login:
- URL: `/admin/login`
- password: `lxc-admin`
For production, set `Admin:PasswordHash` or `Admin:Password` and `ConnectionStrings:DefaultConnection` outside source control.
## Current Features
- Chinese-first public song search page.
- Empty PostgreSQL-backed catalog ready for import.
- Song title and tag search.
- Copyable request text, defaulting to `点歌《{title}》`.
- Instant viewer-added tags.
- Single-admin song and tag management.
- Star porcelain MudBlazor theme with responsive desktop/mobile layout.
+54
View File
@@ -0,0 +1,54 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
namespace lxc_songlist.Services;
public sealed class AdminPasswordService(IOptions<AdminAuthOptions> options)
{
private const int SaltBytes = 16;
private const int HashBytes = 32;
private const int Iterations = 210_000;
public bool Verify(string password)
{
var configuredHash = options.Value.PasswordHash;
if (!string.IsNullOrWhiteSpace(configuredHash))
{
return VerifyHash(password, configuredHash);
}
var configuredPassword = options.Value.Password;
return !string.IsNullOrEmpty(configuredPassword)
&& CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(password),
System.Text.Encoding.UTF8.GetBytes(configuredPassword));
}
public static string CreateHash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashBytes);
return $"pbkdf2${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
private static bool VerifyHash(string password, string storedHash)
{
var parts = storedHash.Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2" || !int.TryParse(parts[1], out var iterations))
{
return false;
}
try
{
var salt = Convert.FromBase64String(parts[2]);
var expectedHash = Convert.FromBase64String(parts[3]);
var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
}
catch (FormatException)
{
return false;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
using lxc_songlist.Data;
using lxc_songlist.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace lxc_songlist.Services;
public sealed class SiteSettingsService(
IDbContextFactory<SongListDbContext> dbFactory,
IOptions<SiteOptions> options)
{
public const string RequestTemplateKey = "request_template";
public async Task<string> GetRequestTemplateAsync(CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var setting = await db.SiteSettings.FindAsync([RequestTemplateKey], cancellationToken);
return string.IsNullOrWhiteSpace(setting?.Value)
? options.Value.RequestTemplate
: setting.Value;
}
public async Task SaveRequestTemplateAsync(string template, CancellationToken cancellationToken = default)
{
var cleanTemplate = string.IsNullOrWhiteSpace(template)
? options.Value.RequestTemplate
: template.Trim();
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var setting = await db.SiteSettings.FindAsync([RequestTemplateKey], cancellationToken);
if (setting is null)
{
db.SiteSettings.Add(new SiteSetting
{
Key = RequestTemplateKey,
Value = cleanTemplate,
UpdatedAt = DateTimeOffset.UtcNow
});
}
else
{
setting.Value = cleanTemplate;
setting.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
}
public static string FormatRequest(string template, string title)
{
return template.Replace("{title}", title, StringComparison.OrdinalIgnoreCase);
}
}
+299
View File
@@ -0,0 +1,299 @@
using lxc_songlist.Data;
using lxc_songlist.Models;
using Microsoft.EntityFrameworkCore;
namespace lxc_songlist.Services;
public sealed class SongCatalogService(IDbContextFactory<SongListDbContext> dbFactory)
{
public async Task<IReadOnlyList<SongListItem>> SearchSongsAsync(
string? search,
string? selectedTag,
CancellationToken cancellationToken = default)
{
var queryText = TextNormalizer.Key(search ?? string.Empty);
var tagKey = TextNormalizer.Key(selectedTag ?? string.Empty);
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var query = db.Songs
.AsNoTracking()
.Include(song => song.SongTags)
.ThenInclude(songTag => songTag.Tag)
.Where(song => !song.IsHidden);
if (!string.IsNullOrWhiteSpace(queryText))
{
query = query.Where(song =>
song.NormalizedTitle.Contains(queryText) ||
song.SongTags.Any(songTag => songTag.Tag.NormalizedName.Contains(queryText)));
}
if (!string.IsNullOrWhiteSpace(tagKey))
{
query = query.Where(song => song.SongTags.Any(songTag => songTag.Tag.NormalizedName == tagKey));
}
var songs = await query
.OrderBy(song => song.NormalizedTitle)
.ToListAsync(cancellationToken);
return songs.Select(ToSongListItem).ToList();
}
public async Task<IReadOnlyList<string>> GetVisibleTagsAsync(CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
return await db.Tags
.AsNoTracking()
.Where(tag => tag.SongTags.Any(songTag => !songTag.Song.IsHidden))
.OrderBy(tag => tag.NormalizedName)
.Select(tag => tag.Name)
.ToListAsync(cancellationToken);
}
public async Task AddViewerTagAsync(int songId, string rawTag, CancellationToken cancellationToken = default)
{
var displayTag = TextNormalizer.DisplayText(rawTag);
var tagKey = TextNormalizer.Key(rawTag);
if (displayTag.Length is 0 or > 50)
{
throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var songExists = await db.Songs.AnyAsync(song => song.Id == songId && !song.IsHidden, cancellationToken);
if (!songExists)
{
throw new InvalidOperationException("歌曲不存在或已隐藏。");
}
var tag = await db.Tags.FirstOrDefaultAsync(existing => existing.NormalizedName == tagKey, cancellationToken);
if (tag is null)
{
tag = new Tag { Name = displayTag, NormalizedName = tagKey, CreatedAt = DateTimeOffset.UtcNow };
db.Tags.Add(tag);
}
var alreadyLinked = await db.SongTags.AnyAsync(
songTag => songTag.SongId == songId && songTag.Tag.NormalizedName == tagKey,
cancellationToken);
if (!alreadyLinked)
{
db.SongTags.Add(new SongTag
{
SongId = songId,
Tag = tag,
CreatedByViewer = true,
CreatedAt = DateTimeOffset.UtcNow
});
}
await db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<AdminSongItem>> GetAdminSongsAsync(CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var songs = await db.Songs
.AsNoTracking()
.Include(song => song.SongTags)
.ThenInclude(songTag => songTag.Tag)
.OrderBy(song => song.NormalizedTitle)
.ToListAsync(cancellationToken);
return songs
.Select(song => new AdminSongItem(
song.Id,
song.Title,
song.IsHidden,
song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList()))
.ToList();
}
public async Task CreateSongAsync(string rawTitle, CancellationToken cancellationToken = default)
{
var title = TextNormalizer.DisplayText(rawTitle);
var normalizedTitle = TextNormalizer.Key(title);
if (title.Length is 0 or > 200)
{
throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var exists = await db.Songs.AnyAsync(song => song.NormalizedTitle == normalizedTitle, cancellationToken);
if (exists)
{
throw new InvalidOperationException("这个歌名已经存在。");
}
var now = DateTimeOffset.UtcNow;
db.Songs.Add(new Song
{
Title = title,
NormalizedTitle = normalizedTitle,
CreatedAt = now,
UpdatedAt = now
});
await db.SaveChangesAsync(cancellationToken);
}
public async Task UpdateSongAsync(int songId, string rawTitle, bool isHidden, CancellationToken cancellationToken = default)
{
var title = TextNormalizer.DisplayText(rawTitle);
var normalizedTitle = TextNormalizer.Key(title);
if (title.Length is 0 or > 200)
{
throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var song = await db.Songs.FindAsync([songId], cancellationToken)
?? throw new InvalidOperationException("歌曲不存在。");
var duplicate = await db.Songs.AnyAsync(
existing => existing.Id != songId && existing.NormalizedTitle == normalizedTitle,
cancellationToken);
if (duplicate)
{
throw new InvalidOperationException("这个歌名已经存在。");
}
song.Title = title;
song.NormalizedTitle = normalizedTitle;
song.IsHidden = isHidden;
song.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
public async Task DeleteSongAsync(int songId, CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var song = await db.Songs.FindAsync([songId], cancellationToken);
if (song is null)
{
return;
}
db.Songs.Remove(song);
await db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<AdminTagItem>> GetAdminTagsAsync(CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
return await db.Tags
.AsNoTracking()
.OrderBy(tag => tag.NormalizedName)
.Select(tag => new AdminTagItem(tag.Id, tag.Name, tag.SongTags.Count))
.ToListAsync(cancellationToken);
}
public async Task RenameTagAsync(int tagId, string rawName, CancellationToken cancellationToken = default)
{
var displayName = TextNormalizer.DisplayText(rawName);
var tagKey = TextNormalizer.Key(rawName);
if (displayName.Length is 0 or > 50)
{
throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var tag = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync(existing => existing.Id == tagId, cancellationToken)
?? throw new InvalidOperationException("标签不存在。");
var target = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync(
existing => existing.Id != tagId && existing.NormalizedName == tagKey,
cancellationToken);
if (target is not null)
{
await MoveLinksAndDeleteTagAsync(db, tag, target, cancellationToken);
return;
}
tag.Name = displayName;
tag.NormalizedName = tagKey;
await db.SaveChangesAsync(cancellationToken);
}
public async Task DeleteTagAsync(int tagId, CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var tag = await db.Tags.FindAsync([tagId], cancellationToken);
if (tag is null)
{
return;
}
db.Tags.Remove(tag);
await db.SaveChangesAsync(cancellationToken);
}
public async Task MergeTagAsync(int sourceTagId, string rawTargetName, CancellationToken cancellationToken = default)
{
var targetName = TextNormalizer.DisplayText(rawTargetName);
var targetKey = TextNormalizer.Key(rawTargetName);
if (targetName.Length is 0 or > 50)
{
throw new InvalidOperationException("目标标签长度需要在 1 到 50 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var source = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.Id == sourceTagId, cancellationToken)
?? throw new InvalidOperationException("来源标签不存在。");
var target = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.NormalizedName == targetKey, cancellationToken);
if (target is null)
{
target = new Tag { Name = targetName, NormalizedName = targetKey, CreatedAt = DateTimeOffset.UtcNow };
db.Tags.Add(target);
await db.SaveChangesAsync(cancellationToken);
}
if (source.Id == target.Id)
{
return;
}
await MoveLinksAndDeleteTagAsync(db, source, target, cancellationToken);
}
private static async Task MoveLinksAndDeleteTagAsync(
SongListDbContext db,
Tag source,
Tag target,
CancellationToken cancellationToken)
{
var links = await db.SongTags.Where(link => link.TagId == source.Id).ToListAsync(cancellationToken);
foreach (var link in links)
{
var targetLinkExists = await db.SongTags.AnyAsync(
existing => existing.SongId == link.SongId && existing.TagId == target.Id,
cancellationToken);
if (!targetLinkExists)
{
db.SongTags.Add(new SongTag
{
SongId = link.SongId,
TagId = target.Id,
CreatedByViewer = link.CreatedByViewer,
CreatedAt = link.CreatedAt
});
}
}
db.Tags.Remove(source);
await db.SaveChangesAsync(cancellationToken);
}
private static SongListItem ToSongListItem(Song song)
{
return new SongListItem(
song.Id,
song.Title,
song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList());
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace lxc_songlist.Services;
public sealed record SongListItem(int Id, string Title, IReadOnlyList<string> Tags);
public sealed record AdminSongItem(int Id, string Title, bool IsHidden, IReadOnlyList<string> Tags);
public sealed record AdminTagItem(int Id, string Name, int SongCount);
+19
View File
@@ -0,0 +1,19 @@
using System.Text.RegularExpressions;
namespace lxc_songlist.Services;
public static partial class TextNormalizer
{
public static string DisplayText(string value)
{
return WhitespaceRegex().Replace(value.Trim(), " ");
}
public static string Key(string value)
{
return DisplayText(value).ToLowerInvariant();
}
[GeneratedRegex(@"\s+")]
private static partial Regex WhitespaceRegex();
}
+14
View File
@@ -0,0 +1,14 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5432;Database=lxc_songlist;Username=lxc_songlist;Password=change-me"
},
"Admin": {
"Password": "lxc-admin"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5432;Database=lxc_songlist;Username=lxc_songlist;Password=change-me"
},
"Admin": {
"PasswordHash": "",
"Password": ""
},
"Site": {
"RequestTemplate": "点歌《{title}》"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>lxc_songlist</RootNamespace>
<AssemblyName>$(AssemblyName.Replace(' ', '_'))</AssemblyName>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="9.5.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
</Project>
+387
View File
@@ -0,0 +1,387 @@
:root {
--porcelain-ink: #18344f;
--porcelain-muted: #55718c;
--porcelain-blue: #5b8ecf;
--porcelain-blue-deep: #356fa8;
--porcelain-line: rgba(104, 154, 199, 0.24);
--porcelain-surface: rgba(255, 255, 255, 0.84);
--porcelain-frost: rgba(235, 247, 255, 0.76);
}
html,
body {
min-height: 100%;
color: var(--porcelain-ink);
background:
radial-gradient(circle at 18% 8%, rgba(183, 226, 245, 0.58), transparent 27rem),
radial-gradient(circle at 84% 16%, rgba(212, 233, 248, 0.78), transparent 24rem),
linear-gradient(145deg, #fafdff 0%, #edf7ff 48%, #f8fbff 100%);
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", sans-serif;
}
a {
color: var(--porcelain-blue-deep);
}
h1:focus {
outline: none;
}
.porcelain-main {
position: relative;
min-height: 100vh;
overflow: hidden;
}
.porcelain-main::before {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
content: "";
background-image:
linear-gradient(120deg, rgba(94, 142, 195, 0.07) 1px, transparent 1px),
linear-gradient(30deg, rgba(94, 142, 195, 0.05) 1px, transparent 1px);
background-size: 58px 58px, 74px 74px;
mask-image: linear-gradient(to bottom, black, transparent 92%);
}
.content-shell {
position: relative;
z-index: 1;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 88px 0 56px;
}
.decor {
position: fixed;
z-index: 0;
pointer-events: none;
border: 1px solid rgba(91, 142, 207, 0.2);
background: rgba(255, 255, 255, 0.28);
transform: rotate(45deg);
}
.decor-a {
top: 112px;
right: 9vw;
width: 98px;
height: 98px;
box-shadow: 0 0 34px rgba(148, 205, 232, 0.26);
}
.decor-b {
bottom: 120px;
left: 5vw;
width: 62px;
height: 62px;
}
.page-heading {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
align-items: end;
margin-bottom: 22px;
}
.page-heading.compact {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.page-heading h1,
.page-heading .mud-typography-h1 {
margin-bottom: 6px;
font-size: 2.45rem;
line-height: 1.12;
letter-spacing: 0;
}
.asset-slot {
min-height: 128px;
border: 1px solid var(--porcelain-line);
border-radius: 8px;
color: var(--porcelain-muted);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(227, 244, 253, 0.72)),
repeating-linear-gradient(120deg, transparent 0 12px, rgba(91, 142, 207, 0.08) 12px 13px);
}
.character-slot {
display: grid;
place-items: center;
gap: 4px;
text-align: center;
font-size: 0.9rem;
}
.character-slot .mud-icon-root {
color: var(--porcelain-blue);
font-size: 2rem;
}
.search-panel,
.admin-panel,
.admin-login-panel,
.song-card,
.empty-state {
border: 1px solid var(--porcelain-line);
border-radius: 8px;
background: var(--porcelain-surface);
box-shadow: 0 18px 50px rgba(61, 111, 154, 0.11);
backdrop-filter: blur(16px);
}
.search-panel {
display: grid;
gap: 14px;
margin-bottom: 22px;
padding: 18px;
}
.active-filter,
.tag-cloud,
.song-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.song-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.song-card {
display: grid;
gap: 16px;
padding: 18px;
}
.song-card-main {
min-width: 0;
}
.song-card-main h6,
.song-card-main .mud-typography-h6 {
overflow-wrap: anywhere;
letter-spacing: 0;
}
.song-card-actions,
.viewer-tag-row,
.admin-add-row,
.admin-actions,
.panel-title-row {
display: flex;
gap: 10px;
align-items: center;
}
.viewer-tag-row,
.admin-add-row {
align-items: stretch;
}
.viewer-tag-row .mud-input-control,
.admin-add-row .mud-input-control {
flex: 1 1 auto;
}
.loading-row,
.empty-state {
display: grid;
place-items: center;
gap: 12px;
min-height: 220px;
padding: 28px;
text-align: center;
}
.admin-page,
.song-page {
display: grid;
gap: 18px;
}
.admin-panel {
display: grid;
gap: 16px;
padding: 18px;
}
.panel-title-row {
justify-content: space-between;
}
.admin-list,
.tag-admin-list {
display: grid;
gap: 12px;
}
.admin-list-item,
.tag-admin-item {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid rgba(104, 154, 199, 0.18);
border-radius: 8px;
background: rgba(248, 252, 255, 0.68);
}
.admin-list-item.is-hidden {
opacity: 0.68;
}
.admin-song-tags {
min-height: 32px;
}
.admin-muted {
padding: 12px 0;
}
.admin-login-page {
min-height: calc(100vh - 180px);
display: grid;
place-items: center;
}
.admin-login-panel {
display: grid;
gap: 18px;
width: min(420px, 100%);
padding: 24px;
}
.native-form {
display: grid;
gap: 14px;
}
.native-field {
display: grid;
gap: 7px;
color: var(--porcelain-muted);
font-size: 0.92rem;
}
.native-field input {
min-height: 48px;
width: 100%;
padding: 0 14px;
border: 1px solid rgba(85, 113, 140, 0.44);
border-radius: 8px;
color: var(--porcelain-ink);
background: rgba(255, 255, 255, 0.72);
font: inherit;
}
.native-field input:focus {
outline: 2px solid rgba(91, 142, 207, 0.24);
border-color: var(--porcelain-blue);
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
padding: 1rem;
color: white;
background: #b32121;
}
.blazor-error-boundary::after {
content: "An error has occurred.";
}
#blazor-error-ui {
display: none;
position: fixed;
right: 16px;
bottom: 16px;
z-index: 10000;
max-width: min(420px, calc(100% - 32px));
padding: 12px 16px;
border-radius: 8px;
color: white;
background: #8d2733;
box-shadow: 0 12px 32px rgba(30, 50, 70, 0.24);
}
#blazor-error-ui .reload {
margin-left: 8px;
color: white;
font-weight: 700;
}
#blazor-error-ui .dismiss {
margin-left: 12px;
cursor: pointer;
}
@media (max-width: 900px) {
.content-shell {
width: min(100% - 24px, 720px);
padding-top: 76px;
}
.page-heading,
.page-heading.compact {
grid-template-columns: 1fr;
gap: 14px;
}
.character-slot {
min-height: 72px;
display: none;
}
.song-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.content-shell {
width: min(100% - 18px, 480px);
padding-bottom: 34px;
}
.page-heading h1,
.page-heading .mud-typography-h1 {
font-size: 1.85rem;
}
.search-panel,
.admin-panel,
.song-card,
.admin-login-panel {
padding: 14px;
}
.song-card-actions,
.viewer-tag-row,
.admin-add-row,
.admin-actions {
flex-wrap: wrap;
}
.song-card-actions .mud-button-root,
.admin-actions .mud-button-root {
flex: 1 1 136px;
}
}