Material Design In XAML 中對話框實際使用中遇到的兩個問題及解決辦法


接上一篇

如果使用Material Design In XAML中的對話框進行表單提交,那么可能會遇到以下幾個問題。

  1. 使用對話框提交表單時,提交按鈕可能會誤觸(多次點擊),導致表單重復點擊,多次提交。(我的客戶端通過WebAPI訪問服務端。)
  2. 在對話框中放置文本框TextBox,輸入法跟隨的問題IME。
  3. 涉及內容修改的時候,直接修改內容,不點擊提交而是直接關閉對話框,導致原內容變化。(引用類型導致的)。

 先說明一下我的代碼結構,為重用代碼和統一樣式風格,將對話框的通用UI部分抽象為統一的樣式文件,在樣式文件中添加ControlTemplate,將ViewModel中相似的功能抽象為基類,配合樣式文件進行使用。

對話框繼承自對話框基類,對話框基類繼承於全局的基類,下面基類內容進行適當刪減,但不影響使用。

全局基類

using System;
using System.ComponentModel;
using MaterialDesignThemes.Wpf;
using Prism.Events;
using Prism.Ioc;
using Prism.Modularity;
using Prism.Mvvm;
using Prism.Regions;
using TXCE.TrainEarlyWaringClient.Common.Identity;

namespace TXCE.TrainEarlyWaringClient.Common.ViewModels
{
    public class BaseViewModel : BindableBase
    {
        protected BaseViewModel(IContainerExtension container)
        {
            ContainerExtension = container;
            EventAggregator = container.Resolve<IEventAggregator>();
            GlobalMessageQueue = container.Resolve<ISnackbarMessageQueue>();
            RegionManager = container.Resolve<IRegionManager>();
            ModuleManager = container.Resolve<IModuleManager>();
        }

        public BaseViewModel() { }

        /// <summary>
        /// 已授權客戶端連接
        /// </summary>
        public static System.Net.Http.HttpClient AuthClient => AuthHttpClient.Instance;/// <summary>
        /// 全局消息隊列
        /// </summary>
        public ISnackbarMessageQueue GlobalMessageQueue { get; set; }

        /// <summary>
        /// 事件匯總器,用於發布或訂閱事件
        /// </summary>
        protected IEventAggregator EventAggregator { get; }

        /// <summary>
        /// 區域管理器
        /// </summary>
        protected IRegionManager RegionManager { get; }

        /// <summary>
        /// 模塊管理器
        /// </summary>
        protected IModuleManager ModuleManager { get; }

        /// <summary>
        /// 依賴注入容器,包括注冊和獲取
        /// </summary>
        protected IContainerExtension ContainerExtension { get; }
    }
}

對話框基類

    public class BaseDialogViewModelEntity<T> : BaseViewModel where T : ValidateModelBase, new()
    {
        public ISnackbarMessageQueue DialogMessageQueue { get; set; }

        private T _voEntity;

        public T VoEntity
        {
            get => _voEntity;
            set => SetProperty(ref _voEntity, value);
        }

        private bool _isEnabled;

        public bool IsEnabled
        {
            get => _isEnabled;
            set => SetProperty(ref _isEnabled, value);
        }

        public DelegateCommand SubmitCommand => new(() => Submit(), CanSubmit);

        public BaseDialogViewModelEntity(IContainerExtension container) : base(container)
        {
            DialogMessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(2));
            VoEntity = new T();
            IsEnabled = true;
            VoEntity.PropertyChanged -= VoEntity_PropertyChanged;
            VoEntity.PropertyChanged += VoEntity_PropertyChanged;
        }

        private void VoEntity_PropertyChanged(object sender, PropertyChangedEventArgs e) => IsEnabled = true;

        public async virtual Task<bool> Submit()
        {
            IsEnabled = false;
            if (VoEntity.IsValidated) return true;
            DialogMessageQueue.Enqueue(AlertConstText.InputError);
            return false;
        }
        private bool CanSubmit() => IsEnabled;
    }

對話框基類中繼承的泛型T所繼承的ValidateModelBase,將在下一篇說明,這個涉及數據驗證。

對話框的統一樣式文件

    <converters:PackIconKindConverter x:Key="IconKindConverter"/>

    <Style x:Key="UserControlDialog" TargetType="UserControl">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type UserControl}">
                    <Border BorderBrush="{TemplateBinding BorderBrush}">
                        <AdornerDecorator>
                            <Grid>
                                <Grid Margin="24 16">
                                    <Grid.RowDefinitions>
                                        <RowDefinition Height="Auto"/>
                                        <RowDefinition Height="8"/>
                                        <RowDefinition Height="Auto"/>
                                        <RowDefinition Height="24"/>
                                        <RowDefinition Height="Auto"/>
                                    </Grid.RowDefinitions>
                                    <StackPanel Grid.Row="0" HorizontalAlignment="Left"
                                                VerticalAlignment="Center"
                                                Focusable="False"
                                                Orientation="Horizontal">

                                        <materialDesign:PackIcon Kind="{TemplateBinding Tag,Converter={StaticResource IconKindConverter}}"
                                                                 Width="20" Height="20"/>
                                        <TextBlock FontSize="15" 
                                                   FontWeight="Bold"
                                                   Margin="8 0 0 0"
                                                   Text="{TemplateBinding Name}" 
                                                   Foreground="{DynamicResource PrimaryHueMidBrush}"/>
                                    </StackPanel>
                                    <Button Grid.Row="0" HorizontalAlignment="Right"
                                            VerticalAlignment="Center"
                                            IsEnabled="{Binding IsCanClose,FallbackValue=True}"
                                            Style="{StaticResource MaterialDesignToolButton}"
                                            Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}">
                                        <materialDesign:PackIcon Kind="WindowClose"/>
                                    </Button>
                                    <ContentPresenter Grid.Row="2">
                                        <b:Interaction.Triggers>
                                            <b:EventTrigger EventName="GotFocus">
                                                <styles:GotFocusTrigger/>
                                            </b:EventTrigger>
                                        </b:Interaction.Triggers>
                                    </ContentPresenter>
                                    <Button Grid.Row="4"
                                            x:Name="Button"
                                            Content="確 定" 
                                            Width="160"
                                            IsDefault="True"
                                            VerticalAlignment="Bottom"
                                            Command="{Binding SubmitCommand}"
                                            IsEnabled="{Binding IsEnabled,UpdateSourceTrigger=PropertyChanged,Mode=TwoWay}">
                                        <b:Interaction.Triggers>
                                            <b:EventTrigger EventName="PreviewMouseDown">
                                                <styles:DisEnableTrigger/>
                                            </b:EventTrigger>
                                        </b:Interaction.Triggers>
                                    </Button>
                                </Grid>
                                <materialDesign:Snackbar MessageQueue="{Binding DialogMessageQueue}" />
                            </Grid>
                        </AdornerDecorator>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

其中用到的轉換器類

using System;
using System.Globalization;
using System.Windows.Data;
using MaterialDesignThemes.Wpf;

namespace TXCE.TrainEarlyWaringClient.Common.Converters
{
    public class PackIconKindConverter:IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return value is not PackIcon packIcon ? PackIconKind.Abacus : packIcon.Kind;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return null;
        }
    }
}

其中用到的自定義行為觸發器類

    class DisEnableTrigger : TriggerAction<DependencyObject>
    {
        protected override void Invoke(object parameter)
        {
            if (((RoutedEventArgs)parameter).Source is not Button button) return;
            if (button.IsEnabled)
            {
                button.Command.Execute(null);
            }
            button.IsEnabled = false;
        }
    }

這個自定義行為觸發器,對應模板中的淡藍色部分,如果對話框中沒有文本框輸入或者不在意輸入法跟隨的問題,可以使用上邊這個辦法,可以更方便的實現全局的防誤觸操作。

    class GotFocusTrigger : TriggerAction<DependencyObject>
    {
        protected override void Invoke(object parameter)
        {
            if (((RoutedEventArgs)parameter).OriginalSource is TextBox textBox)
            {
                var popuxEx = textBox.TryFindParent<PopupEx>();
                var source = (HwndSource)PresentationSource.FromVisual(popuxEx.Child);
                if (source != null)
                {
                    SetFocus(source.Handle);
                    textBox.Focus();
                }
            }
        }

        [DllImport("User32.dll")]
        private static extern IntPtr SetFocus(IntPtr hWnd);
    }

如果對話框中有文本框,需要上述方法來實現所有對話框的輸入法問題,不必每個對話框單獨處理。但是如果兩個擴展全部使用,則會因為焦點導致沖突,因此需要修改ViewModel中命令的方法,在業務代碼執行前,先設置Button.IsEnable綁定的屬性IsEnable為false。或者像本示例中在基類中進行處理,此時需要將樣式文件中藍色部分刪除或者注釋,然后刪除DisEnableTrigger類。

 

其中用到的擴展方法

using System.Windows;
using System.Windows.Media;

namespace TXCE.TrainEarlyWaringClient.Common.Extensions
{
    public static class DependencyObjectExtensions 
    {
        public static T TryFindParent<T>(this DependencyObject child) where T : DependencyObject
        {
            while (true)
            {
                DependencyObject parentObject = child.GetParentObject();
                if (parentObject == null) return null;
                if (parentObject is T parent) return parent;
                child = parentObject;
            }
        }

        public static DependencyObject GetParentObject(this DependencyObject child)
        {
            if (child == null) return null;
            if (child is ContentElement contentElement)
            {
                DependencyObject parent = ContentOperations.GetParent(contentElement);
                if (parent != null) return parent;

                return contentElement is FrameworkContentElement fce ? fce.Parent : null;
            }

            var childParent = VisualTreeHelper.GetParent(child);
            if (childParent != null)
            {
                return childParent;
            }

if (child is FrameworkElement frameworkElement) { DependencyObject parent = frameworkElement.Parent; if (parent != null) return parent; } return null; } } }

 

以上就是解決第一個問題和第二個問題的處理方式,下邊將說明我首先想到的辦法以及為什么沒有這么做的原因。

  1. 點擊確定按鈕后,觸發Click事件,在事件中設置按鈕的IsEnable為false。因為所有對話框應用了全局樣式,按鈕是在ControlTemplate中定義的,因此沒有辦法使用這種解決辦法。
  2. 在xaml中使用x:code,在xaml中編寫C#方法,通過事件將按鈕的IsEnable設置為false。因為樣式文件中沒有x:class,因此也無法使用x:code。
  3. 輸入法無法跟隨的原因是因為WPF框架的一個Bug,或者人家就是這么設計的,原因是Material Design In XAML中的對話框本質是一個Popup,Popup不能直接獲取焦點,而只能通過父窗口打開,輸入法輸入的時候,焦點並沒有在對話框中,為解決這個問題,需要強制為對話框設置焦點,然后將焦點定位到其中需要輸入的文本框上,以此解決輸入法跟隨的問題。

        這個問題的解決辦法參考自這篇文章:WPF 彈出 popup 里面的 TextBox 無法輸入漢字

 

 現在全部鋪墊已經完成,現在來看如何去使用上述方法,打開一個對話框。

首先新建一個UserControl文件,在其中輸入以下代碼,創建一個新增角色信息的對話框。

<UserControl x:Class="TXCE.TrainEarlyWarningClient.SystemManagement.Dialogs.RoleManagement.Views.RoleAdd"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Style="{StaticResource UserControlDialog}"
             x:Name="新增角色信息"
             Tag="{materialDesign:PackIcon ApplicationCog}">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition Height="16"/>
            <RowDefinition/>
            <RowDefinition Height="16"/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition Width="8"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>

        <Grid.Resources>
            <Style TargetType="TextBlock">
                <Setter Property="VerticalAlignment" Value="Center"/>
                <Setter Property="HorizontalAlignment" Value="Right"/>
                <Setter Property="Foreground" Value="Black"/>
            </Style>
            <Style TargetType="TextBox" BasedOn="{StaticResource TextBoxStyleValidationVertical}">
                <Setter Property="HorizontalAlignment" Value="Left"/>
            </Style>
        </Grid.Resources>

        <TextBlock Grid.Row="0" Grid.Column="0" Text="角色名稱:"/>
        <TextBox Grid.Row="0" Grid.Column="2"
                 Width="296"
                 Text="{Binding Path=VoEntity.Name,UpdateSourceTrigger=PropertyChanged,ValidatesOnDataErrors=True}"/>

        <TextBlock Grid.Row="2" Grid.Column="0" Text="備注:"/>
        <TextBox Grid.Row="2"
                 Grid.Column="2" 
                 Width="296"
                 TextWrapping="Wrap"
                 MaxHeight="80"
                 Text="{Binding Path=VoEntity.Remark,UpdateSourceTrigger=PropertyChanged,ValidatesOnDataErrors=True}"/>

        <TextBlock Grid.Row="4" Grid.Column="0" Text="授權菜單:"/>
        <ScrollViewer Grid.Row="4" Grid.Column="2" MaxHeight="240">
            <TreeView ItemsSource="{Binding VoEntity.Menus}" PreviewMouseWheel="TreeView_OnPreviewMouseWheel">
                <TreeView.ItemTemplate>
                    <HierarchicalDataTemplate ItemsSource="{Binding Children, Mode=OneTime}">
                        <StackPanel Orientation="Horizontal">
                            <CheckBox Focusable="False" VerticalAlignment="Center"
                                      IsChecked="{Binding IsChecked}"
                                      Click="CheckBox_OnClick"/>
                            <ContentPresenter Content="{Binding Name, Mode=OneTime}" Margin="2,0"/>
                        </StackPanel>
                    </HierarchicalDataTemplate>
                </TreeView.ItemTemplate>
            </TreeView>
        </ScrollViewer>
    </Grid>
</UserControl>

在該UserControl中應用之前定義的對話框樣式,並在通過x:Name和Tag傳遞對話框標題和圖標。最后通過Prism將View和ViewModel綁定。

然后新建一個ViewModel文件

using System;
using System.Threading.Tasks;
using MaterialDesignThemes.Wpf;
using Prism.Ioc;
using Refit;
using TXCE.TrainEarlyWaringClient.Common.Extensions;
using TXCE.TrainEarlyWaringClient.Common.ViewModels;
using TXCE.TrainEarlyWaringClient.Common.VO;
using TXCE.TrainEarlyWarningClient.SystemManagement.ApiServer;
using TXCE.TrainEarlyWarningClient.SystemManagement.Mapper.RoleManagement;
using TXCE.TrainEarlyWarningClient.SystemManagement.VO.RoleManagement;

namespace TXCE.TrainEarlyWarningClient.SystemManagement.Dialogs.RoleManagement.ViewModels
{
    public class RoleAddViewModel : BaseDialogViewModelEntity<RoleInfo>
    {
        private readonly IRoleServer _roleServer;
        public RoleAddViewModel(IContainerExtension container) : base(container)
        {
            _roleServer = RestService
                .For<IRoleServer>(AuthClient, new RefitSettings(new NewtonsoftJsonContentSerializer()));
            InitTreeView();
        }

        #region Method 方法

        private async void InitTreeView()
        {
            var result = await _roleServer.GetMenu().RunApiForDialog(DialogMessageQueue);
            if(result is { Count: > 0 }) Menu.CreateRoleMenus(result, VoEntity.Menus);
        }

        public override async Task<bool> Submit()
        {
            var isValidated = await base.Submit();
            if (isValidated)
            {
                var menus = Menu.GetCheckedMenus(VoEntity.Menus);
                VoEntity.Id = Guid.Empty.ToString();
                var result = await _roleServer.Add(VoEntity.MapToRoleInfoDto(menus)).RunApiForDialog(DialogMessageQueue, true);
                if (result) DialogHost.CloseDialogCommand.Execute(true, null);
            }
            return isValidated;
        }
        #endregion
    }
}

該文件中只需要關注基類的繼承,其他的可以暫時無需關注,我們可以看到這里使用了上一節中說到的通過代碼關閉對話框的一種方法。

最后在對話框所在頁面的ViewModel通過代碼打開該對話框。因為每個頁面也對公用方法進行了抽象,因此看到的方法中包括虛方法的重寫。

protected override async void Add()=> await DialogHost.Show(new RoleAdd(), DialogNames.RootDialog,RefreshClosingEventHandler);
RefreshClosingEventHandler在基類中實現,采用上一節中說的通過eventArgs獲取返回值的方法拿到返回的結果。
protected virtual void RefreshClosingEventHandler(object sender, DialogClosingEventArgs eventArgs)
{
    if (eventArgs.Parameter is true) GetByPage(PageModel.PageIndex);
}

最后打開的頁面效果如下:

 

 

 到此為止,前兩個問題的解決辦法就已經全部完畢了。因為結合了我的具體實現,因此看起來稍顯復雜,其中核心就是設置焦點,通過自定義行為觸發器實現全局應用,最后通過在子類中判斷基類方法實現防止多次點擊。

點擊后首先將IsEnable屬性設置為False,然后再進行其他業務代碼的執行。如果數據驗證失敗,再次修改對話框中屬性值會重新將IsEnable屬性設置為True,使確定按鈕變為可用狀態。

在繼續下邊一個話題前,還有一個問題需要說明,如果沒有使用過Prism,可能會對對話框基類中的

VoEntity.PropertyChanged += VoEntity_PropertyChanged;

存在疑惑,這是事件來源於Prism中實現INotifyPropertyChanged接口的一個類BindableBase,用於實現屬性的變更通知,為了避免疑惑,將這個類的源碼也貼在這里。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Prism.Mvvm
{
    /// <summary>
    /// Implementation of <see cref="INotifyPropertyChanged"/> to simplify models.
    /// </summary>
    public abstract class BindableBase : INotifyPropertyChanged
    {
        /// <summary>
        /// Occurs when a property value changes.
        /// </summary>
        public event PropertyChangedEventHandler PropertyChanged;

        /// <summary>
        /// Checks if a property already matches a desired value. Sets the property and
        /// notifies listeners only when necessary.
        /// </summary>
        /// <typeparam name="T">Type of the property.</typeparam>
        /// <param name="storage">Reference to a property with both getter and setter.</param>
        /// <param name="value">Desired value for the property.</param>
        /// <param name="propertyName">Name of the property used to notify listeners. This
        /// value is optional and can be provided automatically when invoked from compilers that
        /// support CallerMemberName.</param>
        /// <returns>True if the value was changed, false if the existing value matched the
        /// desired value.</returns>
        protected virtual bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
        {
            if (EqualityComparer<T>.Default.Equals(storage, value)) return false;

            storage = value;
            RaisePropertyChanged(propertyName);

            return true;
        }

        /// <summary>
        /// Checks if a property already matches a desired value. Sets the property and
        /// notifies listeners only when necessary.
        /// </summary>
        /// <typeparam name="T">Type of the property.</typeparam>
        /// <param name="storage">Reference to a property with both getter and setter.</param>
        /// <param name="value">Desired value for the property.</param>
        /// <param name="propertyName">Name of the property used to notify listeners. This
        /// value is optional and can be provided automatically when invoked from compilers that
        /// support CallerMemberName.</param>
        /// <param name="onChanged">Action that is called after the property value has been changed.</param>
        /// <returns>True if the value was changed, false if the existing value matched the
        /// desired value.</returns>
        protected virtual bool SetProperty<T>(ref T storage, T value, Action onChanged, [CallerMemberName] string propertyName = null)
        {
            if (EqualityComparer<T>.Default.Equals(storage, value)) return false;

            storage = value;
            onChanged?.Invoke();
            RaisePropertyChanged(propertyName);

            return true;
        }

        /// <summary>
        /// Raises this object's PropertyChanged event.
        /// </summary>
        /// <param name="propertyName">Name of the property used to notify listeners. This
        /// value is optional and can be provided automatically when invoked from compilers
        /// that support <see cref="CallerMemberNameAttribute"/>.</param>
        protected void RaisePropertyChanged([CallerMemberName] string propertyName = null)
        {
            OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
        }

        /// <summary>
        /// Raises this object's PropertyChanged event.
        /// </summary>
        /// <param name="args">The PropertyChangedEventArgs</param>
        protected virtual void OnPropertyChanged(PropertyChangedEventArgs args)
        {
            PropertyChanged?.Invoke(this, args);
        }
    }
}

好像又好長了,最后一個問題簡單點說吧。

使用情景,在datagrid中有一個編輯按鈕,可以對該行數據進行修改提交,但是改了一半,發現改錯了,直接關閉對話框而沒有點提交按鈕,因為是直接將datagrid中的對象直接傳遞到了對話框,由於引用類型傳遞的特點,在關閉后會導致datagrid中的內容也跟着變了。

為了解決這個問題,需要在VoEntity中繼承ICloneable接口並實現其中的Clone方法。請忽略其中的IChecked自定義接口。

  public object Clone() => MemberwiseClone();

然后為所有編輯對話框的ViewModel創建一個新的基類如下。

    public class BaseDialogViewModelEntity<T, T1> : BaseViewModel
        where T : ValidateModelBase, IChecked<T1>, ICloneable,new()
    {
        public ISnackbarMessageQueue DialogMessageQueue { get; set; }

        private T _voEntity;

        public T VoEntity
        {
            get => _voEntity;
            set => SetProperty(ref _voEntity, value);
        }

        private bool _isEnabled;

        public bool IsEnabled
        {
            get => _isEnabled;
            set => SetProperty(ref _isEnabled, value);
        }

        public DelegateCommand SubmitCommand => new(()=>Submit(), CanSubmit);

        public BaseDialogViewModelEntity(IContainerExtension container) : base(container)
        {
            DialogMessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(2));
            VoEntity = new T();
            IsEnabled = true;
        }

        private void VoEntity_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) =>
            IsEnabled = true;

        public async virtual Task<bool> Submit()
        {
            IsEnabled = false;
            if (VoEntity.IsValidated) return true;
            DialogMessageQueue.Enqueue(AlertConstText.InputError);
            return false;
        }

        private bool CanSubmit() => IsEnabled;

        public virtual void TransferParameters(T param)
        {
            if (param == null || param.Id.ToString().IsNullOrWhiteSpace()) return;
            VoEntity = (T)param.Clone();
            VoEntity.PropertyChanged -= VoEntity_PropertyChanged;
            VoEntity.PropertyChanged += VoEntity_PropertyChanged;
        }
    }

在進行數據傳遞的時候使用Clone方法進行傳遞即可解決這個問題。

在數據傳遞時並沒有使用上一節中所說的幾種方法,而是在基類中定義了TransferParameters方法,具體打開時傳遞參數的方法如下,后來才發現這也是WPF編程寶典中所提及的一種方法。

protected override async void Edit(RoleInfo roleInfo)
{
    var roleEdit = new RoleEdit();
    roleInfo.Menus.Clear();
    ((RoleEditViewModel)roleEdit.DataContext).TransferParameters(roleInfo);
    await DialogHost.Show(roleEdit, DialogNames.RootDialog,RefreshClosingEventHandler);
}

文章有點長,寫的也比較亂,期待與大家交流。

下一篇將對本文中所說的數據驗證實現進行說明。

 

涉及使用軟件框架版本如下:

Mvvm框架 Prism8+

UI框架MaterialDesignThemes4.4.0

Net版本 5.0


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM