Files
xin-procurement/web/pages/dashboard/analysis.tsx
T
2026-08-20 16:48:04 +08:00

635 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from "react";
import {
Card,
Col,
Divider,
Empty,
Row,
Spin,
Statistic,
Table,
Tag,
theme,
} from "antd";
import {
ArrowDownOutlined,
ArrowUpOutlined,
ShopOutlined,
ShoppingCartOutlined,
TruckOutlined,
WalletOutlined,
} from "@ant-design/icons";
import ReactECharts from "echarts-for-react";
import { useTranslation } from "react-i18next";
import useRequest from "@/hooks/useRequest";
import { getDashboardAnalysis } from "@/api/dashboard";
import type IDashboardAnalysis from "@/domain/iDashboard";
import {
BILL_PAY_STATE_MAP,
STORE_ORDER_STATUS_MAP,
} from "@/domain/iStoreOrder";
const { useToken } = theme;
/** 金额格式化 */
const formatMoney = (value?: number): string =>
"¥ " +
Number(value ?? 0).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/** 空数据兜底(加载中/请求失败时页面仍可渲染) */
const EMPTY: IDashboardAnalysis = {
overview: {
sales: { total: 0, today: 0, week: 0, growth: null, trend7: [] },
orders: { total: 0, today: 0, week: 0, growth: null, trend7: [] },
purchase: { total: 0, today: 0, week: 0, growth: null, trend7: [] },
receivable: { bill_total: 0, received: 0, unreceived: 0 },
},
trend: [],
order_status: [],
category_sales: [],
top_products: [],
top_stores: [],
recon: { bills: [], pending_payment: { count: 0, amount: 0 } },
latest_orders: [],
archives: { stores: 0, products: 0, suppliers: 0 },
};
/** 环比涨跌(红涨绿跌);上期为0无基准时显示 — */
const GrowthText: React.FC<{ growth: number | null }> = ({ growth }) => {
const { token } = useToken();
if (growth === null) {
return <span style={{ color: token.colorTextTertiary }}></span>;
}
const up = growth >= 0;
return (
<span style={{ color: up ? token.colorError : token.colorSuccess }}>
{up ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{" "}
{Math.abs(growth).toFixed(1)}%
</span>
);
};
/** 指标卡:标题 + 大数值 + 迷你趋势图 + 今日/近7天/环比 */
const MetricCard: React.FC<{
title: string;
value: string;
sub: React.ReactNode;
growth?: number | null;
chart?: React.ReactNode;
}> = ({ title, value, sub, growth, chart }) => {
const { token } = useToken();
return (
<Card variant={"borderless"}>
<div>{title}</div>
<div className={"flex items-center justify-between pt-4 pb-2"}>
<div className={"text-3xl font-semibold"}>{value}</div>
{chart}
</div>
<div className={"flex items-center justify-between"}>
<span style={{ color: token.colorTextSecondary }}>{sub}</span>
{growth !== undefined && <GrowthText growth={growth} />}
</div>
</Card>
);
};
/** 排名徽标:前三名金/银/铜 */
const RankBadge: React.FC<{ index: number }> = ({ index }) => {
const { token } = useToken();
const rank = index + 1;
const colors = ["#faad14", "#8c8c8c", "#ad6800"];
if (rank <= 3) {
return (
<span
className={
"inline-flex items-center justify-center rounded-full text-white text-xs font-semibold"
}
style={{ width: 20, height: 20, background: colors[rank - 1] }}
>
{rank}
</span>
);
}
return (
<span style={{ color: token.colorTextTertiary, paddingLeft: 6 }}>{rank}</span>
);
};
const Index: React.FC = () => {
const { token } = useToken();
const { t } = useTranslation();
const { data, loading } = useRequest<IDashboardAnalysis>(getDashboardAnalysis);
const dashboard = data ?? EMPTY;
const { overview, recon, archives } = dashboard;
/** 迷你图配置(指标卡右侧小图) */
const miniOption = (
seriesData: number[],
type: "bar" | "line",
color: string
) => ({
grid: { left: 2, right: 2, top: 6, bottom: 0 },
xAxis: { type: "category", show: false },
yAxis: { type: "value", show: false },
tooltip: { show: false },
series: [
type === "bar"
? {
data: seriesData,
type,
barWidth: 8,
itemStyle: { color, borderRadius: [3, 3, 0, 0] },
}
: {
data: seriesData,
type,
smooth: true,
symbol: "none",
lineStyle: { color, width: 2 },
areaStyle: { color, opacity: 0.15 },
},
],
});
/** 近30天销售趋势(金额柱 + 订单数线,双轴) */
const trendOption = {
tooltip: {
trigger: "axis",
axisPointer: { type: "cross" },
borderWidth: 0,
backgroundColor: token.colorBgElevated,
textStyle: { color: token.colorText },
},
legend: {
data: [t("dashboard.analysis.amountAxis"), t("dashboard.analysis.ordersAxis")],
textStyle: { color: token.colorText },
},
grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },
xAxis: {
type: "category",
data: dashboard.trend.map((item) => item.date.slice(5)),
axisLabel: { color: token.colorTextSecondary },
axisLine: { lineStyle: { color: token.colorBorder } },
},
yAxis: [
{
type: "value",
name: t("dashboard.analysis.amountAxis"),
nameTextStyle: { color: token.colorTextSecondary },
axisLabel: { color: token.colorTextSecondary },
splitLine: { lineStyle: { color: token.colorBorderSecondary } },
},
{
type: "value",
name: t("dashboard.analysis.ordersAxis"),
nameTextStyle: { color: token.colorTextSecondary },
axisLabel: { color: token.colorTextSecondary },
splitLine: { show: false },
},
],
series: [
{
name: t("dashboard.analysis.amountAxis"),
type: "bar",
data: dashboard.trend.map((item) => item.amount),
barMaxWidth: 20,
itemStyle: { color: token.colorPrimary, borderRadius: [4, 4, 0, 0] },
},
{
name: t("dashboard.analysis.ordersAxis"),
type: "line",
yAxisIndex: 1,
smooth: true,
symbol: "none",
data: dashboard.trend.map((item) => item.orders),
itemStyle: { color: token.colorSuccess },
lineStyle: { width: 2 },
},
],
};
/** 订单状态分布环图(配送中用 Ant Design cyan-6 */
const statusColor: Record<number, string> = {
0: token.colorTextDisabled,
1: token.colorPrimary,
2: token.colorWarning,
3: "#13c2c2",
4: token.colorSuccess,
9: token.colorError,
};
const statusOption = {
tooltip: { trigger: "item" },
legend: {
bottom: 0,
left: "center",
textStyle: { color: token.colorText },
},
series: [
{
name: t("dashboard.analysis.orderStatusDistribution"),
type: "pie",
radius: ["45%", "70%"],
center: ["50%", "42%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: token.borderRadius,
borderColor: token.colorBgContainer,
borderWidth: 2,
},
label: { show: false },
emphasis: {
label: {
show: true,
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
},
},
labelLine: { show: false },
data: dashboard.order_status.map((item) => ({
value: item.count,
name: item.name,
itemStyle: { color: statusColor[item.status] ?? token.colorTextDisabled },
})),
},
],
};
/** 品类销售占比环图 */
const categoryOption = {
tooltip: { trigger: "item" },
legend: {
bottom: 0,
left: "center",
textStyle: { color: token.colorText },
},
series: [
{
name: t("dashboard.analysis.categorySales"),
type: "pie",
radius: ["40%", "68%"],
center: ["50%", "42%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: token.borderRadius,
borderColor: token.colorBgContainer,
borderWidth: 2,
},
label: { show: false },
emphasis: {
label: {
show: true,
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
},
},
labelLine: { show: false },
data: dashboard.category_sales.map((item) => ({
value: item.amount,
name: item.name,
})),
},
],
};
return (
<Spin spinning={loading}>
<Row gutter={[20, 20]}>
{/* ===== 核心指标卡 ===== */}
<Col xxl={6} lg={12} xs={24}>
<MetricCard
title={t("dashboard.analysis.salesAmount")}
value={formatMoney(overview.sales.total)}
sub={
<>
{t("dashboard.analysis.today")}{" "}
{formatMoney(overview.sales.today)} ·{" "}
{t("dashboard.analysis.thisWeek")}{" "}
{formatMoney(overview.sales.week)}
</>
}
growth={overview.sales.growth}
chart={
<ReactECharts
style={{ width: 130, height: 64 }}
option={miniOption(overview.sales.trend7, "bar", token.colorPrimary)}
/>
}
/>
</Col>
<Col xxl={6} lg={12} xs={24}>
<MetricCard
title={t("dashboard.analysis.orderCount")}
value={overview.orders.total.toLocaleString()}
sub={
<>
{t("dashboard.analysis.today")} {overview.orders.today} ·{" "}
{t("dashboard.analysis.thisWeek")} {overview.orders.week}
</>
}
growth={overview.orders.growth}
chart={
<ReactECharts
style={{ width: 130, height: 64 }}
option={miniOption(overview.orders.trend7, "line", token.colorSuccess)}
/>
}
/>
</Col>
<Col xxl={6} lg={12} xs={24}>
<MetricCard
title={t("dashboard.analysis.purchaseAmount")}
value={formatMoney(overview.purchase.total)}
sub={
<>
{t("dashboard.analysis.today")}{" "}
{formatMoney(overview.purchase.today)} ·{" "}
{t("dashboard.analysis.thisWeek")}{" "}
{formatMoney(overview.purchase.week)}
</>
}
growth={overview.purchase.growth}
chart={
<ReactECharts
style={{ width: 130, height: 64 }}
option={miniOption(
overview.purchase.trend7,
"bar",
token.colorWarning
)}
/>
}
/>
</Col>
<Col xxl={6} lg={12} xs={24}>
<Card variant={"borderless"}>
<div>{t("dashboard.analysis.receivable")}</div>
<div className={"flex items-center justify-between pt-4 pb-2"}>
<div className={"text-3xl font-semibold"}>
{formatMoney(overview.receivable.unreceived)}
</div>
<div
className={"text-3xl rounded-full flex items-center justify-center"}
style={{
height: 64,
width: 64,
background: token.colorPrimaryBg,
}}
>
<WalletOutlined style={{ color: token.colorPrimary }} />
</div>
</div>
<div style={{ color: token.colorTextSecondary }}>
{t("dashboard.analysis.totalBillAmount")}{" "}
{formatMoney(overview.receivable.bill_total)} ·{" "}
{t("dashboard.analysis.receivedAmount")}{" "}
{formatMoney(overview.receivable.received)}
</div>
</Card>
</Col>
{/* ===== 销售趋势 + 订单状态分布 ===== */}
<Col xl={18} xs={24}>
<Card
variant={"borderless"}
title={t("dashboard.analysis.salesTrend")}
>
{dashboard.trend.length === 0 ? (
<Empty description={t("dashboard.analysis.emptyTrend")} />
) : (
<ReactECharts style={{ width: "100%", height: 360 }} option={trendOption} />
)}
</Card>
</Col>
<Col xl={6} xs={24}>
<Card
variant={"borderless"}
title={t("dashboard.analysis.orderStatusDistribution")}
>
<ReactECharts style={{ width: "100%", height: 360 }} option={statusOption} />
</Card>
</Col>
{/* ===== 品类占比 + 热销商品 + 门店排行 ===== */}
<Col xl={6} xs={24}>
<Card variant={"borderless"} title={t("dashboard.analysis.categorySales")}>
{dashboard.category_sales.length === 0 ? (
<Empty description={t("dashboard.analysis.emptyCategory")} />
) : (
<ReactECharts style={{ width: "100%", height: 320 }} option={categoryOption} />
)}
</Card>
</Col>
<Col xl={9} xs={24}>
<Card variant={"borderless"} title={t("dashboard.analysis.topProducts")}>
<Table
size={"small"}
rowKey={"product_id"}
dataSource={dashboard.top_products}
pagination={false}
scroll={{ x: 'max-content' }}
columns={[
{
title: t("dashboard.analysis.rank"),
key: "rank",
width: 60,
render: (_, __, index) => <RankBadge index={index} />,
},
{
title: t("dashboard.analysis.productName"),
dataIndex: "product_name",
ellipsis: true,
render: (name: string, record) => (
<div>
<div>{name}</div>
<div style={{ color: token.colorTextTertiary, fontSize: token.fontSizeSM }}>
{record.product_spec}
</div>
</div>
),
},
{
title: t("dashboard.analysis.quantity"),
key: "quantity",
width: 100,
render: (_, record) => `${record.quantity}`,
},
{
title: t("dashboard.analysis.amount"),
dataIndex: "amount",
width: 120,
render: (amount: number) => formatMoney(amount),
},
]}
/>
</Card>
</Col>
<Col xl={9} xs={24}>
<Card variant={"borderless"} title={t("dashboard.analysis.topStores")}>
<Table
size={"small"}
rowKey={"store_id"}
dataSource={dashboard.top_stores}
pagination={false}
scroll={{ x: 'max-content' }}
columns={[
{
title: t("dashboard.analysis.rank"),
key: "rank",
width: 60,
render: (_, __, index) => <RankBadge index={index} />,
},
{
title: t("dashboard.analysis.storeName"),
dataIndex: "store_name",
ellipsis: true,
},
{
title: t("dashboard.analysis.orderCount"),
dataIndex: "order_count",
width: 90,
},
{
title: t("dashboard.analysis.amount"),
dataIndex: "amount",
width: 120,
render: (amount: number) => formatMoney(amount),
},
]}
/>
</Card>
</Col>
{/* ===== 对账与回款 + 最新订单 ===== */}
<Col xl={10} xs={24}>
<Card variant={"borderless"} title={t("dashboard.analysis.reconOverview")}>
<div
className={"mb-3"}
style={{ fontSize: token.fontSizeLG, fontWeight: token.fontWeightStrong }}
>
{t("dashboard.analysis.billProgress")}
</div>
<Row gutter={[16, 16]}>
{recon.bills.map((bill) => (
<Col span={8} key={bill.pay_state}>
<Tag color={BILL_PAY_STATE_MAP[bill.pay_state]?.color}>
{bill.name}
</Tag>
<div className={"pt-2"}>
<Statistic
value={bill.count}
suffix={t("dashboard.analysis.billUnit") || undefined}
/>
<div style={{ color: token.colorTextSecondary }}>
{formatMoney(bill.amount)}
</div>
</div>
</Col>
))}
</Row>
<Divider style={{ margin: "16px 0" }} />
<div className={"flex items-center justify-between"}>
<div>
<div style={{ fontWeight: token.fontWeightStrong }}>
{t("dashboard.analysis.pendingPaymentReview")}
</div>
<div
style={{
color: token.colorTextTertiary,
fontSize: token.fontSizeSM,
}}
>
{t("dashboard.analysis.pendingPaymentTip")}
</div>
</div>
<div className={"text-right"}>
<Statistic value={recon.pending_payment.count} />
<div style={{ color: token.colorTextSecondary }}>
{formatMoney(recon.pending_payment.amount)}
</div>
</div>
</div>
</Card>
</Col>
<Col xl={14} xs={24}>
<Card variant={"borderless"} title={t("dashboard.analysis.latestOrders")}>
<Table
size={"small"}
rowKey={"id"}
dataSource={dashboard.latest_orders}
pagination={false}
scroll={{ x: 'max-content' }}
columns={[
{
title: t("dashboard.analysis.orderNo"),
dataIndex: "order_no",
ellipsis: true,
},
{
title: t("dashboard.analysis.store"),
dataIndex: "store_name",
ellipsis: true,
},
{
title: t("dashboard.analysis.orderDate"),
dataIndex: "order_date",
width: 110,
},
{
title: t("dashboard.analysis.amount"),
dataIndex: "total_amount",
width: 120,
render: (amount: number) => formatMoney(amount),
},
{
title: t("dashboard.analysis.status"),
dataIndex: "status",
width: 90,
render: (status: number) => (
<Tag color={STORE_ORDER_STATUS_MAP[status]?.color}>
{STORE_ORDER_STATUS_MAP[status]?.text}
</Tag>
),
},
]}
/>
</Card>
</Col>
{/* ===== 基础档案 ===== */}
<Col xxl={8} lg={12} xs={24}>
<Card variant={"borderless"}>
<Statistic
title={t("dashboard.analysis.archivesStores")}
value={archives.stores}
prefix={<ShopOutlined style={{ color: token.colorPrimary }} />}
/>
</Card>
</Col>
<Col xxl={8} lg={12} xs={24}>
<Card variant={"borderless"}>
<Statistic
title={t("dashboard.analysis.archivesProducts")}
value={archives.products}
prefix={<ShoppingCartOutlined style={{ color: token.colorSuccess }} />}
/>
</Card>
</Col>
<Col xxl={8} lg={12} xs={24}>
<Card variant={"borderless"}>
<Statistic
title={t("dashboard.analysis.archivesSuppliers")}
value={archives.suppliers}
prefix={<TruckOutlined style={{ color: token.colorWarning }} />}
/>
</Card>
</Col>
</Row>
</Spin>
);
};
export default Index;