新增加 利润统计Window。
隐藏利润统计按钮 增加 Common 文件夹 内部是WPF公用类库
This commit is contained in:
1 parent
4a7af051b2
commit
7c6f91f207
25 files changed
+6462
-3554
No files matched your search
@@ -68,7 +68,7 @@
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
|
||||
<OutputPath>..\..\KJD Cef138\Bin\</OutputPath>
|
||||
<OutputPath>..\..\KJD\Bin\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<Optimize>true</Optimize>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
@@ -140,6 +140,12 @@
|
||||
</Compile>
|
||||
<Compile Include="BaseWindows\BaseWindow.cs" />
|
||||
<Compile Include="CefHelper\WPFCEFBrowser.cs" />
|
||||
<Compile Include="Common\DebugVisibility.cs" />
|
||||
<Compile Include="Common\DelegateCommand.cs" />
|
||||
<Compile Include="Common\DelegateCommandBase.cs" />
|
||||
<Compile Include="Common\DelegateCommand{T}.cs" />
|
||||
<Compile Include="Common\DialogWindowProperty.cs" />
|
||||
<Compile Include="Common\Result.cs" />
|
||||
<Compile Include="Converters\ImageUrlToBitmapImageConverter.cs" />
|
||||
<Compile Include="Converters\HasValueToVisibilityConverter.cs" />
|
||||
<Compile Include="Converters\ListBoxIndexConverter.cs" />
|
||||
@@ -153,6 +159,9 @@
|
||||
<Compile Include="Ctrls\BuyerCtrls\PDDBuyerSelectCtrl.xaml.cs">
|
||||
<DependentUpon>PDDBuyerSelectCtrl.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Common\DialogWindow.xaml.cs">
|
||||
<DependentUpon>DialogWindow.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Ctrls\OrderCtrls\OrderListRowCtrl.xaml.cs">
|
||||
<DependentUpon>OrderListRowCtrl.xaml</DependentUpon>
|
||||
</Compile>
|
||||
@@ -168,6 +177,18 @@
|
||||
<Compile Include="Ctrls\ShopCtrls\ShopListCtrl.xaml.cs">
|
||||
<DependentUpon>ShopListCtrl.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Ctrls\Statistics\Charts\RevenueLineChart.xaml.cs">
|
||||
<DependentUpon>RevenueLineChart.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Ctrls\Statistics\Charts\RevenuePieChart.xaml.cs">
|
||||
<DependentUpon>RevenuePieChart.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Ctrls\Statistics\Models\ShopRevenueStatisticsModels.cs" />
|
||||
<Compile Include="Ctrls\Statistics\Services\ShopRevenueStatisticsService.cs" />
|
||||
<Compile Include="Ctrls\Statistics\ShopRevenueStatisticsView.xaml.cs">
|
||||
<DependentUpon>ShopRevenueStatisticsView.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Ctrls\Statistics\ViewModels\ShopRevenueStatisticsViewModel.cs" />
|
||||
<Compile Include="UserControls\UcWaterMarkTextBox.xaml.cs">
|
||||
<DependentUpon>UcWaterMarkTextBox.xaml</DependentUpon>
|
||||
</Compile>
|
||||
@@ -202,6 +223,10 @@
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Common\DialogWindow.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Ctrls\OrderCtrls\OrderListRowCtrl.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
@@ -222,6 +247,26 @@
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Ctrls\Statistics\Charts\Chart.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Ctrls\Statistics\Charts\RevenueLineChart.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Ctrls\Statistics\Charts\RevenuePieChart.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Ctrls\Statistics\General.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Ctrls\Statistics\ShopRevenueStatisticsView.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="UserControls\UcWaterMarkTextBox.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<ResourceDictionary Source="Styles/StaticColorBrushDictionary.xaml" />
|
||||
<ResourceDictionary Source="Styles/StaticImageBrushDictionary.xaml" />
|
||||
<!-- 可以添加更多资源字典 -->
|
||||
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="/Ctrls/Statistics/General.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace _29WxOrderWpf.Common
|
||||
{
|
||||
public static class DebugVisibility
|
||||
{
|
||||
// 定义一个附加属性
|
||||
public static readonly DependencyProperty EnableProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"Enable",
|
||||
typeof(bool),
|
||||
typeof(DebugVisibility),
|
||||
new FrameworkPropertyMetadata(
|
||||
true, FrameworkPropertyMetadataOptions.AffectsArrange,
|
||||
OnEnableChanged, OnEnableCoerceCall));
|
||||
|
||||
private static object OnEnableCoerceCall(DependencyObject d, object baseValue)
|
||||
{
|
||||
if (d is UIElement element)
|
||||
{
|
||||
#if DEBUG
|
||||
// Debug 模式下,根据属性值决定可见性
|
||||
element.Visibility = (bool)baseValue ? Visibility.Visible : Visibility.Collapsed;
|
||||
#else
|
||||
// Release 模式下,隐藏所有标记了 IsDebug 的元素
|
||||
element.Visibility = Visibility.Collapsed;
|
||||
#endif
|
||||
}
|
||||
|
||||
return baseValue;
|
||||
}
|
||||
|
||||
private static void OnEnableChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is UIElement element)
|
||||
{
|
||||
#if DEBUG
|
||||
// Debug 模式下,根据属性值决定可见性
|
||||
element.Visibility = (bool)e.NewValue ? Visibility.Visible : Visibility.Collapsed;
|
||||
#else
|
||||
// Release 模式下,隐藏所有标记了 IsDebug 的元素
|
||||
element.Visibility = Visibility.Collapsed;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetEnable(DependencyObject obj) => (bool)obj.GetValue(EnableProperty);
|
||||
public static void SetEnable(DependencyObject obj, bool value) => obj.SetValue(EnableProperty, value);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Linq.Expressions;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace _29WxOrderWpf.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="ICommand"/> whose delegates do not take any parameters for <see cref="Execute()"/> and <see cref="CanExecute()"/>.
|
||||
/// </summary>
|
||||
/// <see cref="DelegateCommandBase"/>
|
||||
/// <see cref="DelegateCommand{T}"/>
|
||||
public class DelegateCommand : DelegateCommandBase
|
||||
{
|
||||
Action _executeMethod;
|
||||
Func<bool> _canExecuteMethod;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="DelegateCommand"/> with the <see cref="Action"/> to invoke on execution.
|
||||
/// </summary>
|
||||
/// <param name="executeMethod">The <see cref="Action"/> to invoke when <see cref="ICommand.Execute(object)"/> is called.</param>
|
||||
public DelegateCommand(Action executeMethod)
|
||||
: this(executeMethod, () => true)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="DelegateCommand"/> with the <see cref="Action"/> to invoke on execution
|
||||
/// and a <see langword="Func" /> to query for determining if the command can execute.
|
||||
/// </summary>
|
||||
/// <param name="executeMethod">The <see cref="Action"/> to invoke when <see cref="ICommand.Execute"/> is called.</param>
|
||||
/// <param name="canExecuteMethod">The <see cref="Func{TResult}"/> to invoke when <see cref="ICommand.CanExecute"/> is called</param>
|
||||
public DelegateCommand(Action executeMethod, Func<bool> canExecuteMethod)
|
||||
: base()
|
||||
{
|
||||
if (executeMethod == null || canExecuteMethod == null)
|
||||
throw new ArgumentNullException(nameof(executeMethod), "Delegate Command Delegates Cannot Be Null");
|
||||
|
||||
_executeMethod = executeMethod;
|
||||
_canExecuteMethod = canExecuteMethod;
|
||||
}
|
||||
|
||||
///<summary>
|
||||
/// Executes the command.
|
||||
///</summary>
|
||||
public void Execute()
|
||||
{
|
||||
_executeMethod();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the command can be executed.
|
||||
/// </summary>
|
||||
/// <returns>Returns <see langword="true"/> if the command can execute,otherwise returns <see langword="false"/>.</returns>
|
||||
public bool CanExecute()
|
||||
{
|
||||
return _canExecuteMethod();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the internal invocation of <see cref="ICommand.Execute(object)"/>
|
||||
/// </summary>
|
||||
/// <param name="parameter">Command Parameter</param>
|
||||
protected override void Execute(object parameter)
|
||||
{
|
||||
Execute();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the internal invocation of <see cref="ICommand.CanExecute(object)"/>
|
||||
/// </summary>
|
||||
/// <param name="parameter"></param>
|
||||
/// <returns><see langword="true"/> if the Command Can Execute, otherwise <see langword="false" /></returns>
|
||||
protected override bool CanExecute(object parameter)
|
||||
{
|
||||
return CanExecute();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes a property that implements INotifyPropertyChanged, and automatically calls DelegateCommandBase.RaiseCanExecuteChanged on property changed notifications.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The object type containing the property specified in the expression.</typeparam>
|
||||
/// <param name="propertyExpression">The property expression. Example: ObservesProperty(() => PropertyName).</param>
|
||||
/// <returns>The current instance of DelegateCommand</returns>
|
||||
public DelegateCommand ObservesProperty<T>(Expression<Func<T>> propertyExpression)
|
||||
{
|
||||
ObservesPropertyInternal(propertyExpression);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes a property that is used to determine if this command can execute, and if it implements INotifyPropertyChanged it will automatically call DelegateCommandBase.RaiseCanExecuteChanged on property changed notifications.
|
||||
/// </summary>
|
||||
/// <param name="canExecuteExpression">The property expression. Example: ObservesCanExecute(() => PropertyName).</param>
|
||||
/// <returns>The current instance of DelegateCommand</returns>
|
||||
public DelegateCommand ObservesCanExecute(Expression<Func<bool>> canExecuteExpression)
|
||||
{
|
||||
_canExecuteMethod = canExecuteExpression.Compile();
|
||||
ObservesPropertyInternal(canExecuteExpression);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace _29WxOrderWpf.Commands
|
||||
{
|
||||
public abstract class DelegateCommandBase : ICommand
|
||||
{
|
||||
private bool _isActive;
|
||||
|
||||
private SynchronizationContext _synchronizationContext;
|
||||
private readonly HashSet<string> _observedPropertiesExpressions = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of a <see cref="DelegateCommandBase"/>, specifying both the execute action and the can execute function.
|
||||
/// </summary>
|
||||
protected DelegateCommandBase()
|
||||
{
|
||||
_synchronizationContext = SynchronizationContext.Current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when changes occur that affect whether or not the command should execute.
|
||||
/// </summary>
|
||||
public virtual event EventHandler CanExecuteChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Raises <see cref="ICommand.CanExecuteChanged"/> so every
|
||||
/// command invoker can requery <see cref="ICommand.CanExecute"/>.
|
||||
/// </summary>
|
||||
protected virtual void OnCanExecuteChanged()
|
||||
{
|
||||
var handler = CanExecuteChanged;
|
||||
if (handler != null)
|
||||
{
|
||||
if (_synchronizationContext != null && _synchronizationContext != SynchronizationContext.Current)
|
||||
_synchronizationContext.Post((o) => handler.Invoke(this, EventArgs.Empty), null);
|
||||
else
|
||||
handler.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises <see cref="CanExecuteChanged"/> so every command invoker
|
||||
/// can requery to check if the command can execute.
|
||||
/// </summary>
|
||||
/// <remarks>Note that this will trigger the execution of <see cref="CanExecuteChanged"/> once for each invoker.</remarks>
|
||||
[SuppressMessage("Microsoft.Design", "CA1030:UseEventsWhereAppropriate")]
|
||||
public void RaiseCanExecuteChanged()
|
||||
{
|
||||
OnCanExecuteChanged();
|
||||
}
|
||||
|
||||
void ICommand.Execute(object parameter)
|
||||
{
|
||||
Execute(parameter);
|
||||
}
|
||||
|
||||
bool ICommand.CanExecute(object parameter)
|
||||
{
|
||||
return CanExecute(parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the internal invocation of <see cref="ICommand.Execute(object)"/>
|
||||
/// </summary>
|
||||
/// <param name="parameter">Command Parameter</param>
|
||||
protected abstract void Execute(object parameter);
|
||||
|
||||
/// <summary>
|
||||
/// Handle the internal invocation of <see cref="ICommand.CanExecute(object)"/>
|
||||
/// </summary>
|
||||
/// <param name="parameter"></param>
|
||||
/// <returns><see langword="true"/> if the Command Can Execute, otherwise <see langword="false" /></returns>
|
||||
protected abstract bool CanExecute(object parameter);
|
||||
|
||||
/// <summary>
|
||||
/// Observes a property that implements INotifyPropertyChanged, and automatically calls DelegateCommandBase.RaiseCanExecuteChanged on property changed notifications.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The object type containing the property specified in the expression.</typeparam>
|
||||
/// <param name="propertyExpression">The property expression. Example: ObservesProperty(() => PropertyName).</param>
|
||||
protected internal void ObservesPropertyInternal<T>(Expression<Func<T>> propertyExpression)
|
||||
{
|
||||
if (_observedPropertiesExpressions.Contains(propertyExpression.ToString()))
|
||||
{
|
||||
throw new ArgumentException($"{propertyExpression.ToString()} is already being observed.",
|
||||
nameof(propertyExpression));
|
||||
}
|
||||
else
|
||||
{
|
||||
_observedPropertiesExpressions.Add(propertyExpression.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
#region IsActive
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the object is active.
|
||||
/// </summary>
|
||||
/// <value><see langword="true" /> if the object is active; otherwise <see langword="false" />.</value>
|
||||
public bool IsActive
|
||||
{
|
||||
get { return _isActive; }
|
||||
set
|
||||
{
|
||||
if (_isActive != value)
|
||||
{
|
||||
_isActive = value;
|
||||
OnIsActiveChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired if the <see cref="IsActive"/> property changes.
|
||||
/// </summary>
|
||||
public virtual event EventHandler IsActiveChanged;
|
||||
|
||||
/// <summary>
|
||||
/// This raises the <see cref="DelegateCommandBase.IsActiveChanged"/> event.
|
||||
/// </summary>
|
||||
protected virtual void OnIsActiveChanged()
|
||||
{
|
||||
IsActiveChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace _29WxOrderWpf.Commands
|
||||
{
|
||||
|
||||
public class DelegateCommand<T> : DelegateCommandBase
|
||||
{
|
||||
readonly Action<T> _executeMethod;
|
||||
Func<T, bool> _canExecuteMethod;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="DelegateCommand{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="executeMethod">Delegate to execute when Execute is called on the command. This can be null to just hook up a CanExecute delegate.</param>
|
||||
/// <remarks><see cref="CanExecute(T)"/> will always return true.</remarks>
|
||||
public DelegateCommand(Action<T> executeMethod)
|
||||
: this(executeMethod, (o) => true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="DelegateCommand{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="executeMethod">Delegate to execute when Execute is called on the command. This can be null to just hook up a CanExecute delegate.</param>
|
||||
/// <param name="canExecuteMethod">Delegate to execute when CanExecute is called on the command. This can be null.</param>
|
||||
/// <exception cref="ArgumentNullException">When both <paramref name="executeMethod"/> and <paramref name="canExecuteMethod"/> are <see langword="null" />.</exception>
|
||||
public DelegateCommand(Action<T> executeMethod, Func<T, bool> canExecuteMethod)
|
||||
: base()
|
||||
{
|
||||
if (executeMethod == null || canExecuteMethod == null)
|
||||
throw new ArgumentNullException(nameof(executeMethod), "Delegate Command Delegates Cannot Be Null");
|
||||
|
||||
TypeInfo genericTypeInfo = typeof(T).GetTypeInfo();
|
||||
|
||||
// DelegateCommand allows object or Nullable<>.
|
||||
// note: Nullable<> is a struct so we cannot use a class constraint.
|
||||
if (genericTypeInfo.IsValueType)
|
||||
{
|
||||
if (!genericTypeInfo.IsGenericType || !typeof(Nullable<>).GetTypeInfo().IsAssignableFrom(genericTypeInfo.GetGenericTypeDefinition().GetTypeInfo()))
|
||||
{
|
||||
throw new InvalidCastException("Delegate CommandInvalid Generic Payload Type");
|
||||
}
|
||||
}
|
||||
|
||||
_executeMethod = executeMethod;
|
||||
_canExecuteMethod = canExecuteMethod;
|
||||
}
|
||||
|
||||
///<summary>
|
||||
///Executes the command and invokes the <see cref="Action{T}"/> provided during construction.
|
||||
///</summary>
|
||||
///<param name="parameter">Data used by the command.</param>
|
||||
public void Execute(T parameter)
|
||||
{
|
||||
_executeMethod(parameter);
|
||||
}
|
||||
|
||||
///<summary>
|
||||
///Determines if the command can execute by invoked the <see cref="Func{T,Bool}"/> provided during construction.
|
||||
///</summary>
|
||||
///<param name="parameter">Data used by the command to determine if it can execute.</param>
|
||||
///<returns>
|
||||
///<see langword="true" /> if this command can be executed; otherwise, <see langword="false" />.
|
||||
///</returns>
|
||||
public bool CanExecute(T parameter)
|
||||
{
|
||||
return _canExecuteMethod(parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the internal invocation of <see cref="ICommand.Execute(object)"/>
|
||||
/// </summary>
|
||||
/// <param name="parameter">Command Parameter</param>
|
||||
protected override void Execute(object parameter)
|
||||
{
|
||||
Execute((T)parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the internal invocation of <see cref="ICommand.CanExecute(object)"/>
|
||||
/// </summary>
|
||||
/// <param name="parameter"></param>
|
||||
/// <returns><see langword="true"/> if the Command Can Execute, otherwise <see langword="false" /></returns>
|
||||
protected override bool CanExecute(object parameter)
|
||||
{
|
||||
return CanExecute((T)parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes a property that implements INotifyPropertyChanged, and automatically calls DelegateCommandBase.RaiseCanExecuteChanged on property changed notifications.
|
||||
/// </summary>
|
||||
/// <typeparam name="TType">The type of the return value of the method that this delegate encapsulates</typeparam>
|
||||
/// <param name="propertyExpression">The property expression. Example: ObservesProperty(() => PropertyName).</param>
|
||||
/// <returns>The current instance of DelegateCommand</returns>
|
||||
public DelegateCommand<T> ObservesProperty<TType>(Expression<Func<TType>> propertyExpression)
|
||||
{
|
||||
ObservesPropertyInternal(propertyExpression);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes a property that is used to determine if this command can execute, and if it implements INotifyPropertyChanged it will automatically call DelegateCommandBase.RaiseCanExecuteChanged on property changed notifications.
|
||||
/// </summary>
|
||||
/// <param name="canExecuteExpression">The property expression. Example: ObservesCanExecute(() => PropertyName).</param>
|
||||
/// <returns>The current instance of DelegateCommand</returns>
|
||||
public DelegateCommand<T> ObservesCanExecute(Expression<Func<bool>> canExecuteExpression)
|
||||
{
|
||||
Expression<Func<T, bool>> expression = Expression.Lambda<Func<T, bool>>(canExecuteExpression.Body, Expression.Parameter(typeof(T), "o"));
|
||||
_canExecuteMethod = expression.Compile();
|
||||
ObservesPropertyInternal(canExecuteExpression);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Window x:Class="_29WxOrderWpf.Common.DialogWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:_29WxOrderWpf.Common"
|
||||
mc:Ignorable="d"
|
||||
Title="{Binding Title}"
|
||||
d:DesignHeight="450"
|
||||
d:DesignWidth="800"
|
||||
Background="#FFFFFF"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Window.Resources>
|
||||
<Style x:Key="DialogWindowStyle" TargetType="Window">
|
||||
<Setter Property="Tag" Value="Default"/>
|
||||
<Setter Property="ResizeMode" Value="NoResize"/>
|
||||
<Setter Property="ShowInTaskbar" Value="False"/>
|
||||
<Setter Property="WindowChrome.GlassFrameThickness" Value="-1"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
<Grid>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
|
||||
|
||||
|
||||
namespace _29WxOrderWpf.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// DialogWindow.xaml 的交互逻辑
|
||||
/// </summary>
|
||||
public partial class DialogWindow : Window
|
||||
{
|
||||
private readonly Style dialogWindowStyle;
|
||||
private bool isConstructorSuccessful = false;
|
||||
public DialogWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Owner = Application.Current.MainWindow;
|
||||
isConstructorSuccessful = true;
|
||||
dialogWindowStyle = (Style)TryFindResource("DialogWindowStyle");
|
||||
|
||||
Loaded += DialogWindow_Loaded;
|
||||
}
|
||||
private void DialogWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Loaded -= DialogWindow_Loaded;
|
||||
|
||||
}
|
||||
protected override void OnContentChanged(object oldContent, object newContent)
|
||||
{
|
||||
this.Style = dialogWindowStyle;
|
||||
if (newContent is System.Windows.Controls.UserControl control)
|
||||
{
|
||||
|
||||
Setter widthSetter = this.Style.Setters.OfType<Setter>().FirstOrDefault(a => a.Property == WidthProperty);
|
||||
if (widthSetter == null)
|
||||
{
|
||||
if (double.IsNaN(control.Width))
|
||||
{
|
||||
this.Width = DialogWindowProperty.GetWidth(control);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Width = control.Width;
|
||||
control.Width = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Setter heightSetter = this.Style.Setters.OfType<Setter>().FirstOrDefault(a => a.Property == HeightProperty);
|
||||
if (heightSetter == null)
|
||||
{
|
||||
if (double.IsNaN(control.Height))
|
||||
{
|
||||
this.Height = DialogWindowProperty.GetHeight(control);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Height = control.Height;
|
||||
control.Height = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ResizeMode resizeExt = DialogWindowProperty.GetResizeMode(control);
|
||||
if (resizeExt != ResizeMode.CanResize)
|
||||
{
|
||||
this.ResizeMode = resizeExt;
|
||||
}
|
||||
|
||||
}
|
||||
base.OnContentChanged(oldContent, newContent);
|
||||
}
|
||||
|
||||
protected override void OnStyleChanged(Style oldStyle, Style newStyle)
|
||||
{
|
||||
if (newStyle.TargetType == typeof(Window))
|
||||
{
|
||||
if (isConstructorSuccessful)
|
||||
{
|
||||
//dialogWindowStyle.Merge(newStyle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
base.OnStyleChanged(oldStyle, newStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace _29WxOrderWpf.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// 弹出窗口属性值
|
||||
/// </summary>
|
||||
public class DialogWindowProperty
|
||||
{
|
||||
public static readonly DependencyProperty WindowStartupLocationProperty = DependencyProperty.RegisterAttached(
|
||||
"WindowStartupLocation", typeof(WindowStartupLocation), typeof(DialogWindowProperty), new PropertyMetadata(WindowStartupLocation.CenterScreen));
|
||||
public static void SetWindowStartupLocation(DependencyObject element, WindowStartupLocation value)
|
||||
=> element.SetValue(WindowStartupLocationProperty, value);
|
||||
public static WindowStartupLocation GetWindowStartupLocation(DependencyObject element)
|
||||
=> (WindowStartupLocation)element.GetValue(WindowStartupLocationProperty);
|
||||
|
||||
|
||||
public static readonly DependencyProperty ResizeModeProperty = DependencyProperty.RegisterAttached(
|
||||
"ResizeMode", typeof(ResizeMode), typeof(DialogWindowProperty), new PropertyMetadata(ResizeMode.CanResize));
|
||||
public static void SetResizeMode(DependencyObject element, ResizeMode value)
|
||||
=> element.SetValue(ResizeModeProperty, value);
|
||||
public static ResizeMode GetResizeMode(DependencyObject element)
|
||||
=> (ResizeMode)element.GetValue(ResizeModeProperty);
|
||||
|
||||
|
||||
|
||||
public static readonly DependencyProperty WidthProperty = DependencyProperty.RegisterAttached(
|
||||
"Width", typeof(double), typeof(DialogWindowProperty), new PropertyMetadata(double.NaN));
|
||||
public static void SetWidth(DependencyObject element, double value)
|
||||
=> element.SetValue(WidthProperty, value);
|
||||
public static double GetWidth(DependencyObject element)
|
||||
=> (double)element.GetValue(WidthProperty);
|
||||
|
||||
|
||||
|
||||
public static readonly DependencyProperty HeightProperty = DependencyProperty.RegisterAttached(
|
||||
"Height", typeof(double), typeof(DialogWindowProperty), new PropertyMetadata(double.NaN));
|
||||
public static void SetHeight(DependencyObject element, double value)
|
||||
=> element.SetValue(HeightProperty, value);
|
||||
public static double GetHeight(DependencyObject element)
|
||||
=> (double)element.GetValue(HeightProperty);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace _29WxOrderWpf.Common
|
||||
{
|
||||
public class Result
|
||||
{
|
||||
public Result()
|
||||
{
|
||||
|
||||
}
|
||||
public Result(int code, string msg)
|
||||
{
|
||||
Code = code;
|
||||
Msg = msg;
|
||||
}
|
||||
|
||||
public int Code { get; set; }
|
||||
public string Msg { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class Result<TData> : Result
|
||||
{
|
||||
public TData Data { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:c="clr-namespace:_29WxOrderWpf.Common"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
|
||||
xmlns:utils="clr-namespace:_29WxOrderWpf.Utils"
|
||||
@@ -37,7 +38,8 @@
|
||||
<ColumnDefinition Width="*"></ColumnDefinition>
|
||||
<ColumnDefinition Width="Auto"></ColumnDefinition>
|
||||
<ColumnDefinition Width="Auto"></ColumnDefinition>
|
||||
</Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"></ColumnDefinition>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
|
||||
<usercontrols:UcWaterMarkTextBox Grid.Column="0" x:Name="txtOrderId" Margin="0,0,8,0" Height="28" WaterMark="店铺单号" />
|
||||
@@ -93,7 +95,12 @@
|
||||
</Image>
|
||||
<CheckBox Content="自动读单" x:Name="chkAutoReadOrder" BorderBrush="{StaticResource mainColorBrush}" FontSize="12" HorizontalAlignment="Left"
|
||||
Margin="2,0" VerticalAlignment="Center" Checked="chkAutoReadBuyerOrder_CheckedChanged" Unchecked="chkAutoReadBuyerOrder_CheckedChanged" IsChecked="true"/>
|
||||
|
||||
</WrapPanel>
|
||||
|
||||
<Button c:DebugVisibility.Enable="True" Grid.Column="11" Content="利润统计" Style="{StaticResource BasePageButtonStyle}" Width="60" Height="28" Click="btn_ShowShopRevenueStatisticsView" Padding="4">
|
||||
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="gridListHeader" Margin="8,2,8,0" Grid.Row="2">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style TargetType="Ellipse" x:Key="LineEllipse">
|
||||
<Setter Property="Width" Value="7"></Setter>
|
||||
<Setter Property="Height" Value="7"></Setter>
|
||||
<Setter Property="Fill" Value="White"></Setter>
|
||||
<Setter Property="Stroke" Value="#1677ff"></Setter>
|
||||
<Setter Property="StrokeThickness" Value="2"></Setter>
|
||||
<Setter Property="ToolTipService.InitialShowDelay" Value="50"></Setter>
|
||||
<Setter Property="ToolTipService.BetweenShowDelay" Value="0" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Fill" Value="White" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,9 @@
|
||||
<UserControl x:Class="_29WxOrderWpf.Ctrls.Statistics.Charts.RevenueLineChart"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="320" d:DesignWidth="640">
|
||||
<Canvas x:Name="LayoutRoot" ClipToBounds="True"/>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using _29WxOrderWpf.Ctrls.Statistics.Models;
|
||||
|
||||
namespace _29WxOrderWpf.Ctrls.Statistics.Charts
|
||||
{
|
||||
/// <summary>
|
||||
/// 销售趋势折线图(轻量自绘,无第三方依赖)。
|
||||
/// 绑定 <see cref="Points"/>(IList<RevenueTrendPoint>)即可,数据变化时自动重绘。
|
||||
/// 鼠标悬停在数据点圆圈上时显示该点数据(日期 / 销售额 / 订单数)。
|
||||
/// </summary>
|
||||
public partial class RevenueLineChart : UserControl
|
||||
{
|
||||
public static readonly DependencyProperty PointsProperty =
|
||||
DependencyProperty.Register("Points", typeof(IList<RevenueTrendPoint>), typeof(RevenueLineChart),
|
||||
new PropertyMetadata(null, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>趋势数据点</summary>
|
||||
public IList<RevenueTrendPoint> Points
|
||||
{
|
||||
get { return (IList<RevenueTrendPoint>)GetValue(PointsProperty); }
|
||||
set { SetValue(PointsProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty LineBrushProperty =
|
||||
DependencyProperty.Register("LineBrush", typeof(Brush), typeof(RevenueLineChart),
|
||||
new PropertyMetadata(new SolidColorBrush(Color.FromRgb(22, 119, 255)), OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>折线颜色</summary>
|
||||
public Brush LineBrush
|
||||
{
|
||||
get { return (Brush)GetValue(LineBrushProperty); }
|
||||
set { SetValue(LineBrushProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty LineThicknessProperty =
|
||||
DependencyProperty.Register("LineThickness", typeof(double), typeof(RevenueLineChart),
|
||||
new PropertyMetadata(2d, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>折线粗细</summary>
|
||||
public double LineThickness
|
||||
{
|
||||
get { return (double)GetValue(LineThicknessProperty); }
|
||||
set { SetValue(LineThicknessProperty, value); }
|
||||
}
|
||||
|
||||
public RevenueLineChart()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += delegate { Render(); };
|
||||
SizeChanged += delegate { Render(); };
|
||||
}
|
||||
|
||||
private static void OnRenderPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
((RevenueLineChart)d).Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
LayoutRoot.Children.Clear();
|
||||
var points = Points;
|
||||
if (points == null || points.Count == 0 || ActualWidth <= 20 || ActualHeight <= 20)
|
||||
return;
|
||||
|
||||
const double marginLeft = 52;
|
||||
const double marginRight = 16;
|
||||
const double marginTop = 18;
|
||||
const double marginBottom = 30;
|
||||
|
||||
double plotWidth = Math.Max(10, ActualWidth - marginLeft - marginRight);
|
||||
double plotHeight = Math.Max(10, ActualHeight - marginTop - marginBottom);
|
||||
|
||||
var values = points.Select(p => (double)p.SalesAmount).ToList();
|
||||
double maxValue = values.Max();
|
||||
if (maxValue <= 0) maxValue = 1;
|
||||
double yMax = maxValue * 1.15;
|
||||
|
||||
var lineBrush = LineBrush ?? new SolidColorBrush(Color.FromRgb(22, 119, 255));
|
||||
Color lineColor = lineBrush is SolidColorBrush ? ((SolidColorBrush)lineBrush).Color : Color.FromRgb(22, 119, 255);
|
||||
|
||||
int count = points.Count;
|
||||
double stepX = count <= 1 ? 0 : plotWidth / (count - 1);
|
||||
|
||||
Func<int, double> getX = i => marginLeft + stepX * i;
|
||||
Func<double, double> getY = v => marginTop + plotHeight * (1 - v / yMax);
|
||||
|
||||
// 1. 横向网格线 + Y 轴刻度
|
||||
for (int k = 0; k <= 4; k++)
|
||||
{
|
||||
double ratio = k / 4.0;
|
||||
double y = marginTop + plotHeight * ratio;
|
||||
double v = yMax * (1 - ratio);
|
||||
|
||||
LayoutRoot.Children.Add(new Line
|
||||
{
|
||||
X1 = marginLeft, Y1 = y, X2 = marginLeft + plotWidth, Y2 = y,
|
||||
Stroke = new SolidColorBrush(Color.FromRgb(238, 238, 238)),
|
||||
StrokeThickness = 1
|
||||
});
|
||||
|
||||
var yLabel = new TextBlock
|
||||
{
|
||||
Text = FormatValue(v),
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(153, 153, 153)),
|
||||
Width = marginLeft - 6,
|
||||
TextAlignment = TextAlignment.Right
|
||||
};
|
||||
LayoutRoot.Children.Add(yLabel);
|
||||
Canvas.SetLeft(yLabel, 0);
|
||||
Canvas.SetTop(yLabel, y - 8);
|
||||
}
|
||||
|
||||
// 2. 面积填充
|
||||
if (count > 1)
|
||||
{
|
||||
var figure = new PathFigure { StartPoint = new Point(getX(0), getY(values[0])) };
|
||||
for (int i = 1; i < count; i++)
|
||||
figure.Segments.Add(new LineSegment { Point = new Point(getX(i), getY(values[i])) });
|
||||
figure.Segments.Add(new LineSegment { Point = new Point(getX(count - 1), marginTop + plotHeight) });
|
||||
figure.Segments.Add(new LineSegment { Point = new Point(getX(0), marginTop + plotHeight) });
|
||||
figure.IsClosed = true;
|
||||
|
||||
var gradient = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new Point(0, 0),
|
||||
EndPoint = new Point(0, 1)
|
||||
};
|
||||
gradient.GradientStops.Add(new GradientStop(Color.FromArgb(64, lineColor.R, lineColor.G, lineColor.B), 0));
|
||||
gradient.GradientStops.Add(new GradientStop(Color.FromArgb(8, lineColor.R, lineColor.G, lineColor.B), 1));
|
||||
|
||||
LayoutRoot.Children.Add(new Path
|
||||
{
|
||||
Data = new PathGeometry { Figures = { figure } },
|
||||
Fill = gradient
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 折线
|
||||
var polylinePoints = new PointCollection();
|
||||
for (int i = 0; i < count; i++)
|
||||
polylinePoints.Add(new Point(getX(i), getY(values[i])));
|
||||
|
||||
LayoutRoot.Children.Add(new Polyline
|
||||
{
|
||||
Points = polylinePoints,
|
||||
Stroke = lineBrush,
|
||||
StrokeThickness = LineThickness,
|
||||
StrokeLineJoin = PenLineJoin.Round
|
||||
});
|
||||
|
||||
// 4. 数据点(点较多时省略;悬停显示该点数据)
|
||||
if (count <= 31)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var dot = new Ellipse
|
||||
{
|
||||
ToolTip = CreateTooltip(points[i])
|
||||
};
|
||||
dot. Style = (Style)FindResource("LineEllipse");
|
||||
//// 悬停时高亮圆点
|
||||
//dot.MouseEnter += (s, e) => dot.Fill = lineBrush;
|
||||
//dot.MouseLeave += (s, e) => dot.Fill = Brushes.White;
|
||||
|
||||
LayoutRoot.Children.Add(dot);
|
||||
Canvas.SetLeft(dot, getX(i) - 3.5);
|
||||
Canvas.SetTop(dot, getY(values[i]) - 3.5);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. X 轴日期刻度(最多显示 8 个)
|
||||
int labelStep = Math.Max(1, (int)Math.Ceiling(count / 8.0));
|
||||
for (int i = 0; i < count; i += labelStep)
|
||||
{
|
||||
var tb = new TextBlock
|
||||
{
|
||||
Text = points[i].DateText,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(153, 153, 153)),
|
||||
TextAlignment = TextAlignment.Center,
|
||||
Width = 60
|
||||
};
|
||||
LayoutRoot.Children.Add(tb);
|
||||
double x = getX(i) - 30;
|
||||
x = Math.Max(0, Math.Min(ActualWidth - 60, x));
|
||||
Canvas.SetLeft(tb, x);
|
||||
Canvas.SetTop(tb, marginTop + plotHeight + 6);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>生成数据点悬浮提示(日期 / 销售额 / 订单数)</summary>
|
||||
private static object CreateTooltip(RevenueTrendPoint p)
|
||||
{
|
||||
var panel = new StackPanel();
|
||||
var txtBrush = new SolidColorBrush(Color.FromRgb(51, 51, 51));
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = p.Date.ToString("yyyy-MM-dd"),
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = txtBrush
|
||||
});
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "销售额:" + FormatValue((double)p.SalesAmount) + "元",
|
||||
FontSize = 11,
|
||||
Foreground = txtBrush
|
||||
});
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "订单数:" + p.OrderCount + " 单",
|
||||
FontSize = 11,
|
||||
Foreground = txtBrush
|
||||
});
|
||||
|
||||
return new Border
|
||||
{
|
||||
Background = Brushes.White,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(8, 5, 8, 5),
|
||||
Child = panel
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatValue(double value)
|
||||
{
|
||||
double abs = Math.Abs(value);
|
||||
if (abs >= 100000000)
|
||||
return (value / 100000000d).ToString("0.#") + "亿";
|
||||
if (abs >= 10000)
|
||||
return (value / 10000d).ToString("0.#") + "万";
|
||||
return value.ToString("#,##0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<UserControl x:Class="_29WxOrderWpf.Ctrls.Statistics.Charts.RevenuePieChart"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="320" d:DesignWidth="520">
|
||||
<Canvas x:Name="LayoutRoot" ClipToBounds="True"/>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,425 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using _29WxOrderWpf.Ctrls.Statistics.Models;
|
||||
|
||||
namespace _29WxOrderWpf.Ctrls.Statistics.Charts
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺销售占比饼图(轻量自绘,无第三方依赖)。
|
||||
/// 绑定 <see cref="ItemsSource"/>(IList<ShopSalesProportionItem>)即可,数据变化时自动重绘。
|
||||
/// 设置 <see cref="MaxItems"/>(>0)可只显示前 N 项,其余项合并为一个“<see cref="OtherLabel"/>”扇区(百分比为剩余总和,整体仍为 100%)。
|
||||
/// 设置 <see cref="Title"/> 可在环形图中央留白处显示标题(<see cref="TitlePlacement"/> 可切换为顶部),不会遮挡扇形;
|
||||
/// 图例通过 <see cref="LegendPosition"/> 可放置到 4 个角,饼图区域由独立 panel 包裹,方便整体挪动和调整大小。
|
||||
/// </summary>
|
||||
public partial class RevenuePieChart : UserControl
|
||||
{
|
||||
/// <summary>标题显示位置</summary>
|
||||
public enum PieTitlePlacement
|
||||
{
|
||||
/// <summary>居中显示在环形图中央留白处(默认,不遮挡扇形)</summary>
|
||||
Center,
|
||||
|
||||
/// <summary>显示在图表顶部</summary>
|
||||
Top
|
||||
}
|
||||
|
||||
/// <summary>图例显示位置(4 个角)</summary>
|
||||
public enum PieLegendPosition
|
||||
{
|
||||
/// <summary>左上角</summary>
|
||||
TopLeft,
|
||||
|
||||
/// <summary>右上角(默认)</summary>
|
||||
TopRight,
|
||||
|
||||
/// <summary>左下角</summary>
|
||||
BottomLeft,
|
||||
|
||||
/// <summary>右下角</summary>
|
||||
BottomRight
|
||||
}
|
||||
|
||||
/// <summary>待绘制扇区(含颜色)</summary>
|
||||
private sealed class DisplaySlice
|
||||
{
|
||||
public ShopSalesProportionItem Item;
|
||||
public Brush Brush;
|
||||
}
|
||||
|
||||
/// <summary>ECharts 默认配色</summary>
|
||||
private static readonly Color[] Palette =
|
||||
{
|
||||
Color.FromRgb(84, 112, 198),
|
||||
Color.FromRgb(145, 204, 117),
|
||||
Color.FromRgb(250, 200, 88),
|
||||
Color.FromRgb(238, 102, 102),
|
||||
Color.FromRgb(115, 192, 222),
|
||||
Color.FromRgb(59, 162, 114),
|
||||
Color.FromRgb(252, 132, 82),
|
||||
Color.FromRgb(154, 96, 180),
|
||||
Color.FromRgb(234, 124, 204)
|
||||
};
|
||||
|
||||
/// <summary>“其他”合并项默认颜色(灰色)</summary>
|
||||
private static readonly Color OtherColor = Color.FromRgb(192, 196, 204);
|
||||
|
||||
public static readonly DependencyProperty ItemsSourceProperty =
|
||||
DependencyProperty.Register("ItemsSource", typeof(IList<ShopSalesProportionItem>), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(null, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>占比数据</summary>
|
||||
public IList<ShopSalesProportionItem> ItemsSource
|
||||
{
|
||||
get { return (IList<ShopSalesProportionItem>)GetValue(ItemsSourceProperty); }
|
||||
set { SetValue(ItemsSourceProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty MaxItemsProperty =
|
||||
DependencyProperty.Register("MaxItems", typeof(int), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(0, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>最多显示的独立扇区数(>0 时其余项合并为“其他”;<=0 显示全部)</summary>
|
||||
public int MaxItems
|
||||
{
|
||||
get { return (int)GetValue(MaxItemsProperty); }
|
||||
set { SetValue(MaxItemsProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty OtherLabelProperty =
|
||||
DependencyProperty.Register("OtherLabel", typeof(string), typeof(RevenuePieChart),
|
||||
new PropertyMetadata("其他", OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>合并项的显示名称(默认“其他”)</summary>
|
||||
public string OtherLabel
|
||||
{
|
||||
get { return (string)GetValue(OtherLabelProperty); }
|
||||
set { SetValue(OtherLabelProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty OtherBrushProperty =
|
||||
DependencyProperty.Register("OtherBrush", typeof(Brush), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(new SolidColorBrush(OtherColor), OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>合并项的颜色(默认灰色)</summary>
|
||||
public Brush OtherBrush
|
||||
{
|
||||
get { return (Brush)GetValue(OtherBrushProperty); }
|
||||
set { SetValue(OtherBrushProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty TitleProperty =
|
||||
DependencyProperty.Register("Title", typeof(string), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(null, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>标题文本(空/Null 不显示)</summary>
|
||||
public string Title
|
||||
{
|
||||
get { return (string)GetValue(TitleProperty); }
|
||||
set { SetValue(TitleProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty TitlePlacementProperty =
|
||||
DependencyProperty.Register("TitlePlacement", typeof(PieTitlePlacement), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(PieTitlePlacement.Center, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>标题显示位置(默认居中显示在环形图留白处,不遮挡扇形)</summary>
|
||||
public PieTitlePlacement TitlePlacement
|
||||
{
|
||||
get { return (PieTitlePlacement)GetValue(TitlePlacementProperty); }
|
||||
set { SetValue(TitlePlacementProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty TitleFontSizeProperty =
|
||||
DependencyProperty.Register("TitleFontSize", typeof(double), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(14d, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>标题字号</summary>
|
||||
public double TitleFontSize
|
||||
{
|
||||
get { return (double)GetValue(TitleFontSizeProperty); }
|
||||
set { SetValue(TitleFontSizeProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty TitleBrushProperty =
|
||||
DependencyProperty.Register("TitleBrush", typeof(Brush), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(new SolidColorBrush(Color.FromRgb(51, 51, 51)), OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>标题颜色</summary>
|
||||
public Brush TitleBrush
|
||||
{
|
||||
get { return (Brush)GetValue(TitleBrushProperty); }
|
||||
set { SetValue(TitleBrushProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty LegendPositionProperty =
|
||||
DependencyProperty.Register("LegendPosition", typeof(PieLegendPosition), typeof(RevenuePieChart),
|
||||
new PropertyMetadata(PieLegendPosition.TopRight, OnRenderPropertyChanged));
|
||||
|
||||
/// <summary>图例显示位置(默认右上角,与饼图分列放置,不遮挡)</summary>
|
||||
public PieLegendPosition LegendPosition
|
||||
{
|
||||
get { return (PieLegendPosition)GetValue(LegendPositionProperty); }
|
||||
set { SetValue(LegendPositionProperty, value); }
|
||||
}
|
||||
|
||||
public RevenuePieChart()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += delegate { Render(); };
|
||||
SizeChanged += delegate { Render(); };
|
||||
}
|
||||
|
||||
private static void OnRenderPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
((RevenuePieChart)d).Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
LayoutRoot.Children.Clear();
|
||||
var items = ItemsSource;
|
||||
if (items == null || items.Count == 0 || ActualWidth <= 20 || ActualHeight <= 20)
|
||||
return;
|
||||
|
||||
double width = ActualWidth;
|
||||
double height = ActualHeight;
|
||||
|
||||
// 图例列宽:图例占左/右一列,饼图占另一列,天然不会互相遮挡
|
||||
double legendWidth = Math.Min(210, width * 0.38);
|
||||
bool legendOnLeft = LegendPosition == PieLegendPosition.TopLeft
|
||||
|| LegendPosition == PieLegendPosition.BottomLeft;
|
||||
|
||||
double pieLeft = legendOnLeft ? legendWidth : 0;
|
||||
double pieWidth = Math.Max(10, width - legendWidth);
|
||||
double pieHeight = height;
|
||||
|
||||
// 顶部标题:先预留高度,饼图相应下移,避免遮挡
|
||||
double topTitleHeight = 0;
|
||||
if (TitlePlacement == PieTitlePlacement.Top && !string.IsNullOrEmpty(Title))
|
||||
{
|
||||
var probe = CreateTitleBlock(pieWidth);
|
||||
probe.Measure(new Size(pieWidth, double.PositiveInfinity));
|
||||
topTitleHeight = Math.Ceiling(probe.DesiredSize.Height) + 8;
|
||||
}
|
||||
|
||||
// 饼图区域用独立 Canvas 包裹,方便整体挪动(SetLeft/SetTop)和修改大小(Width/Height)
|
||||
var piePanel = new Canvas { Width = pieWidth, Height = pieHeight };
|
||||
LayoutRoot.Children.Add(piePanel);
|
||||
Canvas.SetLeft(piePanel, pieLeft);
|
||||
Canvas.SetTop(piePanel, 0);
|
||||
|
||||
double cx = pieWidth / 2;
|
||||
double cy = (topTitleHeight + pieHeight) / 2;
|
||||
double radius = Math.Max(10, Math.Min(pieWidth, pieHeight - topTitleHeight) / 2 - 18);
|
||||
|
||||
// 0. 生成实际绘制的扇区列表(MaxItems > 0 时其余项合并为“其他”)
|
||||
var slices = BuildSlices(items);
|
||||
|
||||
// 1. 绘制扇形
|
||||
double startAngle = -90; // 从正上方开始,顺时针
|
||||
foreach (var slice in slices)
|
||||
{
|
||||
double sweep = slice.Item.Percentage / 100d * 360d;
|
||||
if (sweep < 0) sweep = 0;
|
||||
var brush = slice.Brush;
|
||||
|
||||
if (sweep >= 359.9)
|
||||
{
|
||||
var circle = new Ellipse
|
||||
{
|
||||
Width = radius * 2,
|
||||
Height = radius * 2,
|
||||
Fill = brush
|
||||
};
|
||||
piePanel.Children.Add(circle);
|
||||
Canvas.SetLeft(circle, cx - radius);
|
||||
Canvas.SetTop(circle, cy - radius);
|
||||
}
|
||||
else if (sweep > 0.01)
|
||||
{
|
||||
piePanel.Children.Add(CreateSlice(cx, cy, radius, startAngle, sweep, brush));
|
||||
}
|
||||
startAngle += sweep;
|
||||
}
|
||||
|
||||
// 2. 中间留白圆心
|
||||
double holeRadius = radius * 0.55;
|
||||
var hole = new Ellipse
|
||||
{
|
||||
Width = holeRadius * 2,
|
||||
Height = holeRadius * 2,
|
||||
Fill = Brushes.White
|
||||
};
|
||||
piePanel.Children.Add(hole);
|
||||
Canvas.SetLeft(hole, cx - holeRadius);
|
||||
Canvas.SetTop(hole, cy - holeRadius);
|
||||
|
||||
// 3. 标题(居中显示在环形图留白处,宽度限制在留白直径内,不遮挡扇形)
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
double maxW = TitlePlacement == PieTitlePlacement.Center
|
||||
? Math.Max(20, holeRadius * 2 - 8)
|
||||
: pieWidth;
|
||||
var titleBlock = CreateTitleBlock(maxW);
|
||||
titleBlock.Measure(new Size(maxW, double.PositiveInfinity));
|
||||
double w = Math.Min(titleBlock.DesiredSize.Width, maxW);
|
||||
double h = titleBlock.DesiredSize.Height;
|
||||
|
||||
piePanel.Children.Add(titleBlock);
|
||||
Canvas.SetLeft(titleBlock, cx - w / 2);
|
||||
Canvas.SetTop(titleBlock, TitlePlacement == PieTitlePlacement.Center ? cy - h / 2 : 0);
|
||||
}
|
||||
|
||||
// 4. 图例(4 个角可设置,与饼图分列,不遮挡)
|
||||
var legendPanel = CreateLegendPanel(slices, legendWidth - 16);
|
||||
legendPanel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
double legendW = legendPanel.DesiredSize.Width;
|
||||
double legendH = legendPanel.DesiredSize.Height;
|
||||
|
||||
const double padding = 8;
|
||||
double lx, ly;
|
||||
if (LegendPosition == PieLegendPosition.TopLeft || LegendPosition == PieLegendPosition.TopRight)
|
||||
ly = padding;
|
||||
else
|
||||
ly = Math.Max(padding, height - legendH - padding);
|
||||
lx = legendOnLeft ? padding : width - legendW - padding;
|
||||
|
||||
LayoutRoot.Children.Add(legendPanel);
|
||||
Canvas.SetLeft(legendPanel, lx);
|
||||
Canvas.SetTop(legendPanel, ly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 <see cref="MaxItems"/> 生成实际绘制的扇区列表:
|
||||
/// 按占比从大到小取前 N 项单独显示,其余项合并为一个“其他”扇区(百分比为剩余总和,整体仍为 100%)。
|
||||
/// </summary>
|
||||
private List<DisplaySlice> BuildSlices(IList<ShopSalesProportionItem> items)
|
||||
{
|
||||
var result = new List<DisplaySlice>();
|
||||
if (items == null || items.Count == 0)
|
||||
return result;
|
||||
|
||||
var ordered = items
|
||||
.OrderByDescending(i => i.Percentage)
|
||||
.ThenByDescending(i => i.SalesAmount)
|
||||
.ToList();
|
||||
|
||||
int max = MaxItems > 0 ? MaxItems : ordered.Count;
|
||||
if (max >= ordered.Count)
|
||||
{
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
result.Add(new DisplaySlice
|
||||
{
|
||||
Item = ordered[i],
|
||||
Brush = new SolidColorBrush(Palette[i % Palette.Length])
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
for (int i = 0; i < max; i++)
|
||||
result.Add(new DisplaySlice
|
||||
{
|
||||
Item = ordered[i],
|
||||
Brush = new SolidColorBrush(Palette[i % Palette.Length])
|
||||
});
|
||||
|
||||
var rest = ordered.Skip(max).ToList();
|
||||
result.Add(new DisplaySlice
|
||||
{
|
||||
Item = new ShopSalesProportionItem
|
||||
{
|
||||
ShopName = string.IsNullOrEmpty(OtherLabel) ? "其他" : OtherLabel,
|
||||
Percentage = rest.Sum(r => r.Percentage),
|
||||
SalesAmount = rest.Sum(r => r.SalesAmount)
|
||||
},
|
||||
Brush = OtherBrush ?? new SolidColorBrush(OtherColor)
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private TextBlock CreateTitleBlock(double maxWidth)
|
||||
{
|
||||
var brush = TitleBrush ?? new SolidColorBrush(Color.FromRgb(51, 51, 51));
|
||||
return new TextBlock
|
||||
{
|
||||
Text = Title,
|
||||
FontSize = TitleFontSize,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = brush,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxWidth = maxWidth
|
||||
};
|
||||
}
|
||||
|
||||
private static StackPanel CreateLegendPanel(IList<DisplaySlice> slices, double maxWidth)
|
||||
{
|
||||
double maxLegendWidth = Math.Max(60, maxWidth);
|
||||
var legendPanel = new StackPanel { MaxWidth = maxLegendWidth };
|
||||
foreach (var slice in slices)
|
||||
{
|
||||
var legendRow = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(0, 4, 0, 4)
|
||||
};
|
||||
legendRow.Children.Add(new Rectangle
|
||||
{
|
||||
Width = 12,
|
||||
Height = 12,
|
||||
Fill = slice.Brush
|
||||
});
|
||||
|
||||
var text = new TextBlock
|
||||
{
|
||||
Text = slice.Item.ShopName + " " + slice.Item.Percentage.ToString("0.0") + "%",
|
||||
FontSize = 12,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(102, 102, 102)),
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
MaxWidth = Math.Max(40, maxLegendWidth - 18),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
};
|
||||
legendRow.Children.Add(text);
|
||||
legendPanel.Children.Add(legendRow);
|
||||
}
|
||||
return legendPanel;
|
||||
}
|
||||
|
||||
private static Path CreateSlice(double cx, double cy, double radius, double startAngle, double sweep, Brush fill)
|
||||
{
|
||||
var start = PointOnCircle(cx, cy, radius, startAngle);
|
||||
var end = PointOnCircle(cx, cy, radius, startAngle + sweep);
|
||||
|
||||
var figure = new PathFigure { StartPoint = new Point(cx, cy), IsClosed = true, IsFilled = true };
|
||||
figure.Segments.Add(new LineSegment { Point = start });
|
||||
figure.Segments.Add(new ArcSegment
|
||||
{
|
||||
Point = end,
|
||||
Size = new Size(radius, radius),
|
||||
IsLargeArc = sweep > 180,
|
||||
SweepDirection = SweepDirection.Clockwise
|
||||
});
|
||||
|
||||
return new Path
|
||||
{
|
||||
Data = new PathGeometry { Figures = { figure } },
|
||||
Fill = fill,
|
||||
Stroke = Brushes.White,
|
||||
StrokeThickness = 1
|
||||
};
|
||||
}
|
||||
|
||||
private static Point PointOnCircle(double cx, double cy, double radius, double angle)
|
||||
{
|
||||
double rad = angle * Math.PI / 180d;
|
||||
return new Point(cx + radius * Math.Cos(rad), cy + radius * Math.Sin(rad));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Charts\Chart.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,256 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace _29WxOrderWpf.Ctrls.Statistics.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺营收统计 - 查询参数
|
||||
/// </summary>
|
||||
public class ShopRevenueStatisticsQuery
|
||||
{
|
||||
/// <summary>开始时间</summary>
|
||||
public DateTime StartDate { get; set; }
|
||||
|
||||
/// <summary>结束时间</summary>
|
||||
public DateTime EndDate { get; set; }
|
||||
|
||||
/// <summary>店铺 app_key(A.app_key),空/Null 表示全部店铺</summary>
|
||||
public string ShopKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 店铺营收统计 - 接口返回的原始明细行。
|
||||
/// 对应接口返回字段:
|
||||
/// A.app_key, B.create_time, B.order_id, B.status,
|
||||
/// B.product_price, B.order_price, B.purchase_price, B.freight,
|
||||
/// B.predict_commission_fee, B.commission_amount, B.discounted_price, B.profit,
|
||||
/// B.purchase_status, c.refund_status
|
||||
/// 其中 A 为店铺表,B 为订单表,c 为售后/退款表。
|
||||
/// 金额字段单位均为“分”,展示时需除以 100。
|
||||
/// </summary>
|
||||
public class ShopRevenueStatisticsRow
|
||||
{
|
||||
/// <summary>A.app_key 店铺 app_key</summary>
|
||||
[JsonProperty("app_key")]
|
||||
public string AppKey { get; set; }
|
||||
|
||||
/// <summary>B.create_time 订单创建时间(接口为秒级时间戳,由 UnixDateTimeConverter 转换为 DateTime)</summary>
|
||||
[JsonProperty("create_time")]
|
||||
[JsonConverter(typeof(UnixDateTimeConverter))]
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>B.order_id 订单号</summary>
|
||||
[JsonProperty("order_id")]
|
||||
public string OrderId { get; set; }
|
||||
|
||||
/// <summary>B.status 订单状态:10-待付款;20-待发货;21-部分发货;30-待收货;100-完成;200-全部商品售后之后订单取消;250-未付款用户主动取消或超时未付款订单自动取消</summary>
|
||||
public int Status { get; set; }
|
||||
|
||||
/// <summary>B.product_price 商品总价(分)</summary>
|
||||
[JsonProperty("product_price")]
|
||||
public int ProductPrice { get; set; }
|
||||
|
||||
/// <summary>B.order_price 订单金额(分),计算公式:order_price = original_order_price - discounted_price - deduction_price - change_down_price</summary>
|
||||
[JsonProperty("order_price")]
|
||||
public int OrderPrice { get; set; }
|
||||
|
||||
/// <summary>B.purchase_price 采购价(分)</summary>
|
||||
[JsonProperty("purchase_price")]
|
||||
public int PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>B.freight 运费(分)</summary>
|
||||
public int Freight { get; set; }
|
||||
|
||||
/// <summary>B.predict_commission_fee 平台服务费(微信小店:预计技术服务费)(分)</summary>
|
||||
[JsonProperty("predict_commission_fee")]
|
||||
public int PredictCommissionFee { get; set; }
|
||||
|
||||
/// <summary>B.commission_amount 达人佣金(微信小店:分账金额)(分)</summary>
|
||||
[JsonProperty("commission_amount")]
|
||||
public int CommissionAmount { get; set; }
|
||||
|
||||
/// <summary>B.discounted_price 优惠券优惠金额(分)</summary>
|
||||
[JsonProperty("discounted_price")]
|
||||
public int DiscountedPrice { get; set; }
|
||||
|
||||
/// <summary>B.profit 利润(分),计算公式:实付金额 - 采购价 - 平台服务费 - 达人佣金(采购价、达人佣金为空值则按 0)</summary>
|
||||
public int Profit { get; set; }
|
||||
|
||||
/// <summary>B.purchase_status 采购状态:0-未采购;1-采购(默认值 0,推送仓库时修改为 1)</summary>
|
||||
[JsonProperty("purchase_status")]
|
||||
public int PurchaseStatus { get; set; }
|
||||
|
||||
/// <summary>c.refund_status 退款状态(4/11 视为已退款,与 RevenueStatisticsAggregator.IsRefunded 保持一致)</summary>
|
||||
[JsonProperty("refund_status")]
|
||||
public int RefundStatus { get; set; }
|
||||
|
||||
/// <summary>店铺名称(由 app_key 关联店铺表得到,展示用,非接口原始字段)</summary>
|
||||
[JsonProperty("shop_name")]
|
||||
public string ShopName { get; set; }
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 顶部汇总卡片数据(金额单位:元)
|
||||
/// </summary>
|
||||
public class ShopRevenueSummary
|
||||
{
|
||||
/// <summary>销售额(最终有效销售额)</summary>
|
||||
public decimal SalesAmount { get; set; }
|
||||
|
||||
/// <summary>有效订单数(已扣除退款订单)</summary>
|
||||
public int ValidOrderCount { get; set; }
|
||||
|
||||
/// <summary>利润(实际利润)</summary>
|
||||
public decimal Profit { get; set; }
|
||||
|
||||
/// <summary>退款金额(统计期间退款)</summary>
|
||||
public decimal RefundAmount { get; set; }
|
||||
|
||||
/// <summary>商品销量(有效销量)</summary>
|
||||
public int ProductSalesVolume { get; set; }
|
||||
|
||||
/// <summary>客户数(成交客户)</summary>
|
||||
public int CustomerCount { get; set; }
|
||||
|
||||
/// <summary>客单价(平均每单金额)</summary>
|
||||
public decimal AvgOrderAmount { get; set; }
|
||||
|
||||
/// <summary>退款率(退款订单占比,百分数,如 2.31 表示 2.31%)</summary>
|
||||
public decimal RefundRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 销售趋势数据点(金额单位:元)
|
||||
/// </summary>
|
||||
public class RevenueTrendPoint
|
||||
{
|
||||
/// <summary>日期</summary>
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
/// <summary>日期文本(MM-dd)</summary>
|
||||
public string DateText { get { return Date.ToString("MM-dd"); } }
|
||||
|
||||
/// <summary>当日销售额(元)</summary>
|
||||
public decimal SalesAmount { get; set; }
|
||||
|
||||
/// <summary>当日有效订单数</summary>
|
||||
public int OrderCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 店铺销售占比数据(金额单位:元)
|
||||
/// </summary>
|
||||
public class ShopSalesProportionItem
|
||||
{
|
||||
/// <summary>店铺名称</summary>
|
||||
public string ShopName { get; set; }
|
||||
|
||||
/// <summary>销售额(元)</summary>
|
||||
public decimal SalesAmount { get; set; }
|
||||
|
||||
/// <summary>占比(0~100)</summary>
|
||||
public double Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 店铺统计表格行(金额单位:元)
|
||||
/// </summary>
|
||||
public class ShopStatisticsItem
|
||||
{
|
||||
/// <summary>店铺名称</summary>
|
||||
public string ShopName { get; set; }
|
||||
|
||||
/// <summary>订单数</summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>销售额(元)</summary>
|
||||
public decimal SalesAmount { get; set; }
|
||||
|
||||
/// <summary>利润(元)</summary>
|
||||
public decimal Profit { get; set; }
|
||||
|
||||
/// <summary>退款金额(元)</summary>
|
||||
public decimal RefundAmount { get; set; }
|
||||
|
||||
/// <summary>客户数</summary>
|
||||
public int CustomerCount { get; set; }
|
||||
|
||||
/// <summary>客单价(元)</summary>
|
||||
public decimal AvgOrderAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 店铺下拉选项
|
||||
/// </summary>
|
||||
public class ShopOption
|
||||
{
|
||||
/// <summary>店铺 app_key(A.app_key)</summary>
|
||||
public string AppKey { get; set; }
|
||||
|
||||
/// <summary>店铺名称</summary>
|
||||
public string ShopName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 时间范围下拉选项
|
||||
/// </summary>
|
||||
public class TimeRangeOption
|
||||
{
|
||||
/// <summary>显示文本(如:今天、昨天、本周、本月、最近10天、自定义)</summary>
|
||||
public string Text { get; set; }
|
||||
|
||||
/// <summary>值(today / yesterday / week / month / last10 / custom)</summary>
|
||||
public string Value { get; set; }
|
||||
}
|
||||
|
||||
class UnixDateTimeConverter : JsonConverter
|
||||
{
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return objectType == typeof(DateTime);
|
||||
}
|
||||
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
// 读取数值(支持 long 和 int 类型)
|
||||
if (reader.TokenType == JsonToken.Integer)
|
||||
{
|
||||
long milliseconds = (long)reader.Value;
|
||||
// Unix 纪元:1970-01-01 UTC
|
||||
DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
if (milliseconds.ToString().Length == 13)
|
||||
{
|
||||
return epoch.AddMilliseconds(milliseconds);
|
||||
}
|
||||
return epoch.AddSeconds(milliseconds);
|
||||
|
||||
|
||||
}
|
||||
// 如果传入的是字符串(如 "1700000000000"),也支持
|
||||
else if (reader.TokenType == JsonToken.String)
|
||||
{
|
||||
if (long.TryParse((string)reader.Value, out long milliseconds))
|
||||
{
|
||||
DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
if (milliseconds.ToString().Length == 13)
|
||||
{
|
||||
return epoch.AddMilliseconds(milliseconds);
|
||||
}
|
||||
return epoch.AddSeconds(milliseconds);
|
||||
}
|
||||
}
|
||||
throw new JsonSerializationException($"无法将 {reader.Value} 转换为 DateTime");
|
||||
}
|
||||
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
// 序列化时转回时间戳
|
||||
DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
long milliseconds = (long)((DateTime)value - epoch).TotalMilliseconds;
|
||||
writer.WriteValue(milliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using _29WxOrderWpf.Common;
|
||||
using _29WxOrderWpf.Ctrls.Statistics.Models;
|
||||
using AllBase;
|
||||
using AllBase.Helper;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Work;
|
||||
|
||||
namespace _29WxOrderWpf.Ctrls.Statistics.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺营收统计 - 数据服务调用接口。
|
||||
/// 页面只依赖此接口,接入真实数据源时新建实现类(如对接 HTTP 接口 / SQL 查询),
|
||||
/// 并通过 ShopRevenueStatisticsViewModel 构造函数注入即可,无需改动页面。
|
||||
/// </summary>
|
||||
public interface IShopRevenueStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询原始统计明细(接口返回的 A.* / B.* / c.* 原始字段)。
|
||||
/// 真实实现通常通过 SQL / HTTP 一次性返回这些行,再由聚合器计算。
|
||||
/// </summary>
|
||||
Task<List<ShopRevenueStatisticsRow>> QueryRowsAsync(ShopRevenueStatisticsQuery query);
|
||||
|
||||
/// <summary>获取顶部汇总卡片数据</summary>
|
||||
Task<ShopRevenueSummary> GetSummaryAsync(ShopRevenueStatisticsQuery query);
|
||||
|
||||
/// <summary>获取销售趋势(按天)</summary>
|
||||
Task<List<RevenueTrendPoint>> GetSalesTrendAsync(ShopRevenueStatisticsQuery query);
|
||||
|
||||
/// <summary>获取店铺销售占比(饼图)</summary>
|
||||
Task<List<ShopSalesProportionItem>> GetShopSalesProportionAsync(ShopRevenueStatisticsQuery query);
|
||||
|
||||
/// <summary>获取店铺统计表格</summary>
|
||||
Task<List<ShopStatisticsItem>> GetShopStatisticsAsync(ShopRevenueStatisticsQuery query);
|
||||
|
||||
/// <summary>获取店铺下拉列表(用于查询条件)</summary>
|
||||
Task<List<ShopOption>> GetShopOptionsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统计聚合器:把接口返回的原始明细行聚合成页面所需的汇总/趋势/占比/表格数据。
|
||||
/// 聚合口径说明(依据接口字段描述):
|
||||
/// - 订单状态 status:10-待付款;20-待发货;21-部分发货;30-待收货;100-完成;
|
||||
/// 200-全部商品售后之后订单取消;250-未付款用户主动取消或超时未付款订单自动取消。
|
||||
/// - 有效订单:已付款(20/21/30/100)且未退款;10/250 未付款不计入销售额。
|
||||
/// - 已退款:c.refund_status 为 4/11,或订单状态为 200(全部商品售后后取消)。
|
||||
/// - 金额字段单位均为“分”,除以 100 转为元。
|
||||
/// </summary>
|
||||
public static class RevenueStatisticsAggregator
|
||||
{
|
||||
/// <summary>订单状态 - 待付款</summary>
|
||||
public const int StatusPendingPay = 10;
|
||||
|
||||
/// <summary>订单状态 - 待发货</summary>
|
||||
public const int StatusWaitSend = 20;
|
||||
|
||||
/// <summary>订单状态 - 部分发货</summary>
|
||||
public const int StatusPartSend = 21;
|
||||
|
||||
/// <summary>订单状态 - 待收货</summary>
|
||||
public const int StatusWaitReceive = 30;
|
||||
|
||||
/// <summary>订单状态 - 完成</summary>
|
||||
public const int StatusFinished = 100;
|
||||
|
||||
/// <summary>订单状态 - 全部商品售后之后订单取消</summary>
|
||||
public const int StatusCancelAfterAfterSale = 200;
|
||||
|
||||
/// <summary>订单状态 - 未付款用户主动取消/超时未付款自动取消</summary>
|
||||
public const int StatusCancelNotPay = 250;
|
||||
|
||||
/// <summary>是否已付款的订单(20/21/30/100;10 待付款、250 未付款取消不计入)</summary>
|
||||
public static bool IsPaidOrder(ShopRevenueStatisticsRow row)
|
||||
{
|
||||
if (row == null) return false;
|
||||
return row.Status == StatusWaitSend
|
||||
|| row.Status == StatusPartSend
|
||||
|| row.Status == StatusWaitReceive
|
||||
|| row.Status == StatusFinished;
|
||||
}
|
||||
|
||||
/// <summary>是否已退款(c.refund_status 为 4/11,或订单状态为 200 全部商品售后后取消)</summary>
|
||||
public static bool IsRefunded(ShopRevenueStatisticsRow row)
|
||||
{
|
||||
if (row == null) return false;
|
||||
return row.RefundStatus == 4 || row.RefundStatus == 11 || row.Status == StatusCancelAfterAfterSale;
|
||||
}
|
||||
|
||||
/// <summary>是否有效订单(已付款且未退款)</summary>
|
||||
public static bool IsValidOrder(ShopRevenueStatisticsRow row)
|
||||
{
|
||||
return IsPaidOrder(row) && !IsRefunded(row);
|
||||
}
|
||||
|
||||
/// <summary>汇总卡片</summary>
|
||||
public static ShopRevenueSummary GetSummary(IEnumerable<ShopRevenueStatisticsRow> rows)
|
||||
{
|
||||
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).ToList();
|
||||
var valid = list.Where(IsValidOrder).ToList();
|
||||
var refunded = list.Where(IsRefunded).ToList();
|
||||
|
||||
decimal salesAmount = valid.Sum(r => (decimal)r.OrderPrice) / 100m;
|
||||
decimal profit = valid.Sum(r => (decimal)r.Profit) / 100m;
|
||||
decimal refundAmount = refunded.Sum(r => (decimal)r.OrderPrice) / 100m;
|
||||
int validOrderCount = valid.Select(r => r.OrderId).Distinct().Count();
|
||||
// 总订单数(退款率分母)= 已付款订单 + 已退款订单(含售后取消 200),不含未付款的 10/250
|
||||
int totalOrderCount = list.Where(r => IsPaidOrder(r) || IsRefunded(r)).Select(r => r.OrderId).Distinct().Count();
|
||||
int refundOrderCount = refunded.Select(r => r.OrderId).Distinct().Count();
|
||||
|
||||
return new ShopRevenueSummary
|
||||
{
|
||||
SalesAmount = salesAmount,
|
||||
ValidOrderCount = validOrderCount,
|
||||
Profit = profit,
|
||||
RefundAmount = refundAmount,
|
||||
ProductSalesVolume = valid.Count,
|
||||
CustomerCount = validOrderCount,
|
||||
AvgOrderAmount = validOrderCount == 0 ? 0m : salesAmount / validOrderCount,
|
||||
RefundRate = totalOrderCount == 0 ? 0m : (decimal)refundOrderCount * 100m / totalOrderCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>销售趋势(按天,缺失日期补 0,仅统计有效订单)</summary>
|
||||
public static List<RevenueTrendPoint> GetSalesTrend(IEnumerable<ShopRevenueStatisticsRow> rows, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).Where(IsValidOrder).ToList();
|
||||
var map = list
|
||||
.GroupBy(r => r.CreateTime.Date)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new RevenueTrendPoint
|
||||
{
|
||||
Date = g.Key,
|
||||
SalesAmount = g.Sum(r => (decimal)r.OrderPrice) / 100m,
|
||||
OrderCount = g.Select(r => r.OrderId).Distinct().Count()
|
||||
});
|
||||
|
||||
var result = new List<RevenueTrendPoint>();
|
||||
for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
|
||||
{
|
||||
if (map.TryGetValue(d, out var point))
|
||||
result.Add(point);
|
||||
else
|
||||
result.Add(new RevenueTrendPoint { Date = d, SalesAmount = 0m, OrderCount = 0 });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>店铺销售占比(饼图,仅统计有效订单)</summary>
|
||||
public static List<ShopSalesProportionItem> GetShopSalesProportion(IEnumerable<ShopRevenueStatisticsRow> rows)
|
||||
{
|
||||
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).Where(IsValidOrder).ToList();
|
||||
decimal total = list.Sum(r => (decimal)r.OrderPrice) / 100m;
|
||||
|
||||
return list
|
||||
.GroupBy(r => r.ShopName)
|
||||
.Select(g => new ShopSalesProportionItem
|
||||
{
|
||||
ShopName = string.IsNullOrEmpty(g.Key) ? "未知店铺" : g.Key,
|
||||
SalesAmount = g.Sum(r => (decimal)r.OrderPrice) / 100m,
|
||||
Percentage = total == 0m ? 0d : (double)(g.Sum(r => (decimal)r.OrderPrice) / 100m / total * 100m)
|
||||
})
|
||||
.OrderByDescending(i => i.SalesAmount)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>店铺统计表格</summary>
|
||||
public static List<ShopStatisticsItem> GetShopStatistics(IEnumerable<ShopRevenueStatisticsRow> rows)
|
||||
{
|
||||
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).ToList();
|
||||
|
||||
return list
|
||||
.GroupBy(r => r.ShopName)
|
||||
.Select(g =>
|
||||
{
|
||||
var valid = g.Where(IsValidOrder).ToList();
|
||||
var refunded = g.Where(IsRefunded).ToList();
|
||||
decimal salesAmount = valid.Sum(r => (decimal)r.OrderPrice) / 100m;
|
||||
int orderCount = valid.Select(r => r.OrderId).Distinct().Count();
|
||||
return new ShopStatisticsItem
|
||||
{
|
||||
ShopName = string.IsNullOrEmpty(g.Key) ? "未知店铺" : g.Key,
|
||||
OrderCount = orderCount,
|
||||
SalesAmount = salesAmount,
|
||||
Profit = valid.Sum(r => (decimal)r.Profit) / 100m,
|
||||
RefundAmount = refunded.Sum(r => (decimal)r.OrderPrice) / 100m,
|
||||
CustomerCount = orderCount,
|
||||
AvgOrderAmount = orderCount == 0 ? 0m : salesAmount / orderCount
|
||||
};
|
||||
})
|
||||
.OrderByDescending(i => i.SalesAmount)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 默认实现:返回模拟数据,用于页面演示/联调。
|
||||
/// 接入真实接口时,新建实现类并注入即可(见 IShopRevenueStatisticsService 注释)。
|
||||
/// </summary>
|
||||
public class ShopRevenueStatisticsService : IShopRevenueStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细数据缓存。
|
||||
/// key = 店铺id|yyyyMMdd(店铺 × 天),value = 该店铺当天的明细行。
|
||||
/// 查询按“店铺 × 日期”命中;完全未命中时所有店铺 + 整个时间范围一次请求,
|
||||
/// 部分命中时只请求缺失的店铺/日期段。
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, List<ShopRevenueStatisticsRow>> _rowCache =
|
||||
new Dictionary<string, List<ShopRevenueStatisticsRow>>();
|
||||
private readonly object _cacheLock = new object();
|
||||
|
||||
private sealed class DateRange
|
||||
{
|
||||
public DateTime Start;
|
||||
public DateTime End;
|
||||
}
|
||||
|
||||
private static string CacheKey(string shopId, DateTime day)
|
||||
{
|
||||
return shopId + "|" + day.ToString("yyyyMMdd");
|
||||
}
|
||||
|
||||
public async Task<List<ShopRevenueStatisticsRow>> QueryRowsAsync(ShopRevenueStatisticsQuery query)
|
||||
{
|
||||
// 1. 展开本次查询涉及的店铺(ShopKey 为空 = 全部店铺)
|
||||
List<string> shopIds;
|
||||
if (string.IsNullOrEmpty(query.ShopKey))
|
||||
shopIds = (await GetShopOptionsAsync()).Select(a => a.AppKey).ToList();
|
||||
else
|
||||
shopIds = new List<string> { query.ShopKey };
|
||||
if (shopIds.Count == 0)
|
||||
return new List<ShopRevenueStatisticsRow>();
|
||||
|
||||
var startDay = query.StartDate.Date;
|
||||
var endDay = query.EndDate.Date;
|
||||
int totalDays = (int)(endDay - startDay).Days + 1;
|
||||
|
||||
// 2. 命中检测:按 店铺 × 天 找出缺失项
|
||||
var missingByShop = new Dictionary<string, List<DateTime>>();
|
||||
int missingCount = 0;
|
||||
lock (_cacheLock)
|
||||
{
|
||||
foreach (var shopId in shopIds)
|
||||
{
|
||||
var missingDays = new List<DateTime>();
|
||||
for (var d = startDay; d <= endDay; d = d.AddDays(1))
|
||||
{
|
||||
if (!_rowCache.ContainsKey(CacheKey(shopId, d)))
|
||||
missingDays.Add(d);
|
||||
}
|
||||
missingCount += missingDays.Count;
|
||||
missingByShop[shopId] = missingDays;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 全部命中:直接从缓存拼装返回
|
||||
if (missingCount == 0)
|
||||
return GetCachedRows(shopIds, startDay, endDay);
|
||||
|
||||
// 4. 完全未命中:所有店铺 + 整个时间范围一次请求
|
||||
if (missingCount == shopIds.Count * totalDays)
|
||||
{
|
||||
var allRows = await RequestRowsAsync(shopIds, startDay, endDay);
|
||||
CacheResponse(shopIds, startDay, endDay, allRows, null);
|
||||
var cached = GetCachedRows(shopIds, startDay, endDay);
|
||||
// 若接口行内未返回 app_key 导致无法入库,则直接返回本次请求结果
|
||||
return cached.Count > 0 ? cached : allRows;
|
||||
}
|
||||
|
||||
// 5. 部分命中:只请求未命中的(店铺 × 连续缺失日期段)
|
||||
foreach (var pair in missingByShop)
|
||||
{
|
||||
if (pair.Value.Count == 0)
|
||||
continue;
|
||||
foreach (var run in BuildRuns(pair.Value))
|
||||
{
|
||||
var rows = await RequestRowsAsync(new List<string> { pair.Key }, run.Start, run.End);
|
||||
CacheResponse(new List<string> { pair.Key }, run.Start, run.End, rows, pair.Key);
|
||||
}
|
||||
}
|
||||
|
||||
return GetCachedRows(shopIds, startDay, endDay);
|
||||
}
|
||||
|
||||
/// <summary>按 店铺 × 日期 从缓存拼装结果</summary>
|
||||
private List<ShopRevenueStatisticsRow> GetCachedRows(List<string> shopIds, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var result = new List<ShopRevenueStatisticsRow>();
|
||||
lock (_cacheLock)
|
||||
{
|
||||
for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
|
||||
{
|
||||
foreach (var shopId in shopIds)
|
||||
{
|
||||
if (_rowCache.TryGetValue(CacheKey(shopId, d), out var list))
|
||||
result.AddRange(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>请求接口一次(可传多个店铺),返回原始明细行</summary>
|
||||
private async Task<List<ShopRevenueStatisticsRow>> RequestRowsAsync(List<string> shopIds, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var url = BaseClass.Build29WxObjString("shop_revenue_statistics/get_shop_order_amount");
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(UserLogin.GetSignaturex());
|
||||
sb.Append("&shop_ids=").Append(string.Join(",", shopIds));
|
||||
sb.Append("&start_time=").Append(BaseClass.ConvertDateTimeLong(startDate.Date));
|
||||
sb.Append("&end_time=").Append(BaseClass.ConvertDateTimeLong(endDate.Date.AddDays(1).AddTicks(-1)));
|
||||
var html = await GetHtml.PostUtf8Async(url, sb.ToString());
|
||||
|
||||
var ret = JsonConvert.DeserializeObject<Result<List<ShopRevenueStatisticsRow>>>(html, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
if (ret.Code != 200)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
}
|
||||
return ret.Data ?? new List<ShopRevenueStatisticsRow>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把一次请求的结果写入缓存(按 店铺id|日期 分桶)。
|
||||
/// 除有数据的 店铺×天 外,会把本次请求覆盖但无数据的 店铺×天 也标记为已缓存(空列表),
|
||||
/// 避免“该店铺当天无数据”被反复判定为未命中而重复请求数据库。
|
||||
/// </summary>
|
||||
private void CacheResponse(List<string> shopIds, DateTime startDate, DateTime endDate, List<ShopRevenueStatisticsRow> rows, string fallbackShopId)
|
||||
{
|
||||
if (rows == null)
|
||||
rows = new List<ShopRevenueStatisticsRow>();
|
||||
|
||||
// 能否按 店铺×天 归属:
|
||||
// 单店铺请求一定可以(用请求的店铺兜底);多店铺请求要求行内带 app_key;
|
||||
// 响应为空时也视为可归属(说明覆盖的 店铺×天 都无数据)。
|
||||
bool canAttribute = shopIds.Count == 1
|
||||
|| rows.Count == 0
|
||||
|| rows.Any(r => !string.IsNullOrEmpty(r.AppKey));
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (canAttribute)
|
||||
{
|
||||
// 覆盖的 店铺×天 全部落缓存键(无数据的天记为空的已缓存)
|
||||
for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
|
||||
{
|
||||
foreach (var shopId in shopIds)
|
||||
{
|
||||
var key = CacheKey(shopId, d);
|
||||
if (!_rowCache.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<ShopRevenueStatisticsRow>();
|
||||
_rowCache[key] = list;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 有数据的行按 店铺×天 追加
|
||||
foreach (var row in rows)
|
||||
{
|
||||
// 行内未带 app_key 时,用本次请求的店铺兜底
|
||||
var shopId = string.IsNullOrEmpty(row.AppKey) ? fallbackShopId : row.AppKey;
|
||||
if (string.IsNullOrEmpty(shopId))
|
||||
continue;
|
||||
var key = CacheKey(shopId, row.CreateTime.Date);
|
||||
if (!_rowCache.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<ShopRevenueStatisticsRow>();
|
||||
_rowCache[key] = list;
|
||||
}
|
||||
list.Add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把缺失日期合并为连续日期段(每段一次请求)</summary>
|
||||
private static List<DateRange> BuildRuns(List<DateTime> missingDays)
|
||||
{
|
||||
var sorted = missingDays.OrderBy(d => d).ToList();
|
||||
var runs = new List<DateRange>();
|
||||
int i = 0;
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
var start = sorted[i];
|
||||
var end = start;
|
||||
i++;
|
||||
while (i < sorted.Count && sorted[i] == end.AddDays(1))
|
||||
{
|
||||
end = sorted[i];
|
||||
i++;
|
||||
}
|
||||
runs.Add(new DateRange { Start = start, End = end });
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
public async Task<ShopRevenueSummary> GetSummaryAsync(ShopRevenueStatisticsQuery query)
|
||||
{
|
||||
var rows = await QueryRowsAsync(query);
|
||||
return RevenueStatisticsAggregator.GetSummary(rows);
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTrendPoint>> GetSalesTrendAsync(ShopRevenueStatisticsQuery query)
|
||||
{
|
||||
var rows = await QueryRowsAsync(query);
|
||||
return RevenueStatisticsAggregator.GetSalesTrend(rows, query.StartDate, query.EndDate);
|
||||
}
|
||||
|
||||
public async Task<List<ShopSalesProportionItem>> GetShopSalesProportionAsync(ShopRevenueStatisticsQuery query)
|
||||
{
|
||||
var rows = await QueryRowsAsync(query);
|
||||
return RevenueStatisticsAggregator.GetShopSalesProportion(rows);
|
||||
}
|
||||
|
||||
public async Task<List<ShopStatisticsItem>> GetShopStatisticsAsync(ShopRevenueStatisticsQuery query)
|
||||
{
|
||||
var rows = await QueryRowsAsync(query);
|
||||
return RevenueStatisticsAggregator.GetShopStatistics(rows);
|
||||
}
|
||||
|
||||
public Task<List<ShopOption>> GetShopOptionsAsync()
|
||||
{
|
||||
var list = GlobalConfig.ShopLoginList.Select(a =>
|
||||
new ShopOption()
|
||||
{
|
||||
AppKey = a.ShopId,
|
||||
ShopName = a.ShopAccount
|
||||
}).ToList();
|
||||
|
||||
return Task.FromResult(list);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<UserControl
|
||||
x:Class="_29WxOrderWpf.Ctrls.Statistics.ShopRevenueStatisticsView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:charts="clr-namespace:_29WxOrderWpf.Ctrls.Statistics.Charts"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:_29WxOrderWpf.Ctrls.Statistics.ViewModels"
|
||||
xmlns:wpf="clr-namespace:_29WxOrderWpf.Common"
|
||||
d:Background="#F5F7FB"
|
||||
d:DataContext="{d:DesignInstance Type=vm:ShopRevenueStatisticsViewModel,IsDesignTimeCreatable=True}"
|
||||
d:DesignHeight="900"
|
||||
d:DesignWidth="1600"
|
||||
wpf:DialogWindowProperty.ResizeMode="CanResizeWithGrip"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="boolToVisibilityConverter" />
|
||||
|
||||
<!-- 卡片样式(白底、圆角、阴影) -->
|
||||
<Style x:Key="CardBorderStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="18" />
|
||||
<Setter Property="Margin" Value="4" />
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect
|
||||
BlurRadius="10"
|
||||
Opacity="0.08"
|
||||
ShadowDepth="2"
|
||||
Color="#000000" />
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 表格单元格文字居中 -->
|
||||
<Style x:Key="CenterCellTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- 面板标题 -->
|
||||
<Style x:Key="PanelTitleStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="Foreground" Value="#333333" />
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Background="#F5F7FB">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="12">
|
||||
|
||||
<!-- ============ 查询条件工具栏 ============ -->
|
||||
<Border
|
||||
Margin="6,6,6,0"
|
||||
Padding="14,10"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<WrapPanel>
|
||||
<TextBlock
|
||||
Margin="6,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="#666666"
|
||||
Text="时间" />
|
||||
<ComboBox
|
||||
Width="110"
|
||||
Height="32"
|
||||
VerticalContentAlignment="Center"
|
||||
DisplayMemberPath="Text"
|
||||
ItemsSource="{Binding TimeRangeOptions}"
|
||||
SelectedItem="{Binding SelectedTimeRange, Mode=TwoWay}"
|
||||
Style="{StaticResource BaseComboBoxStyle}" />
|
||||
<TextBlock
|
||||
Margin="14,0,6,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="#666666"
|
||||
Text="日期" />
|
||||
<DatePicker
|
||||
Height="32"
|
||||
VerticalContentAlignment="Center"
|
||||
SelectedDate="{Binding StartDate, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Margin="6,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="#666666"
|
||||
Text="~" />
|
||||
<DatePicker
|
||||
Height="32"
|
||||
VerticalContentAlignment="Center"
|
||||
SelectedDate="{Binding EndDate, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Margin="14,0,6,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="#666666"
|
||||
Text="店铺" />
|
||||
<ComboBox
|
||||
Width="130"
|
||||
Height="32"
|
||||
VerticalContentAlignment="Center"
|
||||
DisplayMemberPath="ShopName"
|
||||
ItemsSource="{Binding ShopOptions}"
|
||||
SelectedItem="{Binding SelectedShop, Mode=TwoWay}"
|
||||
Style="{StaticResource BaseComboBoxStyle}" />
|
||||
<Button
|
||||
Height="32"
|
||||
Margin="18,0,0,0"
|
||||
Padding="16,0"
|
||||
Command="{Binding QueryCommand}"
|
||||
Content=" 查询 "
|
||||
Style="{StaticResource BasePageButtonStyle}" />
|
||||
<Button
|
||||
Height="32"
|
||||
Margin="8,0,0,0"
|
||||
Padding="16,0"
|
||||
Command="{Binding ResetCommand}"
|
||||
Content=" 重置 "
|
||||
Style="{StaticResource BasePageButtonLightStyle}" />
|
||||
</WrapPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ 汇总卡片(2 行 x 4 列) ============ -->
|
||||
<Grid Margin="3,8,3,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 销售额 -->
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="销售额" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.SalesAmount, StringFormat={}¥{0:N2}}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="最终有效销售额" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 有效订单 -->
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="有效订单" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.ValidOrderCount, StringFormat={}{0:N0}}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="已扣除退款订单" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 利润 -->
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="利润" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.Profit, StringFormat={}¥{0:N2}}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="实际利润" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 退款金额 -->
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Grid.Column="3"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="退款金额" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.RefundAmount, StringFormat={}¥{0:N2}}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="统计期间退款" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 商品销量 -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="商品销量" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.ProductSalesVolume, StringFormat={}{0:N0}}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="有效销量" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 客户数 -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="客户数" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.CustomerCount, StringFormat={}{0:N0}}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="成交客户" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 客单价 -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="客单价" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.AvgOrderAmount, StringFormat={}¥{0:N2}}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="平均每单金额" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 退款率 -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Grid.Column="3"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
Foreground="#888888"
|
||||
Text="退款率" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="30"
|
||||
FontWeight="Bold"
|
||||
Foreground="#333333"
|
||||
Text="{Binding Summary.RefundRate, StringFormat={}{0:0.00}%}" />
|
||||
<TextBlock
|
||||
Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Text="退款订单占比" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ============ 图表区域 ============ -->
|
||||
<Grid Margin="3,8,3,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="1*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 销售趋势 -->
|
||||
<Border
|
||||
Grid.Column="0"
|
||||
Margin="6,6,3,6"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource PanelTitleStyle}" Text="销售趋势" />
|
||||
<charts:RevenueLineChart
|
||||
Height="330"
|
||||
Margin="0,10,0,0"
|
||||
Points="{Binding TrendData}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 店铺销售占比 -->
|
||||
<Border
|
||||
Grid.Column="1"
|
||||
Margin="3,6,6,6"
|
||||
Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource PanelTitleStyle}" Text="店铺销售占比 TOP 5" />
|
||||
<charts:RevenuePieChart
|
||||
Height="330"
|
||||
Margin="0,10,0,0" MaxItems="5"
|
||||
ItemsSource="{Binding ShopProportionData}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ============ 店铺统计表格 ============ -->
|
||||
<Border Margin="6,8,6,12" Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
Margin="0,0,0,10"
|
||||
Style="{StaticResource PanelTitleStyle}"
|
||||
Text="店铺统计" />
|
||||
<DataGrid
|
||||
AlternatingRowBackground="#FAFAFA"
|
||||
AutoGenerateColumns="False"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
CanUserReorderColumns="False"
|
||||
CanUserResizeRows="False"
|
||||
ColumnHeaderStyle="{StaticResource BaseDataGridColumnHeaderStyle}"
|
||||
FontSize="13"
|
||||
Foreground="#333333"
|
||||
GridLinesVisibility="Horizontal"
|
||||
HeadersVisibility="Column"
|
||||
HorizontalGridLinesBrush="#EEEEEE"
|
||||
IsReadOnly="True"
|
||||
ItemsSource="{Binding ShopStatistics}"
|
||||
RowHeight="46"
|
||||
ScrollViewer.CanContentScroll="True"
|
||||
SelectionMode="Single"
|
||||
SelectionUnit="FullRow"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Binding="{Binding ShopName}"
|
||||
ElementStyle="{StaticResource CenterCellTextStyle}"
|
||||
Header="店铺" />
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Binding="{Binding OrderCount, StringFormat={}{0:N0}}"
|
||||
ElementStyle="{StaticResource CenterCellTextStyle}"
|
||||
Header="订单数" />
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Binding="{Binding SalesAmount, StringFormat={}¥{0:N0}}"
|
||||
ElementStyle="{StaticResource CenterCellTextStyle}"
|
||||
Header="销售额" />
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Binding="{Binding Profit, StringFormat={}¥{0:N0}}"
|
||||
ElementStyle="{StaticResource CenterCellTextStyle}"
|
||||
Header="利润" />
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Binding="{Binding RefundAmount, StringFormat={}¥{0:N0}}"
|
||||
ElementStyle="{StaticResource CenterCellTextStyle}"
|
||||
Header="退款金额" />
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Binding="{Binding CustomerCount, StringFormat={}{0:N0}}"
|
||||
ElementStyle="{StaticResource CenterCellTextStyle}"
|
||||
Header="客户数" />
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
Binding="{Binding AvgOrderAmount, StringFormat={}¥{0:N2}}"
|
||||
ElementStyle="{StaticResource CenterCellTextStyle}"
|
||||
Header="客单价" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ 加载遮罩 ============ -->
|
||||
<Border Background="#66FFFFFF" Visibility="{Binding IsLoading, Converter={StaticResource boolToVisibilityConverter}}" d:Visibility="Collapsed" >
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ProgressBar
|
||||
Width="240"
|
||||
Height="8"
|
||||
IsIndeterminate="True" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="13"
|
||||
Foreground="#888888"
|
||||
Text="数据加载中..." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Windows.Controls;
|
||||
using _29WxOrderWpf.Ctrls.Statistics.ViewModels;
|
||||
|
||||
namespace _29WxOrderWpf.Ctrls.Statistics
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺营收统计页面(MVVM)。
|
||||
/// 页面不写业务逻辑,只负责绑定 ShopRevenueStatisticsViewModel;
|
||||
/// 如需注入自定义数据服务,请使用
|
||||
/// new ShopRevenueStatisticsView(new ShopRevenueStatisticsViewModel(你的 IShopRevenueStatisticsService 实现))。
|
||||
/// </summary>
|
||||
public partial class ShopRevenueStatisticsView : UserControl
|
||||
{
|
||||
public ShopRevenueStatisticsView()
|
||||
: this(new ShopRevenueStatisticsViewModel())
|
||||
{
|
||||
}
|
||||
|
||||
public ShopRevenueStatisticsView(ShopRevenueStatisticsViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using AllBase.Model.MvvmBaseClasses;
|
||||
using _29WxOrderWpf.Ctrls.Statistics.Models;
|
||||
using _29WxOrderWpf.Ctrls.Statistics.Services;
|
||||
using CefSharp;
|
||||
using _29WxOrderWpf.Commands;
|
||||
|
||||
namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺营收统计 - 页面 ViewModel(MVVM)。
|
||||
/// 通过 IShopRevenueStatisticsService 调用数据接口;
|
||||
/// 接入真实接口时传入自定义实现:new ShopRevenueStatisticsViewModel(你的服务实现)。
|
||||
/// </summary>
|
||||
public class ShopRevenueStatisticsViewModel : PropertyNotifyObject
|
||||
{
|
||||
private readonly IShopRevenueStatisticsService _service;
|
||||
|
||||
private ShopRevenueSummary _summary;
|
||||
private ObservableCollection<RevenueTrendPoint> _trendData;
|
||||
private ObservableCollection<ShopSalesProportionItem> _shopProportionData;
|
||||
private ObservableCollection<ShopStatisticsItem> _shopStatistics;
|
||||
private ShopOption _selectedShop;
|
||||
private string _selectedPlatform;
|
||||
|
||||
public ShopRevenueStatisticsViewModel()
|
||||
: this(new ShopRevenueStatisticsService())
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public ShopRevenueStatisticsViewModel(IShopRevenueStatisticsService service)
|
||||
{
|
||||
_service = service ?? new ShopRevenueStatisticsService();
|
||||
|
||||
TimeRangeOptions = new ObservableCollection<TimeRangeOption>
|
||||
{
|
||||
new TimeRangeOption { Text = "昨天", Value = "yesterday" },
|
||||
new TimeRangeOption { Text = "本周", Value = "week" },
|
||||
new TimeRangeOption { Text = "本月", Value = "month" },
|
||||
new TimeRangeOption { Text = "最近10天", Value = "last10" },
|
||||
new TimeRangeOption { Text = "自定义", Value = "custom" }
|
||||
};
|
||||
|
||||
ShopOptions = new ObservableCollection<ShopOption>();
|
||||
|
||||
QueryCommand = new DelegateCommand(async () => await LoadAsync());
|
||||
ResetCommand = new DelegateCommand(Reset);
|
||||
|
||||
InitializeAsync();
|
||||
}
|
||||
|
||||
#region 查询条件
|
||||
|
||||
/// <summary>时间范围选项</summary>
|
||||
public ObservableCollection<TimeRangeOption> TimeRangeOptions { get; }
|
||||
|
||||
/// <summary>选中的时间范围</summary>
|
||||
public TimeRangeOption SelectedTimeRange
|
||||
{
|
||||
get { return this.GetValue(x => x.SelectedTimeRange); }
|
||||
set
|
||||
{
|
||||
this.SetValue(x => x.SelectedTimeRange, value);
|
||||
ApplyTimeRange();
|
||||
OnPropertyChanged(nameof(IsCustomDateRange));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>是否为自定义时间范围</summary>
|
||||
public bool IsCustomDateRange
|
||||
{
|
||||
get { return SelectedTimeRange != null && SelectedTimeRange.Value == "custom"; }
|
||||
}
|
||||
|
||||
/// <summary>开始日期</summary>
|
||||
public DateTime StartDate
|
||||
{
|
||||
get { return this.GetValue(x => x.StartDate); }
|
||||
set { this.SetValue(x => x.StartDate, value); }
|
||||
}
|
||||
|
||||
/// <summary>结束日期</summary>
|
||||
public DateTime EndDate
|
||||
{
|
||||
get { return this.GetValue(x => x.EndDate); }
|
||||
set { this.SetValue(x => x.EndDate, value); }
|
||||
}
|
||||
|
||||
/// <summary>店铺选项(含“全部店铺”)</summary>
|
||||
public ObservableCollection<ShopOption> ShopOptions { get; }
|
||||
|
||||
/// <summary>选中的店铺</summary>
|
||||
public ShopOption SelectedShop
|
||||
{
|
||||
get { return _selectedShop; }
|
||||
set
|
||||
{
|
||||
_selectedShop = value;
|
||||
OnPropertyChanged(nameof(SelectedShop));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 统计结果
|
||||
|
||||
/// <summary>汇总卡片数据</summary>
|
||||
public ShopRevenueSummary Summary
|
||||
{
|
||||
get { return _summary; }
|
||||
private set
|
||||
{
|
||||
_summary = value;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>销售趋势数据</summary>
|
||||
public ObservableCollection<RevenueTrendPoint> TrendData
|
||||
{
|
||||
get { return _trendData; }
|
||||
private set
|
||||
{
|
||||
_trendData = value;
|
||||
OnPropertyChanged(nameof(TrendData));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>店铺销售占比数据</summary>
|
||||
public ObservableCollection<ShopSalesProportionItem> ShopProportionData
|
||||
{
|
||||
get { return _shopProportionData; }
|
||||
private set
|
||||
{
|
||||
_shopProportionData = value;
|
||||
OnPropertyChanged(nameof(ShopProportionData));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>店铺统计表格数据</summary>
|
||||
public ObservableCollection<ShopStatisticsItem> ShopStatistics
|
||||
{
|
||||
get { return _shopStatistics; }
|
||||
private set
|
||||
{
|
||||
_shopStatistics = value;
|
||||
OnPropertyChanged(nameof(ShopStatistics));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>是否正在加载</summary>
|
||||
public bool IsLoading
|
||||
{
|
||||
get { return this.GetValue(x => x.IsLoading); }
|
||||
set { this.SetValue(x => x.IsLoading, value); }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 命令
|
||||
|
||||
/// <summary>查询命令</summary>
|
||||
public ICommand QueryCommand { get; }
|
||||
|
||||
/// <summary>重置命令</summary>
|
||||
public ICommand ResetCommand { get; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region 初始化
|
||||
|
||||
private async void InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 店铺选项
|
||||
ShopOptions.Add(new ShopOption { AppKey = string.Empty, ShopName = "全部店铺" });
|
||||
var shops = await _service.GetShopOptionsAsync();
|
||||
foreach (var shop in shops)
|
||||
ShopOptions.Add(shop);
|
||||
SelectedShop = ShopOptions.FirstOrDefault();
|
||||
|
||||
// 默认时间范围:本月
|
||||
SelectedTimeRange = TimeRangeOptions.FirstOrDefault(t => t.Value == "month");
|
||||
ApplyTimeRange();
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 初始化失败(如接口不可用),页面保持可查询状态;可在此接入日志/提示
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 业务逻辑
|
||||
|
||||
/// <summary>根据时间范围选项计算起止日期</summary>
|
||||
private void ApplyTimeRange()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var today = now.Date;
|
||||
var selected = SelectedTimeRange == null ? string.Empty : SelectedTimeRange.Value;
|
||||
switch (selected)
|
||||
{
|
||||
case "yesterday":
|
||||
StartDate = today.AddDays(-1);
|
||||
EndDate = today.AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
break;
|
||||
case "week":
|
||||
int diff = today.DayOfWeek == DayOfWeek.Sunday ? 6 : (int)today.DayOfWeek - 1;
|
||||
StartDate = today.AddDays(-diff);
|
||||
EndDate = now;
|
||||
break;
|
||||
case "month":
|
||||
StartDate = new DateTime(today.Year, today.Month, 1);
|
||||
EndDate = now;
|
||||
break;
|
||||
case "last10":
|
||||
StartDate = today.AddDays(-9);
|
||||
EndDate = now;
|
||||
break;
|
||||
default:
|
||||
// 自定义:保持用户选择的日期不变
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>执行查询,刷新页面所有统计数据</summary>
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (StartDate > EndDate)
|
||||
{
|
||||
var temp = StartDate;
|
||||
StartDate = EndDate;
|
||||
EndDate = temp;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
try
|
||||
{
|
||||
var query = new ShopRevenueStatisticsQuery
|
||||
{
|
||||
StartDate = StartDate,
|
||||
EndDate = EndDate,
|
||||
ShopKey = SelectedShop == null ? string.Empty : SelectedShop.AppKey,
|
||||
};
|
||||
|
||||
Summary = await _service.GetSummaryAsync(query);
|
||||
|
||||
var trend = await _service.GetSalesTrendAsync(query);
|
||||
TrendData = new ObservableCollection<RevenueTrendPoint>(trend);
|
||||
|
||||
var proportion = await _service.GetShopSalesProportionAsync(query);
|
||||
ShopProportionData = new ObservableCollection<ShopSalesProportionItem>(proportion);
|
||||
|
||||
var table = await _service.GetShopStatisticsAsync(query);
|
||||
ShopStatistics = new ObservableCollection<ShopStatisticsItem>(table);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 查询异常:保留上一次数据,可在此接入日志/错误提示
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>重置查询条件并重新加载</summary>
|
||||
private async void Reset()
|
||||
{
|
||||
SelectedShop = ShopOptions.FirstOrDefault();
|
||||
SelectedTimeRange = TimeRangeOptions.FirstOrDefault(t => t.Value == "month");
|
||||
ApplyTimeRange();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -631,7 +631,7 @@ namespace AllBase
|
||||
/// <returns></returns>
|
||||
public static long ConvertDateTimeLong(DateTime time)
|
||||
{
|
||||
TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
|
||||
TimeSpan ts = time - new DateTime(1970, 1, 1, 0, 0, 0, 0);
|
||||
return Convert.ToInt64(ts.TotalMilliseconds);
|
||||
}
|
||||
|
||||
|
||||
+2065
-2008
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user