add product/order chart
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user