feat(admin): 공지사항, FAQ, 블로그 목록 검색 필터 추가 및 블로그 미리보기 탭 탑재, FAQ 순서 조정 기능 구현
This commit is contained in:
@@ -22,14 +22,22 @@
|
|||||||
</MudButton>
|
</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="d-flex pa-4 gap-4 align-center">
|
||||||
|
<MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
@if (announcements is null)
|
@if (announcements is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<MudProgressLinear Indeterminate="true" />
|
||||||
}
|
}
|
||||||
else if (!announcements.Any())
|
else if (!FilteredAnnouncements.Any())
|
||||||
{
|
{
|
||||||
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
|
<div class="pa-6 text-center">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Campaign" Style="font-size:3rem; opacity:.3;" />
|
||||||
|
<MudText Class="mt-2 text-muted">검색 조건에 맞는 공지사항이 없습니다.</MudText>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -45,7 +53,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in announcements)
|
@foreach (var item in FilteredAnnouncements)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@item.Title</td>
|
<td>@item.Title</td>
|
||||||
@@ -86,6 +94,9 @@
|
|||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</MudSimpleTable>
|
||||||
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||||
|
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
|
||||||
|
</MudText>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@@ -94,6 +105,12 @@
|
|||||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Announcement>? announcements;
|
private List<Announcement>? announcements;
|
||||||
|
private string searchQuery = "";
|
||||||
|
|
||||||
|
private IEnumerable<Announcement> FilteredAnnouncements => announcements?
|
||||||
|
.Where(a => string.IsNullOrEmpty(searchQuery) ||
|
||||||
|
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>();
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
|
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<MudForm @ref="form">
|
<MudForm @ref="form">
|
||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
|
||||||
|
|
||||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||||
Variant="Variant.Outlined" Class="mb-4">
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
@@ -32,8 +32,24 @@
|
|||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
|
|
||||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
|
||||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
|
||||||
|
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
|
||||||
|
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
|
||||||
|
</MudTabPanel>
|
||||||
|
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
|
||||||
|
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
|
||||||
|
@if (string.IsNullOrWhiteSpace(model.Content))
|
||||||
|
{
|
||||||
|
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@((MarkupString)model.Content)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
|
||||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ else
|
|||||||
{
|
{
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<MudForm @ref="form">
|
<MudForm @ref="form">
|
||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
|
||||||
|
|
||||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||||
Variant="Variant.Outlined" Class="mb-4">
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
@@ -43,8 +43,24 @@ else
|
|||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
|
|
||||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
|
||||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
|
||||||
|
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
|
||||||
|
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
|
||||||
|
</MudTabPanel>
|
||||||
|
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
|
||||||
|
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
|
||||||
|
@if (string.IsNullOrWhiteSpace(model.Content))
|
||||||
|
{
|
||||||
|
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@((MarkupString)model.Content)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
|
||||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|||||||
@@ -15,14 +15,19 @@
|
|||||||
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="d-flex pa-4 gap-4 align-center">
|
||||||
|
<MudTextField @bind-Value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface mb-4" Elevation="0">
|
<MudPaper Class="admin-surface mb-4" Elevation="0">
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||||
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
|
<MudText Typo="Typo.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
|
||||||
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||||
<Columns>
|
<Columns>
|
||||||
<PropertyColumn Property="x => x.Title" Title="제목" />
|
<PropertyColumn Property="x => x.Title" Title="제목" />
|
||||||
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
||||||
@@ -54,12 +59,18 @@
|
|||||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||||
|
private string searchQuery = "";
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private int currentPage = 1;
|
private int currentPage = 1;
|
||||||
private int totalPages = 1;
|
private int totalPages = 1;
|
||||||
private int totalPosts = 0;
|
private int totalPosts = 0;
|
||||||
private const int PageSize = 20;
|
private const int PageSize = 20;
|
||||||
|
|
||||||
|
private IEnumerable<TaxBaik.Domain.Entities.BlogPost> FilteredPosts => posts?
|
||||||
|
.Where(p => string.IsNullOrEmpty(searchQuery) ||
|
||||||
|
p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))) ?? Enumerable.Empty<TaxBaik.Domain.Entities.BlogPost>();
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
|
|||||||
@@ -22,16 +22,21 @@
|
|||||||
</MudButton>
|
</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="d-flex pa-4 gap-4 align-center">
|
||||||
|
<MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
@if (faqs is null)
|
@if (faqs is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<MudProgressLinear Indeterminate="true" />
|
||||||
}
|
}
|
||||||
else if (!faqs.Any())
|
else if (!FilteredFaqs.Any())
|
||||||
{
|
{
|
||||||
<div class="pa-6 text-center">
|
<div class="pa-6 text-center">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
|
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
|
||||||
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
|
<MudText Class="mt-2 text-muted">검색 조건에 맞는 FAQ가 없습니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -39,7 +44,7 @@
|
|||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:60px;">순서</th>
|
<th style="width:110px;">순서</th>
|
||||||
<th>질문</th>
|
<th>질문</th>
|
||||||
<th style="width:130px;">카테고리</th>
|
<th style="width:130px;">카테고리</th>
|
||||||
<th style="width:90px;">상태</th>
|
<th style="width:90px;">상태</th>
|
||||||
@@ -47,11 +52,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in faqs)
|
@foreach (var item in FilteredFaqs)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center">
|
<td>
|
||||||
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
|
<div class="d-flex align-center justify-start gap-1">
|
||||||
|
<MudText Typo="Typo.body2" Class="mr-2">@item.SortOrder</MudText>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropUp" Size="Size.Small" OnClick="@(() => MoveUpAsync(item))" Style="padding:2px;" Dense="true" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropDown" Size="Size.Small" OnClick="@(() => MoveDownAsync(item))" Style="padding:2px;" Dense="true" />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||||
@@ -77,10 +86,10 @@
|
|||||||
<td>
|
<td>
|
||||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
|
||||||
수정
|
수정
|
||||||
</MudButton>
|
</MudButton>
|
||||||
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
||||||
삭제
|
삭제
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudButtonGroup>
|
</MudButtonGroup>
|
||||||
</td>
|
</td>
|
||||||
@@ -89,7 +98,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</MudSimpleTable>
|
||||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||||
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -99,6 +108,13 @@
|
|||||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Faq>? faqs;
|
private List<Faq>? faqs;
|
||||||
|
private string searchQuery = "";
|
||||||
|
|
||||||
|
private IEnumerable<Faq> FilteredFaqs => faqs?
|
||||||
|
.Where(f => string.IsNullOrEmpty(searchQuery) ||
|
||||||
|
f.Question.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>();
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
@@ -120,7 +136,7 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
faqs = (await FaqClient.GetAllAsync()).ToList();
|
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -129,6 +145,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task MoveUpAsync(Faq item)
|
||||||
|
{
|
||||||
|
if (faqs == null) return;
|
||||||
|
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
|
||||||
|
var index = sorted.IndexOf(item);
|
||||||
|
if (index <= 0) return;
|
||||||
|
|
||||||
|
var prev = sorted[index - 1];
|
||||||
|
var temp = item.SortOrder;
|
||||||
|
item.SortOrder = prev.SortOrder;
|
||||||
|
prev.SortOrder = temp;
|
||||||
|
|
||||||
|
if (item.SortOrder == prev.SortOrder)
|
||||||
|
{
|
||||||
|
prev.SortOrder = item.SortOrder + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FaqClient.UpdateAsync(item.Id, item);
|
||||||
|
await FaqClient.UpdateAsync(prev.Id, prev);
|
||||||
|
Snackbar.Add("순서가 상향되었습니다.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MoveDownAsync(Faq item)
|
||||||
|
{
|
||||||
|
if (faqs == null) return;
|
||||||
|
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
|
||||||
|
var index = sorted.IndexOf(item);
|
||||||
|
if (index < 0 || index >= sorted.Count - 1) return;
|
||||||
|
|
||||||
|
var next = sorted[index + 1];
|
||||||
|
var temp = item.SortOrder;
|
||||||
|
item.SortOrder = next.SortOrder;
|
||||||
|
next.SortOrder = temp;
|
||||||
|
|
||||||
|
if (item.SortOrder == next.SortOrder)
|
||||||
|
{
|
||||||
|
next.SortOrder = item.SortOrder + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FaqClient.UpdateAsync(item.Id, item);
|
||||||
|
await FaqClient.UpdateAsync(next.Id, next);
|
||||||
|
Snackbar.Add("순서가 하향되었습니다.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(Faq item)
|
private async Task DeleteAsync(Faq item)
|
||||||
{
|
{
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
|
|||||||
Reference in New Issue
Block a user