635 lines
20 KiB
TypeScript
635 lines
20 KiB
TypeScript
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;
|