XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Net;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Microsoft.Win32;
using XFEToolBox.Client.Utilities;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Core.Downloads;

namespace XFEToolBox.Client.Views.Pages.Admin;

public partial class SoftwareManagementPage : Page
{
    public static SoftwareManagementPage Current { get; } = new();

    private static readonly SemaphoreSlim IconLoadGate = new(4);
    private readonly ObservableCollection<SoftwareCatalogItem> _software = [];
    private readonly ObservableCollection<SoftwareCatalogRow> _softwareRows = [];
    private readonly ObservableCollection<EditableChannel> _channels = [];
    private readonly ObservableCollection<string> _selectedTags = [];
    private readonly ObservableCollection<string> _categorySuggestions = [];
    private readonly ObservableCollection<string> _tagSuggestions = [];
    private readonly ObservableCollection<string> _categoryFilters = [];
    private ICollectionView? _softwareView;
    private string? _editingSoftwareId;
    private bool _isCreating;
    private bool _pageInitialized;

    public SoftwareManagementPage()
    {
        InitializeComponent();

        _softwareView = CollectionViewSource.GetDefaultView(_softwareRows);
        _softwareView.Filter = FilterSoftware;
        SoftwareList.ItemsSource = _softwareView;
        ChannelList.ItemsSource = _channels;
        SelectedTagsItems.ItemsSource = _selectedTags;
        CategorySuggestionItems.ItemsSource = _categorySuggestions;
        TagSuggestionItems.ItemsSource = _tagSuggestions;

        CategoryFilterBox.ItemsSource = _categoryFilters;
        _categoryFilters.Add(AllCategoriesFilter);
        CategoryFilterBox.SelectedIndex = 0;

        StatusFilterBox.ItemsSource = new[]
        {
            new FilterOption("全部状态", "all"),
            new FilterOption("已发布", "published"),
            new FilterOption("未发布", "unpublished"),
            new FilterOption("已停用", "disabled"),
            new FilterOption("精选", "featured")
        };
        StatusFilterBox.SelectedIndex = 0;

        ChannelModeBox.ItemsSource = new[]
        {
            new ChannelModeOption("浏览器打开", SoftwareDownloadMode.Browser),
            new ChannelModeOption("直接下载", SoftwareDownloadMode.Direct),
            new ChannelModeOption("服务器文件", SoftwareDownloadMode.Server)
        };

        UpdateChannelEditorState();
        _pageInitialized = true;
        UpdateCatalogCount();
    }

    private const string AllCategoriesFilter = "全部分类";

    private async void Page_Loaded(object sender, RoutedEventArgs e)
    {
        if (_software.Count == 0)
            await RefreshAsync();
    }

    private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await RefreshAsync();

    private async Task RefreshAsync(string? reloadEditorId = null)
    {
        StatusText.Text = "正在读取软件目录…";
        try
        {
            var response = await ClientSession.Requester.Request<SoftwareCatalogItem[]>("adminSoftware");
            if (response.StatusCode != HttpStatusCode.OK || response.Result is null)
            {
                StatusText.Text = response.Message;
                return;
            }

            _software.Clear();
            _softwareRows.Clear();
            foreach (var item in response.Result.OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase))
            {
                _software.Add(item);
                _softwareRows.Add(new SoftwareCatalogRow(item, GetBundledIcon(item.Id)));
            }

            RebuildSuggestionSources();
            RebuildCategoryFilters();
            _softwareView?.Refresh();
            UpdateCatalogCount();

            StatusText.Text = $"已加载 {_software.Count} 个软件。";
            if (reloadEditorId is not null)
            {
                var refreshedItem = _software.FirstOrDefault(item =>
                    item.Id.Equals(reloadEditorId, StringComparison.OrdinalIgnoreCase));
                if (refreshedItem is not null)
                    OpenEditor(refreshedItem);
                else
                    ShowCatalog();
            }
        }
        catch (Exception exception)
        {
            StatusText.Text = $"读取失败:{exception.Message}";
        }
    }

    private void RebuildSuggestionSources()
    {
        ReplaceValues(
            _categorySuggestions,
            _software.Select(item => item.Category));
        RefreshTagSuggestions();
    }

    private void RebuildCategoryFilters()
    {
        var previous = CategoryFilterBox.SelectedItem as string ?? AllCategoriesFilter;
        _categoryFilters.Clear();
        _categoryFilters.Add(AllCategoriesFilter);
        foreach (var category in _categorySuggestions)
            _categoryFilters.Add(category);
        CategoryFilterBox.SelectedItem = _categoryFilters.FirstOrDefault(value =>
            value.Equals(previous, StringComparison.OrdinalIgnoreCase)) ?? AllCategoriesFilter;
    }

    private void RefreshTagSuggestions()
    {
        ReplaceValues(
            _tagSuggestions,
            _software.SelectMany(item => item.Tags ?? [])
                .Where(tag => !_selectedTags.Contains(tag, StringComparer.OrdinalIgnoreCase)));
        TagSuggestionEmptyText.Visibility = _tagSuggestions.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
    }

    private static void ReplaceValues(ObservableCollection<string> target, IEnumerable<string> source)
    {
        target.Clear();
        foreach (var value in source
                     .Where(value => !string.IsNullOrWhiteSpace(value))
                     .Select(value => value.Trim())
                     .Distinct(StringComparer.OrdinalIgnoreCase)
                     .OrderBy(value => value, StringComparer.CurrentCultureIgnoreCase))
            target.Add(value);
    }

    private bool FilterSoftware(object candidate)
    {
        if (candidate is not SoftwareCatalogRow row)
            return false;

        var item = row.Item;
        var query = SearchTextBox.Text.Trim();
        if (query.Length > 0 &&
            !Contains(item.Name, query) &&
            !Contains(item.Id, query) &&
            !Contains(item.Summary, query) &&
            !Contains(item.Publisher, query) &&
            !Contains(item.Category, query) &&
            !(item.Tags ?? []).Any(tag => Contains(tag, query)))
            return false;

        if (CategoryFilterBox.SelectedItem is string category &&
            !category.Equals(AllCategoriesFilter, StringComparison.OrdinalIgnoreCase) &&
            !item.Category.Equals(category, StringComparison.OrdinalIgnoreCase))
            return false;

        return (StatusFilterBox.SelectedItem as FilterOption)?.Value switch
        {
            "published" => item.Published && item.Enabled,
            "unpublished" => !item.Published,
            "disabled" => !item.Enabled,
            "featured" => item.Featured,
            _ => true
        };
    }

    private static bool Contains(string? value, string query) =>
        value?.Contains(query, StringComparison.CurrentCultureIgnoreCase) == true;

    private void CatalogSearchTextBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyCatalogFilter();

    private void CatalogFilterBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => ApplyCatalogFilter();

    private void ApplyCatalogFilter()
    {
        if (!_pageInitialized)
            return;

        _softwareView?.Refresh();
        UpdateCatalogCount();
    }

    private void ClearFiltersButton_Click(object sender, RoutedEventArgs e)
    {
        SearchTextBox.Text = string.Empty;
        CategoryFilterBox.SelectedIndex = 0;
        StatusFilterBox.SelectedIndex = 0;
        _softwareView?.Refresh();
        UpdateCatalogCount();
    }

    private void UpdateCatalogCount()
    {
        if (!_pageInitialized)
            return;

        var filteredCount = _softwareView?.Cast<object>().Count() ?? 0;
        CatalogSummaryText.Text = $"{_software.Count} 个软件";
        FilteredCountText.Text = filteredCount == _software.Count
            ? $"共 {_software.Count} 项"
            : $"显示 {filteredCount} / {_software.Count} 项";
        CatalogEmptyState.Visibility = filteredCount == 0 ? Visibility.Visible : Visibility.Collapsed;
    }

    private void SoftwareCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        if (sender is Border { Tag: SoftwareCatalogRow row })
        {
            OpenEditor(row.Item);
            e.Handled = true;
        }
    }

    private async void SoftwareCard_Loaded(object sender, RoutedEventArgs e)
    {
        if (sender is Border { DataContext: SoftwareCatalogRow row } && row.TryBeginIconLoad())
            await LoadConfiguredIconAsync(row);
    }

    private static async Task LoadConfiguredIconAsync(SoftwareCatalogRow row)
    {
        await IconLoadGate.WaitAsync();
        try
        {
            var icon = await WebImageSourceLoader.LoadAsync(row.Item.IconUrl);
            if (icon is not null)
                row.IconSource = icon;
        }
        catch
        {
            // 配置图标不可用时继续显示内置图标或首字母占位。
        }
        finally
        {
            IconLoadGate.Release();
        }
    }

    private static ImageSource? GetBundledIcon(string id)
    {
        var resource = id.ToLowerInvariant() switch
        {
            "steam" => "/Resources/Image/DownloadImage/steam_logo.png",
            "watt-toolkit" or "steampp" => "/Resources/Image/DownloadImage/steampp.png",
            "visual-studio" => "/Resources/Image/DownloadImage/visual_studio.png",
            "cheat-engine" => "/Resources/Image/DownloadImage/cheat_engine.png",
            _ => null
        };
        if (resource is null)
            return null;

        var image = new BitmapImage(new Uri($"pack://application:,,,{resource}", UriKind.Absolute));
        image.Freeze();
        return image;
    }

    private void NewButton_Click(object sender, RoutedEventArgs e)
    {
        _isCreating = true;
        _editingSoftwareId = null;
        ClearForm();
        ShowEditor();
        IdTextBox.Focus();
        StatusText.Text = "请填写软件信息并至少保留一个下载渠道。";
    }

    private void OpenEditor(SoftwareCatalogItem item)
    {
        _isCreating = false;
        _editingSoftwareId = item.Id;
        LoadForm(item);
        ShowEditor();
    }

    private void ShowEditor()
    {
        CatalogView.Visibility = Visibility.Collapsed;
        EditorView.Visibility = Visibility.Visible;
        CatalogHeaderActions.Visibility = Visibility.Collapsed;
        EditorHeaderActions.Visibility = Visibility.Visible;
        HeaderBackButton.Visibility = Visibility.Visible;
        HeaderIcon.Visibility = Visibility.Collapsed;
        HeaderTitleText.Text = _isCreating ? "新建软件" : "编辑软件";
        HeaderSubtitleText.Text = _isCreating
            ? "创建软件资料并配置首个下载渠道"
            : "维护基础信息、目录状态和下载渠道";
        UpdateEditorHeader();
    }

    private void ShowCatalog()
    {
        CategorySuggestionPopup.IsOpen = false;
        TagSuggestionPopup.IsOpen = false;
        EditorView.Visibility = Visibility.Collapsed;
        CatalogView.Visibility = Visibility.Visible;
        EditorHeaderActions.Visibility = Visibility.Collapsed;
        CatalogHeaderActions.Visibility = Visibility.Visible;
        HeaderBackButton.Visibility = Visibility.Collapsed;
        HeaderIcon.Visibility = Visibility.Visible;
        HeaderTitleText.Text = "软件发布与管理";
        HeaderSubtitleText.Text = "浏览软件目录,选择条目后进入独立编辑界面";
        _editingSoftwareId = null;
        _isCreating = false;
        _softwareView?.Refresh();
        UpdateCatalogCount();
    }

    private void BackButton_Click(object sender, RoutedEventArgs e) => ShowCatalog();

    private void ClearForm()
    {
        IdTextBox.IsReadOnly = false;
        IdTextBox.Text = string.Empty;
        NameTextBox.Text = string.Empty;
        SummaryTextBox.Text = string.Empty;
        DescriptionTextBox.Text = string.Empty;
        PublisherTextBox.Text = string.Empty;
        CategoryTextBox.Text = string.Empty;
        VersionTextBox.Text = "最新版";
        WebsiteTextBox.Text = string.Empty;
        IconTextBox.Text = string.Empty;
        NoticeTextBox.Text = string.Empty;
        EnabledCheckBox.IsChecked = true;
        PublishedCheckBox.IsChecked = false;
        FeaturedCheckBox.IsChecked = false;
        DeleteButton.IsEnabled = false;
        PublicationButton.IsEnabled = false;

        _selectedTags.Clear();
        _channels.Clear();
        _channels.Add(new EditableChannel(new SoftwareDownloadChannel
        {
            Id = "official",
            Name = "官网",
            Mode = SoftwareDownloadMode.Browser
        }));
        ChannelList.SelectedIndex = 0;
        RefreshTagSuggestions();
        UpdateChannelEditorState();
    }

    private void LoadForm(SoftwareCatalogItem item)
    {
        IdTextBox.IsReadOnly = true;
        IdTextBox.Text = item.Id;
        NameTextBox.Text = item.Name;
        SummaryTextBox.Text = item.Summary;
        DescriptionTextBox.Text = item.Description;
        PublisherTextBox.Text = item.Publisher;
        CategoryTextBox.Text = item.Category;
        VersionTextBox.Text = item.Version;
        WebsiteTextBox.Text = item.WebsiteUrl;
        IconTextBox.Text = item.IconUrl;
        NoticeTextBox.Text = item.Notice;
        EnabledCheckBox.IsChecked = item.Enabled;
        PublishedCheckBox.IsChecked = item.Published;
        FeaturedCheckBox.IsChecked = item.Featured;
        DeleteButton.IsEnabled = true;
        PublicationButton.IsEnabled = true;

        _selectedTags.Clear();
        foreach (var tag in item.Tags ?? [])
            AddTag(tag);

        _channels.Clear();
        foreach (var channel in item.GetEffectiveChannels())
            _channels.Add(new EditableChannel(channel));
        ChannelList.SelectedIndex = _channels.Count == 0 ? -1 : 0;
        RefreshTagSuggestions();
        UpdateChannelEditorState();
        UpdateEditorHeader();
    }

    private void UpdateEditorHeader()
    {
        EditorTitleText.Text = _isCreating
            ? "创建软件条目"
            : string.IsNullOrWhiteSpace(NameTextBox.Text) ? "编辑软件" : NameTextBox.Text;
        EditorSubtitleText.Text = _isCreating
            ? "填写基础资料、标签与下载渠道后保存"
            : "修改后保存,下载专区将在下一次刷新时获取最新信息";
        EditorIdBadgeText.Text = _isCreating ? "尚未保存" : IdTextBox.Text;
        EditorModeBadgeText.Text = _isCreating ? "新建模式" : "编辑现有条目";
    }

    private void CategoryAddButton_Click(object sender, RoutedEventArgs e)
    {
        CategorySuggestionEmptyText.Visibility = _categorySuggestions.Count == 0
            ? Visibility.Visible
            : Visibility.Collapsed;
        CategorySuggestionPopup.IsOpen = true;
    }

    private void CategorySuggestionButton_Click(object sender, RoutedEventArgs e)
    {
        if (sender is Button { Tag: string category })
        {
            CategoryTextBox.Text = category;
            CategorySuggestionPopup.IsOpen = false;
            CategoryTextBox.Focus();
        }
    }

    private void TagAddButton_Click(object sender, RoutedEventArgs e)
    {
        RefreshTagSuggestions();
        CustomTagTextBox.Text = string.Empty;
        TagValidationText.Text = string.Empty;
        TagSuggestionPopup.IsOpen = true;
        _ = Dispatcher.InvokeAsync(() => CustomTagTextBox.Focus());
    }

    private void TagSuggestionButton_Click(object sender, RoutedEventArgs e)
    {
        if (sender is Button { Tag: string tag })
        {
            AddTag(tag);
            RefreshTagSuggestions();
        }
    }

    private void AddCustomTagButton_Click(object sender, RoutedEventArgs e)
    {
        var tag = CustomTagTextBox.Text.Trim();
        var error = ValidateTag(tag);
        if (error is not null)
        {
            TagValidationText.Text = error;
            CustomTagTextBox.Focus();
            return;
        }

        AddTag(tag);
        CustomTagTextBox.Text = string.Empty;
        TagValidationText.Text = string.Empty;
        RefreshTagSuggestions();
        CustomTagTextBox.Focus();
    }

    private string? ValidateTag(string tag)
    {
        if (tag.Length == 0)
            return "请输入标签名称。";
        if (tag.Length > 40)
            return "标签名称不能超过 40 个字符。";
        if (tag.IndexOfAny([',', ',', ';', ';', '\r', '\n']) >= 0)
            return "一次只能添加一个标签。";
        if (_selectedTags.Contains(tag, StringComparer.OrdinalIgnoreCase))
            return "这个标签已经添加。";
        return null;
    }

    private void AddTag(string tag)
    {
        var value = tag.Trim();
        if (value.Length == 0 || _selectedTags.Contains(value, StringComparer.OrdinalIgnoreCase))
            return;
        _selectedTags.Add(value);
    }

    private void RemoveTagButton_Click(object sender, RoutedEventArgs e)
    {
        if (sender is not Button { Tag: string tag })
            return;
        var existing = _selectedTags.FirstOrDefault(value => value.Equals(tag, StringComparison.OrdinalIgnoreCase));
        if (existing is not null)
            _selectedTags.Remove(existing);
        RefreshTagSuggestions();
    }

    private void AddChannelButton_Click(object sender, RoutedEventArgs e)
    {
        var index = _channels.Count + 1;
        string id;
        do id = $"channel-{index++}";
        while (_channels.Any(channel => channel.Id.Equals(id, StringComparison.OrdinalIgnoreCase)));

        var channel = new EditableChannel(new SoftwareDownloadChannel
        {
            Id = id,
            Name = $"渠道 {_channels.Count + 1}",
            Mode = SoftwareDownloadMode.Browser
        });
        _channels.Add(channel);
        ChannelList.SelectedItem = channel;
        ChannelList.ScrollIntoView(channel);
        UpdateChannelEditorState();
    }

    private void RemoveChannelButton_Click(object sender, RoutedEventArgs e)
    {
        if (ChannelList.SelectedItem is not EditableChannel channel)
            return;
        if (_channels.Count == 1)
        {
            StatusText.Text = "每个软件至少需要一个下载渠道。";
            return;
        }

        var index = _channels.IndexOf(channel);
        _channels.Remove(channel);
        ChannelList.SelectedIndex = Math.Min(index, _channels.Count - 1);
        UpdateChannelEditorState();
    }

    private void MoveChannelUpButton_Click(object sender, RoutedEventArgs e) => MoveSelectedChannel(-1);

    private void MoveChannelDownButton_Click(object sender, RoutedEventArgs e) => MoveSelectedChannel(1);

    private void MoveSelectedChannel(int offset)
    {
        if (ChannelList.SelectedItem is not EditableChannel channel)
            return;
        var oldIndex = _channels.IndexOf(channel);
        var newIndex = oldIndex + offset;
        if (newIndex < 0 || newIndex >= _channels.Count)
            return;
        _channels.Move(oldIndex, newIndex);
        ChannelList.SelectedItem = channel;
        ChannelList.ScrollIntoView(channel);
    }

    private void ChannelList_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateChannelEditorState();

    private void UpdateChannelEditorState()
    {
        var hasSelection = ChannelList.SelectedItem is EditableChannel;
        ChannelEditorPanel.IsEnabled = hasSelection;
        UploadChannelButton.IsEnabled = hasSelection;
        ChannelEmptyText.Visibility = _channels.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
    }

    private async void SaveButton_Click(object sender, RoutedEventArgs e) => await SaveAsync();

    private async Task<bool> SaveAsync()
    {
        var validationError = ValidateForm();
        if (validationError is not null)
        {
            StatusText.Text = validationError;
            return false;
        }

        var item = BuildItem();
        SaveButton.IsEnabled = false;
        HeaderSaveButton.IsEnabled = false;
        try
        {
            var response = await ClientSession.Requester.Request<SoftwareCatalogItem>("adminUpsertSoftware", item);
            StatusText.Text = response.StatusCode == HttpStatusCode.OK ? "软件信息已保存。" : response.Message;
            if (response.StatusCode != HttpStatusCode.OK || response.Result is null)
                return false;

            _isCreating = false;
            _editingSoftwareId = response.Result.Id;
            await RefreshAsync(response.Result.Id);
            return true;
        }
        catch (Exception exception)
        {
            StatusText.Text = $"保存失败:{exception.Message}";
            return false;
        }
        finally
        {
            SaveButton.IsEnabled = true;
            HeaderSaveButton.IsEnabled = true;
        }
    }

    private string? ValidateForm()
    {
        var id = IdTextBox.Text.Trim();
        if (id.Length == 0)
            return "请输入软件标识。";
        if (id.Length > 64 || id.Any(character => !(char.IsLetterOrDigit(character) || character is '.' or '_' or '-')))
            return "软件标识只能包含字母、数字、点、下划线和短横线,长度不超过 64。";
        if (NameTextBox.Text.Trim().Length is < 1 or > 80)
            return "软件名称长度应为 1-80 个字符。";
        if (_channels.Count == 0)
            return "请至少添加一个下载渠道。";

        var channelIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var channel in _channels)
        {
            var channelId = channel.Id.Trim();
            if (channelId.Length == 0 || channelId.Length > 64 ||
                channelId.Any(character => !(char.IsLetterOrDigit(character) || character is '.' or '_' or '-')))
                return "渠道标识只能包含字母、数字、点、下划线和短横线,长度不超过 64。";
            if (!channelIds.Add(channelId))
                return $"渠道标识“{channelId}”重复。";
            if (channel.Name.Trim().Length is < 1 or > 40)
                return "渠道名称长度应为 1-40 个字符。";
            if (channel.Mode != SoftwareDownloadMode.Server && !IsHttpAddress(channel.DisplayAddress))
                return $"渠道“{channel.Name}”需要有效的 HTTP(S) 地址。";
            if (channel.Mode == SoftwareDownloadMode.Server && string.IsNullOrWhiteSpace(channel.DisplayAddress))
                return $"渠道“{channel.Name}”还没有服务器文件,请先上传文件或填写文件名。";
        }
        return null;
    }

    private static bool IsHttpAddress(string value) =>
        Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
        (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);

    private async void UploadChannelButton_Click(object sender, RoutedEventArgs e)
    {
        if (ChannelList.SelectedItem is not EditableChannel selected)
        {
            StatusText.Text = "请先选择要上传文件的渠道。";
            return;
        }

        var dialog = new OpenFileDialog
        {
            Title = $"为“{selected.Name}”选择软件文件",
            Filter = "所有文件|*.*"
        };
        if (dialog.ShowDialog() != true)
            return;

        var channelId = selected.Id.Trim();
        selected.Mode = SoftwareDownloadMode.Server;
        selected.DisplayAddress = Path.GetFileName(dialog.FileName);
        if (!await SaveAsync())
            return;

        try
        {
            StatusText.Text = "正在读取并上传文件…";
            var bytes = await File.ReadAllBytesAsync(dialog.FileName);
            var response = await ClientSession.Requester.Request<SoftwareCatalogItem>(
                "adminUploadSoftware",
                IdTextBox.Text,
                channelId,
                Path.GetFileName(dialog.FileName),
                Convert.ToBase64String(bytes));
            StatusText.Text = response.StatusCode == HttpStatusCode.OK
                ? "文件已上传,渠道已切换为服务器下载。"
                : response.Message;
            if (response.StatusCode == HttpStatusCode.OK)
                await RefreshAsync(IdTextBox.Text);
        }
        catch (Exception exception)
        {
            StatusText.Text = $"上传失败:{exception.Message}";
        }
    }

    private async void PublicationButton_Click(object sender, RoutedEventArgs e)
    {
        if (_isCreating || string.IsNullOrWhiteSpace(_editingSoftwareId))
            return;
        var published = PublishedCheckBox.IsChecked != true;
        try
        {
            var response = await ClientSession.Requester.Request<SoftwareCatalogItem>(
                "adminSetSoftwarePublication", _editingSoftwareId, published);
            StatusText.Text = response.StatusCode == HttpStatusCode.OK
                ? published ? "软件已发布。" : "软件已下架。"
                : response.Message;
            if (response.StatusCode == HttpStatusCode.OK)
                await RefreshAsync(_editingSoftwareId);
        }
        catch (Exception exception)
        {
            StatusText.Text = $"操作失败:{exception.Message}";
        }
    }

    private async void DeleteButton_Click(object sender, RoutedEventArgs e)
    {
        if (_isCreating || string.IsNullOrWhiteSpace(_editingSoftwareId))
            return;
        var item = _software.FirstOrDefault(item => item.Id.Equals(_editingSoftwareId, StringComparison.OrdinalIgnoreCase));
        if (item is null)
            return;

        var confirmation = new TextBlock
        {
            Text = $"确定删除“{item.Name}”及服务器上已上传的文件吗?",
            TextWrapping = TextWrapping.Wrap,
            Margin = new Thickness(20)
        };
        if (PopupHelper.ShowConfirmDialog(confirmation, true) != MessageBoxResult.OK)
            return;

        try
        {
            var response = await ClientSession.Requester.Request<System.Text.Json.JsonElement>(
                "adminDeleteSoftware", item.Id);
            StatusText.Text = response.StatusCode == HttpStatusCode.OK ? "软件已删除。" : response.Message;
            if (response.StatusCode == HttpStatusCode.OK)
            {
                ShowCatalog();
                await RefreshAsync();
            }
        }
        catch (Exception exception)
        {
            StatusText.Text = $"删除失败:{exception.Message}";
        }
    }

    private SoftwareCatalogItem BuildItem() => new()
    {
        Id = IdTextBox.Text.Trim(),
        Name = NameTextBox.Text.Trim(),
        Summary = SummaryTextBox.Text.Trim(),
        Description = DescriptionTextBox.Text.Trim(),
        Publisher = PublisherTextBox.Text.Trim(),
        Category = CategoryTextBox.Text.Trim(),
        Version = string.IsNullOrWhiteSpace(VersionTextBox.Text) ? "最新版" : VersionTextBox.Text.Trim(),
        WebsiteUrl = WebsiteTextBox.Text.Trim(),
        IconUrl = IconTextBox.Text.Trim(),
        Notice = NoticeTextBox.Text.Trim(),
        Tags = _selectedTags.ToArray(),
        Enabled = EnabledCheckBox.IsChecked == true,
        Published = PublishedCheckBox.IsChecked == true,
        Featured = FeaturedCheckBox.IsChecked == true,
        Channels = _channels.Select((channel, index) => channel.ToContract(index)).ToArray()
    };

    private sealed class SoftwareCatalogRow(SoftwareCatalogItem item, ImageSource? iconSource) : INotifyPropertyChanged
    {
        private int _iconLoadStarted;
        private ImageSource? _iconSource = iconSource;

        public SoftwareCatalogItem Item { get; } = item;
        public string Initial => string.IsNullOrWhiteSpace(Item.Name) ? "?" : Item.Name.Trim()[..1].ToUpperInvariant();
        public string ChannelSummary => $"{Item.GetEffectiveChannels().Length} 个渠道";
        public string FeaturedText => Item.Featured ? "精选" : "普通";

        public ImageSource? IconSource
        {
            get => _iconSource;
            set
            {
                if (ReferenceEquals(_iconSource, value))
                    return;
                _iconSource = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IconSource)));
            }
        }

        public bool TryBeginIconLoad() =>
            !string.IsNullOrWhiteSpace(Item.IconUrl) && Interlocked.Exchange(ref _iconLoadStarted, 1) == 0;

        public event PropertyChangedEventHandler? PropertyChanged;
    }

    private sealed class EditableChannel : INotifyPropertyChanged
    {
        private string _id;
        private string _name;
        private string _displayAddress;
        private SoftwareDownloadMode _mode;
        private bool _enabled;

        public EditableChannel(SoftwareDownloadChannel channel)
        {
            _id = channel.Id;
            _name = channel.Name;
            _mode = channel.Mode;
            _displayAddress = channel.Mode == SoftwareDownloadMode.Server ? channel.FileName : channel.Url;
            _enabled = channel.Enabled;
            Sha256 = channel.Sha256;
            StorageKey = channel.StorageKey;
        }

        public string Id { get => _id; set => Set(ref _id, value); }
        public string Name { get => _name; set => Set(ref _name, value); }
        public string DisplayAddress { get => _displayAddress; set => Set(ref _displayAddress, value); }
        public bool Enabled { get => _enabled; set => Set(ref _enabled, value); }
        public string Sha256 { get; }
        public string StorageKey { get; }

        public SoftwareDownloadMode Mode
        {
            get => _mode;
            set
            {
                if (!Set(ref _mode, value))
                    return;
                OnPropertyChanged(nameof(ModeLabel));
                OnPropertyChanged(nameof(AddressLabel));
                OnPropertyChanged(nameof(AddressHint));
                OnPropertyChanged(nameof(FileMetadata));
            }
        }

        public string ModeLabel => Mode switch
        {
            SoftwareDownloadMode.Browser => "浏览器",
            SoftwareDownloadMode.Direct => "直接下载",
            SoftwareDownloadMode.Server => "服务器",
            _ => Mode.ToString()
        };

        public string AddressLabel => Mode == SoftwareDownloadMode.Server ? "服务器文件名 *" : "HTTP(S) 地址 *";
        public string AddressHint => Mode == SoftwareDownloadMode.Server ? "上传后自动填写,也可修改展示文件名" : "https://";
        public string FileMetadata => Mode == SoftwareDownloadMode.Server && !string.IsNullOrWhiteSpace(StorageKey)
            ? $"已上传到服务器 · SHA-256 {ShortHash(Sha256)}"
            : Mode == SoftwareDownloadMode.Server ? "尚未上传服务器文件" : "该方式使用网络地址,不保存文件到工具箱服务器";

        public SoftwareDownloadChannel ToContract(int sortOrder) => new()
        {
            Id = Id.Trim(),
            Name = Name.Trim(),
            Mode = Mode,
            Url = Mode == SoftwareDownloadMode.Server ? string.Empty : DisplayAddress.Trim(),
            FileName = Mode == SoftwareDownloadMode.Server ? DisplayAddress.Trim() : string.Empty,
            Sha256 = Sha256,
            StorageKey = StorageKey,
            Enabled = Enabled,
            SortOrder = sortOrder
        };

        private static string ShortHash(string hash) => string.IsNullOrWhiteSpace(hash)
            ? "待生成"
            : hash[..Math.Min(12, hash.Length)] + "…";

        public event PropertyChangedEventHandler? PropertyChanged;

        private bool Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
        {
            if (EqualityComparer<T>.Default.Equals(field, value))
                return false;
            field = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private sealed record FilterOption(string Text, string Value)
    {
        public override string ToString() => Text;
    }
    private sealed record ChannelModeOption(string Name, SoftwareDownloadMode Value)
    {
        public override string ToString() => Name;
    }
}