add product/order chart

This commit is contained in:
Timi
2026-09-06 23:20:26 +08:00
parent 0b01a54eac
commit 9841723177
20 changed files with 2236 additions and 0 deletions
+337
View File
@@ -0,0 +1,337 @@
<template>
<div v-can="permissionCodes.stat.read" class="gao-chart-page order-data-charts">
<section class="hero">
<div class="eyebrow" v-text="'订单经营分析'" />
<h1 v-text="'客户经营看板'" />
<p v-text="`${currentStoreStore.currentStoreName} · 订单、收款与客户贡献 · 近 1 个月`" />
</section>
<div v-if="!storeId" class="no-store">
<t-empty description="暂无可统计的门店" />
</div>
<template v-else>
<section class="summary">
<div v-for="item in summaryList" :key="item.label" class="summary-item">
<span v-text="item.label" />
<strong v-text="item.value" />
</div>
</section>
<div class="chart-list">
<order-daily-chart
v-entrance-transition
:date-text-list="dateTextList"
:list="dailyList"
:loading="isLoading"
/>
<div class="two-columns">
<order-type-chart
v-entrance-transition="70"
:list="typeList"
:loading="isLoading"
/>
<order-payment-chart
v-entrance-transition="140"
:list="paymentList"
:loading="isLoading"
/>
</div>
<div class="two-columns">
<order-status-chart
v-entrance-transition="210"
:list="statusList"
:loading="isLoading"
/>
<customer-order-rank-chart
v-entrance-transition="280"
:list="customerRankList"
:loading="isLoading"
/>
</div>
<order-receivable-card
v-entrance-transition="350"
:receivable="receivable"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import GaoSalesOrderChartAPI from "@/api/GaoSalesOrderChartAPI";
import CustomerOrderRankChart from "@/pages/product/component/CustomerOrderRankChart.vue";
import OrderDailyChart from "@/pages/product/component/OrderDailyChart.vue";
import OrderPaymentChart from "@/pages/product/component/OrderPaymentChart.vue";
import OrderReceivableCard from "@/pages/product/component/OrderReceivableCard.vue";
import OrderStatusChart from "@/pages/product/component/OrderStatusChart.vue";
import OrderTypeChart from "@/pages/product/component/OrderTypeChart.vue";
import type {
GaoCustomerOrderRankView,
GaoSalesOrderDailyStatView,
GaoSalesOrderPaymentStatView,
GaoSalesOrderReceivableStatView,
GaoSalesOrderStatusStatView,
GaoSalesOrderTypeStatView
} from "@/types/GaoChart";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import { getCurrentStoreId } from "@/utils/store";
import { clearStoreChartCache } from "@/utils/storeChartCache";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { buildDateTextList, formatMoney, formatQuantity, toUnixRange } from "@/pages/product/component/chartUtils";
interface SummaryItem {
label: string;
value: string;
}
defineOptions({
name: "OrderDataCharts"
});
const currentStoreStore = useCurrentStoreStore();
const navBarStore = useNavBarStore();
const dateRange = buildDefaultDateRange();
const dateTextList = buildDateTextList(dateRange[0], dateRange[1]);
const dailyList = ref<GaoSalesOrderDailyStatView[]>([]);
const typeList = ref<GaoSalesOrderTypeStatView[]>([]);
const paymentList = ref<GaoSalesOrderPaymentStatView[]>([]);
const statusList = ref<GaoSalesOrderStatusStatView[]>([]);
const customerRankList = ref<GaoCustomerOrderRankView[]>([]);
const receivable = ref<GaoSalesOrderReceivableStatView>({});
const isLoading = ref(false);
let requestId = 0;
const storeId = computed(() => currentStoreStore.currentStoreId || getCurrentStoreId());
const summaryList = computed<SummaryItem[]>(() => [
{
label: "订单数",
value: `${formatQuantity(receivable.value.orderCount)} 单`
},
{
label: "成交金额",
value: formatMoney(receivable.value.saleAmount)
},
{
label: "有效收款",
value: formatMoney(receivable.value.paidAmount)
},
{
label: "未收金额",
value: formatMoney(receivable.value.unpaidAmount)
},
{
label: "净收款",
value: formatMoney(receivable.value.netPaidAmount)
}
]);
watch(storeId, async () => {
await loadCharts();
}, { immediate: true });
onMounted(async () => {
navBarStore.setRightIcon("refresh", reload);
if (!currentStoreStore.currentStoreId) {
await currentStoreStore.loadLoginStore();
}
});
onUnmounted(() => {
navBarStore.clearRight();
});
async function reload(): Promise<void> {
clearStoreChartCache();
await loadCharts();
}
async function loadCharts(): Promise<void> {
if (!storeId.value) {
clearData();
return;
}
const currentRequestId = ++requestId;
isLoading.value = true;
try {
const range = toUnixRange(dateRange);
const request = { storeId: storeId.value, ...range, topN: 10 };
const [daily, type, payment, status, receivableData, customerRank] = await Promise.all([
GaoSalesOrderChartAPI.daily(request),
GaoSalesOrderChartAPI.type(request),
GaoSalesOrderChartAPI.payment(request),
GaoSalesOrderChartAPI.status(request),
GaoSalesOrderChartAPI.receivable(request),
GaoSalesOrderChartAPI.customerRank(request)
]);
if (currentRequestId !== requestId) {
return;
}
dailyList.value = daily || [];
typeList.value = type || [];
paymentList.value = payment || [];
statusList.value = status || [];
receivable.value = receivableData || {};
customerRankList.value = customerRank || [];
} catch (error) {
if (currentRequestId === requestId) {
Message.error(getErrorMessage(error, "订单图表加载失败"));
}
} finally {
if (currentRequestId === requestId) {
isLoading.value = false;
}
}
}
function clearData(): void {
isLoading.value = false;
dailyList.value = [];
typeList.value = [];
paymentList.value = [];
statusList.value = [];
receivable.value = {};
customerRankList.value = [];
}
function buildDefaultDateRange(): [string, string] {
const end = getDateEnd(new Date());
const today = getDateStart(end);
const beginMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1, 0, 0, 0, 0);
const lastDayOfBeginMonth = new Date(beginMonth.getFullYear(), beginMonth.getMonth() + 1, 0).getDate();
const begin = new Date(beginMonth);
begin.setDate(Math.min(today.getDate(), lastDayOfBeginMonth));
return [formatDateTime(begin), formatDateTime(end)];
}
function getDateStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
function getDateEnd(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
}
function formatDateTime(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
</script>
<style scoped lang="less">
.gao-chart-page {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 1rem 1rem;
display: flex;
flex-direction: column;
.hero {
padding: .25rem .25rem 0;
.eyebrow {
color: var(--td-brand-color);
font-size: .6875rem;
font-weight: 700;
letter-spacing: .12em;
}
h1 {
color: #0f172a;
margin: .25rem 0 0;
font-size: 1.5rem;
line-height: 1.3;
}
p {
color: #7b889d;
margin: .375rem 0 0;
font-size: .8125rem;
}
}
.summary {
gap: .625rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-item {
padding: .75rem .625rem;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
span {
color: #52607a;
display: block;
font-size: .75rem;
}
strong {
color: #0f172a;
margin-top: .25rem;
display: block;
font-size: 1rem;
font-weight: 700;
}
}
.chart-list {
gap: 1rem;
display: flex;
flex-direction: column;
}
.two-columns {
gap: 1rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.no-store {
padding: 1.5rem 0;
display: flex;
align-items: center;
justify-content: center;
}
}
:global(html.theme-dark) {
.gao-chart-page {
.hero {
h1 {
color: #e2e8f0;
}
p {
color: #94a3b8;
}
}
.summary-item {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
span {
color: #94a3b8;
}
strong {
color: #e2e8f0;
}
}
}
}
@media (max-width: 42rem) {
.gao-chart-page {
.two-columns {
grid-template-columns: 1fr;
}
}
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<template>
<div v-can="permissionCodes.stat.read" class="gao-chart-page product-data-charts">
<section class="hero">
<div class="eyebrow" v-text="'产品经营分析'" />
<h1 v-text="'产品经营看板'" />
<p v-text="`${currentStoreStore.currentStoreName} · 销售、履约与库存 · 近 1 个月`" />
</section>
<div v-if="!storeId" class="no-store">
<t-empty description="暂无可统计的门店" />
</div>
<template v-else>
<section class="summary">
<div v-for="item in summaryList" :key="item.label" class="summary-item">
<span v-text="item.label" />
<strong v-text="item.value" />
</div>
</section>
<div class="chart-list">
<product-sales-trend-chart
v-entrance-transition
:date-text-list="dateTextList"
:list="salesDailyList"
:loading="isLoading"
/>
<div class="two-columns">
<product-sales-rank-chart
v-entrance-transition="70"
:list="salesRankList"
:loading="isLoading"
/>
<product-fulfillment-rank-chart
v-entrance-transition="140"
:list="fulfillmentRankList"
:loading="isLoading"
/>
</div>
<product-fulfillment-trend-chart
v-entrance-transition="210"
:date-text-list="dateTextList"
:list="fulfillmentDailyList"
:loading="isLoading"
/>
<product-stock-movement-chart
v-entrance-transition="280"
:date-text-list="dateTextList"
:list="stockMovementList"
:loading="isLoading"
/>
<product-stock-health-table
v-entrance-transition="350"
:list="stockHealthList"
:loading="isLoading"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import GaoProductChartAPI from "@/api/GaoProductChartAPI";
import ProductFulfillmentRankChart from "@/pages/product/component/ProductFulfillmentRankChart.vue";
import ProductFulfillmentTrendChart from "@/pages/product/component/ProductFulfillmentTrendChart.vue";
import ProductSalesRankChart from "@/pages/product/component/ProductSalesRankChart.vue";
import ProductSalesTrendChart from "@/pages/product/component/ProductSalesTrendChart.vue";
import ProductStockHealthTable from "@/pages/product/component/ProductStockHealthTable.vue";
import ProductStockMovementChart from "@/pages/product/component/ProductStockMovementChart.vue";
import type {
GaoProductFulfillmentDailyStatView,
GaoProductFulfillmentRankView,
GaoProductSalesDailyStatView,
GaoProductSalesRankView,
GaoProductStockHealthView,
GaoProductStockMovementDailyStatView
} from "@/types/GaoChart";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import { getCurrentStoreId } from "@/utils/store";
import { clearStoreChartCache } from "@/utils/storeChartCache";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { buildDateTextList, formatMoney, formatQuantity, sumNumber, toUnixRange } from "@/pages/product/component/chartUtils";
interface SummaryItem {
label: string;
value: string;
}
defineOptions({
name: "ProductDataCharts"
});
const currentStoreStore = useCurrentStoreStore();
const navBarStore = useNavBarStore();
const dateRange = buildDefaultDateRange();
const dateTextList = buildDateTextList(dateRange[0], dateRange[1]);
const salesDailyList = ref<GaoProductSalesDailyStatView[]>([]);
const salesRankList = ref<GaoProductSalesRankView[]>([]);
const fulfillmentDailyList = ref<GaoProductFulfillmentDailyStatView[]>([]);
const fulfillmentRankList = ref<GaoProductFulfillmentRankView[]>([]);
const stockMovementList = ref<GaoProductStockMovementDailyStatView[]>([]);
const stockHealthList = ref<GaoProductStockHealthView[]>([]);
const isLoading = ref(false);
let requestId = 0;
const storeId = computed(() => currentStoreStore.currentStoreId || getCurrentStoreId());
const summaryList = computed<SummaryItem[]>(() => [
{
label: "订单数",
value: `${formatQuantity(sumNumber(salesDailyList.value, item => item.orderCount))} 单`
},
{
label: "直接销量",
value: formatQuantity(sumNumber(salesDailyList.value, item => item.directQuantity))
},
{
label: "套餐权益",
value: formatQuantity(sumNumber(salesDailyList.value, item => item.packageQuantity))
},
{
label: "直接销售额",
value: formatMoney(sumNumber(salesDailyList.value, item => item.directSaleAmount))
},
{
label: "低库存产品",
value: `${stockHealthList.value.filter(item => item.lowStock === 1).length} 个`
}
]);
watch(storeId, async () => {
await loadCharts();
}, { immediate: true });
onMounted(async () => {
navBarStore.setRightIcon("refresh", reload);
if (!currentStoreStore.currentStoreId) {
await currentStoreStore.loadLoginStore();
}
});
onUnmounted(() => {
navBarStore.clearRight();
});
async function reload(): Promise<void> {
clearStoreChartCache();
await loadCharts();
}
async function loadCharts(): Promise<void> {
if (!storeId.value) {
clearData();
return;
}
const currentRequestId = ++requestId;
isLoading.value = true;
try {
const range = toUnixRange(dateRange);
const rangeRequest = { storeId: storeId.value, ...range, topN: 10 };
const snapshotRequest = { storeId: storeId.value };
const [salesDaily, salesRank, fulfillmentDaily, fulfillmentRank, stockMovement, stockHealth] = await Promise.all([
GaoProductChartAPI.salesDaily(rangeRequest),
GaoProductChartAPI.salesRank(rangeRequest),
GaoProductChartAPI.fulfillmentDaily(rangeRequest),
GaoProductChartAPI.fulfillmentRank(rangeRequest),
GaoProductChartAPI.stockMovementDaily(rangeRequest),
GaoProductChartAPI.stockHealth(snapshotRequest)
]);
if (currentRequestId !== requestId) {
return;
}
salesDailyList.value = salesDaily || [];
salesRankList.value = salesRank || [];
fulfillmentDailyList.value = fulfillmentDaily || [];
fulfillmentRankList.value = fulfillmentRank || [];
stockMovementList.value = stockMovement || [];
stockHealthList.value = stockHealth || [];
} catch (error) {
if (currentRequestId === requestId) {
Message.error(getErrorMessage(error, "产品图表加载失败"));
}
} finally {
if (currentRequestId === requestId) {
isLoading.value = false;
}
}
}
function clearData(): void {
isLoading.value = false;
salesDailyList.value = [];
salesRankList.value = [];
fulfillmentDailyList.value = [];
fulfillmentRankList.value = [];
stockMovementList.value = [];
stockHealthList.value = [];
}
function buildDefaultDateRange(): [string, string] {
const end = getDateEnd(new Date());
const today = getDateStart(end);
const beginMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1, 0, 0, 0, 0);
const lastDayOfBeginMonth = new Date(beginMonth.getFullYear(), beginMonth.getMonth() + 1, 0).getDate();
const begin = new Date(beginMonth);
begin.setDate(Math.min(today.getDate(), lastDayOfBeginMonth));
return [formatDateTime(begin), formatDateTime(end)];
}
function getDateStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
function getDateEnd(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
}
function formatDateTime(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
</script>
<style scoped lang="less">
.gao-chart-page {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 1rem 1rem;
display: flex;
flex-direction: column;
.hero {
padding: .25rem .25rem 0;
.eyebrow {
color: var(--td-brand-color);
font-size: .6875rem;
font-weight: 700;
letter-spacing: .12em;
}
h1 {
color: #0f172a;
margin: .25rem 0 0;
font-size: 1.5rem;
line-height: 1.3;
}
p {
color: #7b889d;
margin: .375rem 0 0;
font-size: .8125rem;
}
}
.summary {
gap: .625rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-item {
padding: .75rem .625rem;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
span {
color: #52607a;
display: block;
font-size: .75rem;
}
strong {
color: #0f172a;
margin-top: .25rem;
display: block;
font-size: 1rem;
font-weight: 700;
}
}
.chart-list {
gap: 1rem;
display: flex;
flex-direction: column;
}
.two-columns {
gap: 1rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.no-store {
padding: 1.5rem 0;
display: flex;
align-items: center;
justify-content: center;
}
}
:global(html.theme-dark) {
.gao-chart-page {
.hero {
h1 {
color: #e2e8f0;
}
p {
color: #94a3b8;
}
}
.summary-item {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
span {
color: #94a3b8;
}
strong {
color: #e2e8f0;
}
}
}
}
@media (max-width: 42rem) {
.gao-chart-page {
.two-columns {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -0,0 +1,51 @@
<template>
<gao-chart-card
title="客户净收款排行"
caption="排除已取消订单,前 10"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
height="17rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoCustomerOrderRankView } from "@/types/GaoChart";
import { buildHorizontalBarOption, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "CustomerOrderRankChart"
});
const props = defineProps<{
list: GaoCustomerOrderRankView[];
loading: boolean;
}>();
const chartOption = computed<EChartsOption>(() => {
const list = props.list.slice(0, 10);
return {
...buildHorizontalBarOption(list.map(getCustomerName)),
legend: { show: false },
xAxis: {
type: "value",
axisLabel: {
color: "#52607a",
formatter: (value: number) => `${value.toLocaleString("zh-CN")} 元`
},
splitLine: { lineStyle: { color: "rgba(148, 163, 184, .2)" } }
},
series: [{
name: "净收款",
type: "bar",
data: list.map(item => getNumber(item.netPaidAmount) / 100)
}]
};
});
function getCustomerName(item: GaoCustomerOrderRankView): string {
return item.customerName || item.customerCode || "未命名客户";
}
</script>
@@ -0,0 +1,113 @@
<template>
<section class="chart-section">
<div class="title-row">
<h2 v-text="title" />
<span v-if="caption" class="caption" v-text="caption" />
</div>
<div ref="chartElement" class="chart" :style="{ height }" />
<div v-if="loading" class="state">
<t-loading size="1.5rem" text="加载中" />
</div>
<div v-else-if="!hasData" class="state">
<t-empty description="暂无数据" />
</div>
<chart-range-slider
v-if="chartShowSlider"
:model-value="sliderValue"
:max="maxSliderValue"
:min-range="5"
:label="rangeText"
@change="handleSliderChange"
/>
</section>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import ChartRangeSlider from "@/components/ChartRangeSlider.vue";
import { useStoreChart, useStoreChartWindow } from "@/pages/store/component/useStoreChart";
type ChartOptionBuilder = (visibleDateTextList: string[]) => EChartsOption;
const props = withDefaults(defineProps<{
caption?: string;
dateTextList?: string[];
height?: string;
hasData?: boolean;
loading?: boolean;
option: EChartsOption | ChartOptionBuilder;
title: string;
}>(), {
caption: "",
dateTextList: () => [],
height: "16rem",
hasData: true,
loading: false
});
const dateTextList = computed(() => props.dateTextList);
const {
rangeText,
showSlider: chartShowSlider,
sliderValue,
maxSliderValue,
visibleDateTextList,
handleSliderChange
} = useStoreChartWindow(dateTextList);
const option = computed<EChartsOption>(() => typeof props.option === "function"
? props.option(visibleDateTextList.value)
: props.option);
const { chartElement } = useStoreChart(option);
</script>
<style scoped lang="less">
.chart-section {
padding: 1rem;
position: relative;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
.title-row {
gap: .5rem;
display: flex;
align-items: baseline;
justify-content: space-between;
}
h2 {
color: #0f172a;
margin: 0 0 .875rem 0;
font-size: 1rem;
font-weight: 700;
}
.caption {
color: #94a3b8;
font-size: .6875rem;
}
.chart {
width: 100%;
}
.state {
inset: 3.5rem 0 0 0;
display: flex;
position: absolute;
align-items: center;
justify-content: center;
}
}
:global(html.theme-dark) {
.chart-section {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
h2 {
color: #e2e8f0;
}
}
}
</style>
@@ -0,0 +1,81 @@
<template>
<gao-chart-card
title="订单每日趋势"
caption="金额已换算为元"
:date-text-list="dateTextList"
:option="buildOption"
:loading="loading"
:has-data="list.length > 0"
height="18rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoSalesOrderDailyStatView } from "@/types/GaoChart";
import { buildAxisChartOption, getDailyItem, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "OrderDailyChart"
});
const props = defineProps<{
dateTextList: string[];
list: GaoSalesOrderDailyStatView[];
loading: boolean;
}>();
function buildOption(visibleDateTextList: string[]): EChartsOption {
const labelList = visibleDateTextList.map(item => item.slice(5));
return {
...buildAxisChartOption(labelList),
xAxis: {
type: "category",
data: labelList,
axisTick: { show: false },
axisLabel: { color: "#52607a", interval: "auto", hideOverlap: true }
},
yAxis: [
{
type: "value",
name: "订单",
axisLabel: { color: "#52607a" },
splitLine: { lineStyle: { color: "rgba(148, 163, 184, .2)" } }
},
{
type: "value",
name: "元",
axisLabel: {
color: "#52607a",
formatter: (value: number) => value.toLocaleString("zh-CN")
},
splitLine: { show: false }
}
],
series: [
{
name: "订单数",
type: "bar",
yAxisIndex: 0,
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.orderCount))
},
{
name: "成交金额",
type: "line",
yAxisIndex: 1,
smooth: true,
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.saleAmount) / 100)
},
{
name: "净收款",
type: "line",
yAxisIndex: 1,
smooth: true,
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.netPaidAmount) / 100)
}
]
};
}
</script>
@@ -0,0 +1,43 @@
<template>
<gao-chart-card
title="付款状态分布"
caption="当前订单快照"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
height="17rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoSalesOrderPaymentStatView } from "@/types/GaoChart";
import { buildHorizontalBarOption, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "OrderPaymentChart"
});
const props = defineProps<{
list: GaoSalesOrderPaymentStatView[];
loading: boolean;
}>();
const chartOption = computed<EChartsOption>(() => {
const list = props.list;
return {
...buildHorizontalBarOption(list.map(item => getPaymentStatusText(item.paymentStatus))),
legend: { show: false },
series: [{
name: "订单数",
type: "bar",
data: list.map(item => getNumber(item.orderCount))
}]
};
});
function getPaymentStatusText(value?: string): string {
return ({ UNPAID: "未付款", PARTIALLY_PAID: "部分付款", PAID: "已付款" } as Record<string, string>)[value || ""] || "未知状态";
}
</script>
@@ -0,0 +1,123 @@
<template>
<section class="receivable-section">
<div class="title-row">
<h2 v-text="'应收构成'" />
<span class="caption" v-text="'时间范围内创建订单的当前快照'" />
</div>
<div class="receivable-list">
<div v-for="item in detailList" :key="item.label" class="receivable-item">
<span v-text="item.label" />
<strong v-text="item.value" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { GaoSalesOrderReceivableStatView } from "@/types/GaoChart";
import { formatMoney } from "@/pages/product/component/chartUtils";
type DetailItem = {
label: string;
value: string;
};
defineOptions({
name: "OrderReceivableCard"
});
const props = defineProps<{
receivable: GaoSalesOrderReceivableStatView;
}>();
const detailList = computed<DetailItem[]>(() => [
{ label: "有效收款", value: formatMoney(props.receivable.paidAmount) },
{ label: "待核销", value: formatMoney(props.receivable.pendingPaidAmount) },
{ label: "未收", value: formatMoney(props.receivable.unpaidAmount) },
{ label: "有效退款", value: formatMoney(props.receivable.refundedAmount) },
{ label: "净收款", value: formatMoney(props.receivable.netPaidAmount) }
]);
</script>
<style scoped lang="less">
.receivable-section {
padding: 1rem;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
.title-row {
gap: .5rem;
display: flex;
align-items: baseline;
justify-content: space-between;
}
h2 {
color: #0f172a;
margin: 0 0 .875rem;
font-size: 1rem;
font-weight: 700;
}
.caption {
color: #94a3b8;
font-size: .6875rem;
}
.receivable-list {
gap: .5rem;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.receivable-item {
padding: .625rem .5rem;
background: rgba(241, 245, 249, .8);
border-radius: .75rem;
span {
color: #64748b;
display: block;
font-size: .6875rem;
}
strong {
color: #0f172a;
margin-top: .25rem;
display: block;
font-size: .8125rem;
font-weight: 700;
}
}
}
:global(html.theme-dark) {
.receivable-section {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
h2 {
color: #e2e8f0;
}
.receivable-item {
background: rgba(30, 41, 59, .88);
span {
color: #94a3b8;
}
strong {
color: #e2e8f0;
}
}
}
}
@media (max-width: 42rem) {
.receivable-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,43 @@
<template>
<gao-chart-card
title="订单状态分布"
caption="当前订单快照"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
height="17rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoSalesOrderStatusStatView } from "@/types/GaoChart";
import { buildHorizontalBarOption, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "OrderStatusChart"
});
const props = defineProps<{
list: GaoSalesOrderStatusStatView[];
loading: boolean;
}>();
const chartOption = computed<EChartsOption>(() => {
const list = props.list;
return {
...buildHorizontalBarOption(list.map(item => getOrderStatusText(item.status))),
legend: { show: false },
series: [{
name: "订单数",
type: "bar",
data: list.map(item => getNumber(item.orderCount))
}]
};
});
function getOrderStatusText(value?: string): string {
return ({ ACTIVE: "进行中", FULFILLED: "已完成", CANCELLED: "已取消" } as Record<string, string>)[value || ""] || "未知状态";
}
</script>
@@ -0,0 +1,51 @@
<template>
<gao-chart-card
title="订单类型分布"
caption="按订单数量"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
height="17rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoSalesOrderTypeStatView } from "@/types/GaoChart";
import { buildPieChartOption, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "OrderTypeChart"
});
const props = defineProps<{
list: GaoSalesOrderTypeStatView[];
loading: boolean;
}>();
const chartOption = computed<EChartsOption>(() => ({
...buildPieChartOption(),
legend: {
bottom: 0,
icon: "circle",
textStyle: { color: "#52607a" }
},
series: [
{
type: "pie",
radius: ["0%", "58%"],
center: ["50%", "44%"],
label: { color: "#52607a", formatter: "{b} {c} 单" },
data: props.list.map(item => ({
name: getOrderTypeText(item.orderType),
value: getNumber(item.orderCount)
}))
}
]
}));
function getOrderTypeText(value?: string): string {
return ({ PACKAGE: "套餐订单", DIRECT_PRODUCT: "直接产品" } as Record<string, string>)[value || ""] || "未知类型";
}
</script>
@@ -0,0 +1,57 @@
<template>
<gao-chart-card
title="履约进度排行"
caption="购买数量的当前履约快照"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
height="20rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoProductFulfillmentRankView } from "@/types/GaoChart";
import { buildHorizontalBarOption, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "ProductFulfillmentRankChart"
});
const props = defineProps<{
list: GaoProductFulfillmentRankView[];
loading: boolean;
}>();
const chartOption = computed<EChartsOption>(() => {
const list = props.list.slice(0, 10);
return {
...buildHorizontalBarOption(list.map(getProductName)),
series: [
{
name: "待履约",
type: "bar",
stack: "progress",
data: list.map(item => getNumber(item.reservedQuantity))
},
{
name: "已履约",
type: "bar",
stack: "progress",
data: list.map(item => getNumber(item.fulfilledQuantity))
},
{
name: "可履约",
type: "bar",
stack: "progress",
data: list.map(item => getNumber(item.availableQuantity))
}
]
};
});
function getProductName(item: GaoProductFulfillmentRankView): string {
return item.productName || "未命名产品";
}
</script>
@@ -0,0 +1,62 @@
<template>
<gao-chart-card
title="每日履约趋势"
caption="冲正数量允许为负值"
:date-text-list="dateTextList"
:option="buildOption"
:loading="loading"
:has-data="list.length > 0"
height="17rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoProductFulfillmentDailyStatView } from "@/types/GaoChart";
import { buildAxisChartOption, getDailyItem, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "ProductFulfillmentTrendChart"
});
const props = defineProps<{
dateTextList: string[];
list: GaoProductFulfillmentDailyStatView[];
loading: boolean;
}>();
function buildOption(visibleDateTextList: string[]): EChartsOption {
const labelList = visibleDateTextList.map(item => item.slice(5));
return {
...buildAxisChartOption(labelList),
xAxis: {
type: "category",
data: labelList,
axisTick: { show: false },
axisLabel: { color: "#52607a", interval: "auto", hideOverlap: true }
},
series: [
{
name: "直接履约",
type: "bar",
stack: "fulfillment",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.directQuantity))
},
{
name: "套餐履约",
type: "bar",
stack: "fulfillment",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.packageQuantity))
},
{
name: "基础单位变化",
type: "line",
smooth: true,
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.baseQuantity))
}
]
};
}
</script>
@@ -0,0 +1,51 @@
<template>
<gao-chart-card
title="产品销售排行"
caption="按销量取前 10"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
height="20rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoProductSalesRankView } from "@/types/GaoChart";
import { buildHorizontalBarOption, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "ProductSalesRankChart"
});
const props = defineProps<{
list: GaoProductSalesRankView[];
loading: boolean;
}>();
const chartOption = computed<EChartsOption>(() => {
const list = props.list.slice(0, 10);
return {
...buildHorizontalBarOption(list.map(getProductName)),
series: [
{
name: "直接销量",
type: "bar",
stack: "quantity",
data: list.map(item => getNumber(item.directQuantity))
},
{
name: "套餐权益",
type: "bar",
stack: "quantity",
data: list.map(item => getNumber(item.packageQuantity))
}
]
};
});
function getProductName(item: GaoProductSalesRankView): string {
return item.productName || "未命名产品";
}
</script>
@@ -0,0 +1,62 @@
<template>
<gao-chart-card
title="产品销售趋势"
caption="直接产品与套餐权益分开展示"
:date-text-list="dateTextList"
:option="buildOption"
:loading="loading"
:has-data="list.length > 0"
height="17rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoProductSalesDailyStatView } from "@/types/GaoChart";
import { buildAxisChartOption, getDailyItem, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "ProductSalesTrendChart"
});
const props = defineProps<{
dateTextList: string[];
list: GaoProductSalesDailyStatView[];
loading: boolean;
}>();
function buildOption(visibleDateTextList: string[]): EChartsOption {
const labelList = visibleDateTextList.map(item => item.slice(5));
return {
...buildAxisChartOption(labelList),
xAxis: {
type: "category",
data: labelList,
axisTick: { show: false },
axisLabel: { color: "#52607a", interval: "auto", hideOverlap: true }
},
series: [
{
name: "直接销量",
type: "bar",
stack: "quantity",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.directQuantity))
},
{
name: "套餐权益",
type: "bar",
stack: "quantity",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.packageQuantity))
},
{
name: "订单数",
type: "line",
smooth: true,
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.orderCount))
}
]
};
}
</script>
@@ -0,0 +1,153 @@
<template>
<section class="table-section">
<div class="title-row">
<h2 v-text="'库存健康度'" />
<span class="caption" v-text="'低库存优先'" />
</div>
<div v-if="loading" class="table-state"><t-loading size="1.5rem" text="加载中" /></div>
<div v-else-if="!list.length" class="table-state"><t-empty description="暂无库存数据" /></div>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th v-text="'产品'" />
<th v-text="'可用库存'" />
<th v-text="'预占'" />
<th v-text="'最低库存'" />
<th v-text="'状态'" />
</tr>
</thead>
<tbody>
<tr v-for="item in sortedList" :key="item.productId">
<td>
<div class="product-name" v-text="item.productName || '未命名产品'" />
<div class="product-type" v-text="getProductTypeText(item.productType)" />
</td>
<td class="number" v-text="formatQuantity(item.availableQuantity)" />
<td class="number" v-text="formatQuantity(item.reservedQuantity)" />
<td class="number" v-text="formatQuantity(item.minStockQuantity)" />
<td>
<t-tag v-if="item.lowStock === 1" theme="danger" variant="light" content="低库存" />
<t-tag v-else theme="success" variant="light" content="正常" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<script setup lang="ts">
import type { GaoProductStockHealthView } from "@/types/GaoChart";
import { formatQuantity, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "ProductStockHealthTable"
});
const props = defineProps<{
list: GaoProductStockHealthView[];
loading: boolean;
}>();
const sortedList = computed(() => [...props.list].sort((left, right) => {
const lowStockDiff = getNumber(right.lowStock) - getNumber(left.lowStock);
return lowStockDiff || getNumber(left.availableQuantity) - getNumber(right.availableQuantity);
}));
function getProductTypeText(value?: string): string {
return ({ SERVICE: "服务", GOODS: "商品", CONSUMABLE: "耗材" } as Record<string, string>)[value || ""] || "未分类";
}
</script>
<style scoped lang="less">
.table-section {
padding: 1rem;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
.title-row {
gap: .5rem;
display: flex;
align-items: baseline;
justify-content: space-between;
}
h2 {
color: #0f172a;
margin: 0 0 .875rem;
font-size: 1rem;
font-weight: 700;
}
.caption {
color: #94a3b8;
font-size: .6875rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: .75rem;
white-space: nowrap;
}
th {
color: #94a3b8;
padding: .5rem .375rem;
text-align: left;
font-weight: 500;
}
td {
color: #334155;
padding: .625rem .375rem;
border-top: 1px solid rgba(148, 163, 184, .16);
}
.number {
text-align: right;
font-variant-numeric: tabular-nums;
}
.product-name {
max-width: 7rem;
overflow: hidden;
text-overflow: ellipsis;
}
.product-type {
color: #94a3b8;
margin-top: .125rem;
font-size: .6875rem;
}
.table-state {
padding: 1.5rem 0;
display: flex;
align-items: center;
justify-content: center;
}
}
:global(html.theme-dark) {
.table-section {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
h2 {
color: #e2e8f0;
}
td {
color: #cbd5e1;
border-top-color: rgba(148, 163, 184, .2);
}
}
}
</style>
@@ -0,0 +1,70 @@
<template>
<gao-chart-card
title="库存变动"
caption="预占与释放不计入实际出入库"
:date-text-list="dateTextList"
:option="buildOption"
:loading="loading"
:has-data="list.length > 0"
height="17rem"
/>
</template>
<script setup lang="ts">
import type { EChartsOption } from "echarts";
import GaoChartCard from "@/pages/product/component/GaoChartCard.vue";
import type { GaoProductStockMovementDailyStatView } from "@/types/GaoChart";
import { buildAxisChartOption, getDailyItem, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
name: "ProductStockMovementChart"
});
const props = defineProps<{
dateTextList: string[];
list: GaoProductStockMovementDailyStatView[];
loading: boolean;
}>();
function buildOption(visibleDateTextList: string[]): EChartsOption {
const labelList = visibleDateTextList.map(item => item.slice(5));
return {
...buildAxisChartOption(labelList),
xAxis: {
type: "category",
data: labelList,
axisTick: { show: false },
axisLabel: { color: "#52607a", interval: "auto", hideOverlap: true }
},
series: [
{
name: "入库",
type: "bar",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.inQuantity))
},
{
name: "出库",
type: "bar",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.outQuantity))
},
{
name: "库存净变化",
type: "line",
smooth: true,
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.netOnHandQuantity))
},
{
name: "新增预占",
type: "line",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.reserveQuantity))
},
{
name: "释放预占",
type: "line",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.releaseQuantity))
}
]
};
}
</script>
+215
View File
@@ -0,0 +1,215 @@
import type { EChartsOption } from "echarts";
export const chartColorList = ["#2563eb", "#0f766e", "#d97706", "#db2777", "#7c3aed", "#0891b2"];
export function buildAxisChartOption(labelList: string[]): EChartsOption {
return {
color: chartColorList,
grid: {
top: 48,
left: 12,
right: 12,
bottom: 12,
containLabel: true
},
legend: {
top: 0,
icon: "roundRect",
textStyle: {
color: "#52607a"
}
},
tooltip: {
trigger: "axis",
confine: true
},
xAxis: {
type: "category",
data: labelList,
axisTick: {
show: false
},
axisLabel: {
color: "#52607a",
interval: "auto",
hideOverlap: true
}
},
yAxis: {
type: "value",
axisLabel: {
color: "#52607a",
interval: "auto",
hideOverlap: true
},
splitLine: {
lineStyle: {
color: "rgba(148, 163, 184, .2)"
}
}
}
};
}
export function buildHorizontalBarOption(categoryList: string[]): EChartsOption {
return {
color: chartColorList,
grid: {
top: 12,
left: 8,
right: 18,
bottom: 12,
containLabel: true
},
legend: {
top: 0,
icon: "roundRect",
textStyle: {
color: "#52607a"
}
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
},
confine: true
},
xAxis: {
type: "value",
axisLabel: {
color: "#52607a"
},
splitLine: {
lineStyle: {
color: "rgba(148, 163, 184, .2)"
}
}
},
yAxis: {
type: "category",
inverse: true,
data: categoryList,
axisTick: {
show: false
},
axisLabel: {
color: "#52607a",
width: 92,
overflow: "truncate"
}
}
};
}
export function buildPieChartOption(): EChartsOption {
return {
color: chartColorList,
legend: {
bottom: 0,
icon: "circle",
textStyle: {
color: "#52607a"
}
},
tooltip: {
trigger: "item",
confine: true
},
series: [
{
type: "pie",
radius: ["0%", "58%"],
center: ["50%", "45%"],
avoidLabelOverlap: true,
label: {
color: "#52607a",
formatter: "{b} {c}"
},
labelLine: {
length: 12,
length2: 8
}
}
]
};
}
export function buildDateTextList(beginText: string, endText: string): string[] {
const begin = getDateStart(parseDateTime(beginText));
const end = getDateStart(parseDateTime(endText));
const list: string[] = [];
for (let item = begin; item <= end; item = addDays(item, 1)) {
list.push(formatDate(item));
}
return list;
}
export function toUnixRange(dateRange: readonly [string, string]): { beginAt: number; endAt: number } {
const end = parseDateTime(dateRange[1]);
if (end.getHours() === 23 && end.getMinutes() === 59) {
end.setSeconds(59, 999);
}
return {
beginAt: parseDateTime(dateRange[0]).getTime(),
endAt: end.getTime()
};
}
export function getDailyItem<T extends { dateValue?: number }>(list: T[], dateText: string): T | undefined {
return list.find(item => item.dateValue === toDateValue(dateText));
}
export function getNumber(value?: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
export function sumNumber<T>(list: T[], getter: (item: T) => number | undefined): number {
return list.reduce((sum, item) => sum + getNumber(getter(item)), 0);
}
export function formatQuantity(value?: number): string {
return getNumber(value).toLocaleString("zh-CN", {
maximumFractionDigits: 4
});
}
export function formatMoney(value?: number): string {
return `¥${(getNumber(value) / 100).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`;
}
export function getDateStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
export function getDateEnd(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
}
function parseDateTime(value: string): Date {
const [dateText, timeText = "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour, minute, 0, 0);
}
function addDays(date: Date, days: number): Date {
const value = new Date(date);
value.setDate(value.getDate() + days);
return value;
}
function formatDate(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function toDateValue(dateText: string): number {
return Number(dateText.replaceAll("-", ""));
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}