提交 806e3aed authored 作者: 王鹏飞's avatar 王鹏飞

refactor(admin): migrate book orders module

上级 b51a4aca
# Books Orders Migration Report
## Status
- The active `/books/order-management` route now delegates to the private `books/orders/routes.tsx` module.
- The module owns the order-list routes (`index` and `dataset`) and the `sale-statistics` route while preserving all public URLs.
- Order list and sales statistics views are route-lazy, so the ECharts implementation remains isolated from the normal list route.
## Order list
- `OrdersListView.tsx` renders `AppList<OrderRow, OrdersFilters>` as the controlled list shell.
- The 13 legacy columns are preserved: order ID, book, buyer, four amount columns, order/pay timestamps, settlement state, payment icon, rating, and refund action.
- Filters preserve book name, payment status, payment type, and payment-time range.
- Payment-time conversion uses the real backend fields `start_pay` and `end_pay`, with local start-of-day and end-of-day Unix seconds.
- Pagination, page-size choices 10/20/30/40, draft-filter pagination, search, reset, refresh, retry, and immediate reload when clearing a filter are controlled through `useOrdersList`.
## Permissions, requests, and refund
- The tab options preserve `/books/order-management/getOrderList`, the legacy list-only `/books/order-management/getOrderList1`, and `/books/order-management/salesStatistics` checks.
- Refund remains available only with `/books/order-management/refund` and `status === 3`.
- `RefundDrawer` preserves the optional refund reason, original copy, mask/keyboard behavior, and cancel/confirm actions.
- Successful refunds preserve the legacy unfiltered-current-page refresh behavior.
- Local request wrappers preserve form encoding, success/error messages, fallback text, and these endpoints:
- `POST /order/getOrderList`
- `POST /order/salesStatistics`
- `POST /order/refund`
## Sales statistics
- The dedicated sales view preserves the four summary cards: expected income, orders, buyers, and average order value, including formatting and units.
- The monthly sales bar chart preserves alternating month labels, tooltip formatting, and original color.
- `type === 1` renders teacher TOP10 performance; `type === 2` renders book TOP10 performance.
- Both pie charts preserve titles, tooltip fields, ratios, wrapped labels, colors, and layout.
- ECharts instances are initialized from private refs and disposed when the view changes or unmounts.
## Legacy compatibility
- The old `pages/books/order-management` files were not deleted because deprecated `src/routes/routes.jsx` and `src/config/component.js` still reference them and those files are outside this task's allowed scope.
## Verification
- `pnpm exec oxlint src/modules/books/orders src/modules/books/routes.tsx --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/books/orders src/modules/books/routes.tsx`: passed.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/books/orders` or `src/modules/books/routes.tsx`; the workspace still has unrelated existing diagnostics.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- Per task direction, no API/query/Hook tests were added.
- The dedicated ECharts sales-statistics chunk remains large, matching the legacy implementation's dependency footprint.
- Legacy order-management pages remain until all deprecated route/config references can be migrated in an explicitly authorized scope.
import { message } from "antd";
// @ts-expect-error The legacy workspace does not install the qs declaration package.
import qs from "qs";
import request from "@/utils/request.js";
import type { OrdersQuery } from "./query";
export interface OrderRow {
id: number;
book_name?: string;
members_name?: string;
total_price?: number | string;
coupon_price?: number | string;
integral_price?: number | string;
final_total_price?: number | string;
create_time?: string;
pay_time?: string;
status?: number | string;
status_name?: string;
pay_type?: number | string;
pay_type_name?: string;
rating?: number;
}
export interface OrdersListResult {
rows: OrderRow[];
total: number;
}
export interface MonthlySales {
month: number | string;
price: number | string;
}
export interface TeacherSales {
teacher_name: string;
price: number | string;
ratio: number;
}
export interface BookSales {
book_name: string;
price: number | string;
ratio: number;
}
export interface SalesStatisticsResult {
type: 1 | 2;
salesAmount: MonthlySales[];
teacher: TeacherSales[];
book: BookSales[];
order_num: number | string;
buy_num: number | string;
average: number | string;
income: number | string;
}
type ApiBody<T> = { code?: number; data?: T; message?: string };
type ApiParams = Record<string, unknown>;
async function getPayload<T>(url: string, params?: ApiParams): Promise<T | false> {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as ApiBody<T>;
const text = body?.message ?? "数据获取失败!";
if (body?.code === 200) return (body.data ?? []) as T;
message.error(text);
return false;
} catch {
return false;
}
}
export async function getOrders(query: OrdersQuery): Promise<OrdersListResult | false> {
const data = await getPayload<{ list?: OrderRow[]; total?: number }>("/order/getOrderList", {
...query,
});
if (data === false) return false;
return { rows: data.list ?? [], total: data.total ?? 0 };
}
export function getSalesStatistics() {
return getPayload<SalesStatisticsResult>("/order/salesStatistics");
}
export async function refundOrder(id: number, refund_reason?: string) {
try {
const response = await request.post("/order/refund", qs.stringify({ id, refund_reason }));
const body = response.data as ApiBody<unknown>;
const text = body?.message ?? "数据获取失败!";
if (body?.code === 200) {
message.success(text);
return true;
}
message.error(text);
return false;
} catch {
return false;
}
}
import { Segmented } from "antd";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { Outlet, useLocation, useNavigate } from "react-router";
interface RootState {
user: {
operationPermissionsList?: string[];
};
}
export default function OrdersLayout() {
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? []);
const navigate = useNavigate();
const location = useLocation();
const [selectedOption, setSelectedOption] = useState(
() => Number.parseInt(localStorage.getItem("selectedOption") ?? "", 10) || 1,
);
useEffect(() => {
localStorage.setItem("selectedOption", String(selectedOption));
}, [selectedOption]);
useEffect(() => {
if (location.pathname === "/books/order-management/dataset") setSelectedOption(1);
if (location.pathname === "/books/order-management/sale-statistics") setSelectedOption(2);
}, [location.pathname]);
let options: { label: string; value: number }[] = [];
if (
permissions.includes("/books/order-management/getOrderList") &&
permissions.includes("/books/order-management/salesStatistics")
) {
options = [
{ label: "订单列表", value: 1 },
{ label: "销售统计", value: 2 },
];
} else if (permissions.includes("/books/order-management/getOrderList1")) {
options = [{ label: "订单列表", value: 1 }];
} else if (permissions.includes("/books/order-management/salesStatistics")) {
options = [{ label: "销售统计", value: 2 }];
}
const changeView = (value: string | number) => {
const next = Number(value);
setSelectedOption(next);
localStorage.setItem("selectedOption", String(next));
if (next === 1) navigate("/books/order-management/dataset");
if (next === 2) navigate("/books/order-management/sale-statistics");
};
return (
<div style={{ maxHeight: 790 }} className="salesTop">
<Segmented
options={options}
onChange={changeView}
value={selectedOption}
style={{ marginBottom: 20 }}
/>
<Outlet />
</div>
);
}
import { Button, Col, Drawer, Form, Input, Row } from "antd";
interface RefundValues {
refund_reason?: string;
}
interface RefundDrawerProps {
open: boolean;
onClose: () => void;
onSubmit: (values: RefundValues) => void | Promise<void>;
}
export default function RefundDrawer({ open, onClose, onSubmit }: RefundDrawerProps) {
const [form] = Form.useForm<RefundValues>();
return (
<Drawer
open={open}
maskClosable={false}
footer={null}
mask={false}
onClose={onClose}
keyboard={false}
>
<Form
form={form}
onFinish={onSubmit}
labelCol={{ span: 4 }}
style={{ textAlign: "center", padding: "10px 0 18px", fontSize: 16 }}
>
<Form.Item label="退款原因" name="refund_reason">
<Input.TextArea placeholder="请输入退款原因" autoSize={{ minRows: 3, maxRows: 5 }} />
</Form.Item>
<Form.Item wrapperCol={{ offset: 3 }}>
<Row gutter={20} justify="center">
<Col>
<Button onClick={onClose}>取消</Button>
</Col>
<Col>
<Button type="primary" htmlType="submit">
确认
</Button>
</Col>
</Row>
</Form.Item>
</Form>
</Drawer>
);
}
import * as echarts from "echarts";
import { useEffect, useRef } from "react";
import type { BookSales, MonthlySales, SalesStatisticsResult, TeacherSales } from "../api";
const incomeIcon = new URL("../../../../assets/images/图标.png", import.meta.url).href;
const ordersIcon = new URL("../../../../assets/images/图标1.png", import.meta.url).href;
const buyersIcon = new URL("../../../../assets/images/图标2.png", import.meta.url).href;
const averageIcon = new URL("../../../../assets/images/图标3.png", import.meta.url).href;
interface SalesDashboardProps {
data: SalesStatisticsResult;
}
function monthlyOption(data: MonthlySales[]): echarts.EChartsOption {
return {
title: { text: "销售总金额(元)/月", left: "left" },
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter(params: any) {
const first = params[0];
if (typeof first.data === "string" && first.data === "0.00") return "暂无数据";
const amount = Number(first.value);
if (Number.isNaN(amount)) return "数据异常";
return `${first.name}月<br/>${amount.toFixed(2)}元`;
},
},
grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },
xAxis: [
{
type: "category",
data: data.map((item) => item.month),
axisTick: { alignWithLabel: true },
axisLabel: {
interval: 0,
formatter(value: string, index: number) {
return (index + 1) % 2 !== 0 ? `${value}月` : "";
},
},
},
],
yAxis: [{ type: "value", splitLine: { show: false } }],
series: [
{
name: "Direct",
type: "bar",
barWidth: "60%",
data: data.map((item) => item.price),
itemStyle: { color: "#d48ca0" },
},
],
};
}
function teacherOption(data: TeacherSales[]): echarts.EChartsOption {
const seriesData = data.map((item) => ({
name: item.teacher_name,
value: Number(String(item.price).replace(",", "")),
}));
return {
title: { text: "老师业绩分布TOP10", left: "left" },
tooltip: {
trigger: "item",
formatter(params: any) {
return `<div style="display:flex;align-items:center"><div style="width:10px;height:10px;border-radius:50%;background-color:${params.color};margin-right:10px"></div><span style="color:${params.color}">老师名称</span> : ${params.name}。<br/></div><div style="display:flex;align-items:center"><div style="width:10px;height:10px;border-radius:50%;background-color:${params.color};margin-right:10px"></div><span style="color:${params.color}">老师业绩</span> : ${params.value} 元。</div>`;
},
},
legend: {
orient: "vertical",
top: "5%",
itemStyle: { color: seriesData.length === 0 ? "#d3d3d3" : "auto" },
itemHeight: 20,
height: 20,
},
series: [
{
name: "暂无数据",
type: "pie",
radius: "50%",
data: seriesData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(211, 211, 211)",
},
},
labelLine: { show: false },
label: {
formatter(params: any) {
const item = data[params.dataIndex];
if (!item) return "";
const name =
params.name.length > 10
? `${params.name.slice(0, 10)}\n${params.name.slice(10)}`
: params.name;
return `${name} ${(item.ratio * 100).toFixed(2)}%`;
},
},
},
],
};
}
function bookOption(data: BookSales[]): echarts.EChartsOption {
const seriesData = data.map((item) => ({
name: item.book_name,
value: Number(String(item.price).replace(",", "")),
}));
return {
title: { text: "书籍业绩分布TOP10", left: "left" },
tooltip: {
trigger: "item",
formatter(params: any) {
return `<div style="display:flex;align-items:center"><div style="width:10px;height:10px;border-radius:50%;background-color:${params.color};margin-right:10px"></div><span style="color:${params.color}">书籍名称</span> : ${params.name}。<br/></div><div style="display:flex;align-items:center"><div style="width:10px;height:10px;border-radius:50%;background-color:${params.color};margin-right:10px"></div><span style="color:${params.color}">书籍价格</span> : ${params.value} 元。</div>`;
},
},
grid: { left: "center", right: "center", top: "center", bottom: "center" },
legend: {
orient: "vertical",
top: "5%",
itemStyle: { color: seriesData.length === 0 ? "#d3d3d3" : "auto" },
itemHeight: 20,
height: 20,
},
series: [
{
name: "暂无数据",
type: "pie",
radius: "68%",
center: ["50%", "50%"],
clockwise: false,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
borderWidth: 0,
},
},
data: seriesData,
label: {
formatter(params: any) {
const item = data[params.dataIndex];
if (!item) return "";
const name =
params.name.length > 10
? `${params.name.slice(0, 10)}\n${params.name.slice(10)}`
: params.name;
return `${name} ${(item.ratio * 100).toFixed(2)}%`;
},
},
labelLine: { show: false },
itemStyle: { borderWidth: 4, borderColor: "#ffffff" },
},
],
color: ["#00acee", "#52cdd5", "#79d9f1", "#a7e7ff", "#c8efff"],
backgroundColor: "#fff",
};
}
export default function SalesDashboard({ data }: SalesDashboardProps) {
const totalSalesAmountRef = useRef<HTMLDivElement>(null);
const performanceRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!totalSalesAmountRef.current || !performanceRef.current) return;
const monthlyChart = echarts.init(totalSalesAmountRef.current);
const performanceChart = echarts.init(performanceRef.current);
monthlyChart.setOption(monthlyOption(data.salesAmount ?? []));
performanceChart.setOption(
data.type === 1 ? teacherOption(data.teacher ?? []) : bookOption(data.book ?? []),
);
return () => {
monthlyChart.dispose();
performanceChart.dispose();
};
}, [data]);
const summary = [
{ name: "预计总收入", value: Number(data.income).toFixed(2), unit: "元", icon: incomeIcon },
{ name: "总订单数", value: Number(data.order_num), unit: "单", icon: ordersIcon },
{ name: "总购买人数", value: Number(data.buy_num), unit: "人", icon: buyersIcon },
{ name: "平均客单价", value: Number(data.average).toFixed(2), unit: "元", icon: averageIcon },
];
return (
<div className="sales">
<div className="items">
{summary.map((item) => (
<div className="item" key={item.name}>
<div className="img">
<img src={item.icon} alt="" />
</div>
<div className="cont">
<div className="title">{item.name}</div>
<div className="total">
{item.value}
<span className="unit">{item.unit}</span>
</div>
</div>
</div>
))}
</div>
<div className="echarts">
<div className="echarts-item" ref={totalSalesAmountRef} />
<div className="echarts-item" ref={performanceRef} />
</div>
</div>
);
}
import type { Dayjs } from "dayjs";
export interface OrdersFilters {
book_name?: string;
status?: string | null;
pay_type?: string | null;
pay_time?: [Dayjs | null, Dayjs | null] | null;
}
export interface OrdersQuery {
page: number;
page_size: number;
book_name?: string;
status?: string;
pay_type?: string;
start_pay?: number;
end_pay?: number;
}
interface BuildOrdersQueryInput {
page: number;
pageSize: number;
filters: OrdersFilters;
}
export function buildOrdersQuery({ page, pageSize, filters }: BuildOrdersQueryInput): OrdersQuery {
const query: OrdersQuery = { page, page_size: pageSize };
if (filters.book_name) query.book_name = filters.book_name;
if (filters.status) query.status = filters.status;
if (filters.pay_type) query.pay_type = filters.pay_type;
if (filters.pay_time?.[0] && filters.pay_time[1]) {
query.start_pay = filters.pay_time[0].startOf("day").unix();
query.end_pay = filters.pay_time[1].endOf("day").unix();
}
return query;
}
import { lazy } from "react";
import { Navigate, useRoutes } from "react-router";
import OrdersLayout from "./components/OrdersLayout";
// @ts-expect-error The admin TypeScript boundary does not declare Less modules.
import "./styles.less";
const OrdersListView = lazy(() => import("./views/OrdersListView"));
const SalesStatisticsView = lazy(() => import("./views/SalesStatisticsView"));
export default function OrdersRoutes() {
return useRoutes([
{
Component: OrdersLayout,
children: [
{ index: true, Component: OrdersListView },
{ path: "dataset", Component: OrdersListView },
{ path: "sale-statistics", Component: SalesStatisticsView },
],
},
{ path: "*", element: <Navigate to="." replace /> },
]);
}
.salesTop {
background-color: #fff;
.ant-segmented-item {
background-color: #fff;
}
.ant-segmented-item.ant-segmented-item-selected {
background-color: #f8edf0;
.ant-segmented-item-label {
color: #ab1941;
font-weight: bold;
}
}
.sales {
width: 1400px;
margin: auto;
.items {
display: flex;
justify-content: space-between;
align-items: center;
.item {
flex: 0 0 300px;
border-radius: 4px;
border: 1px solid #e4e4e4;
display: flex;
padding: 30px;
.img {
width: 40px;
margin-right: 40px;
img {
width: 100%;
}
}
.cont {
.title {
font-size: 12px;
font-weight: 400;
color: #999;
}
.total {
font-size: 22px;
font-weight: 500;
color: #333;
line-height: 30px;
}
}
.unit {
margin-left: 5px;
}
}
}
.echarts {
display: flex;
margin-top: 90px;
.echarts-item {
width: 800px;
height: 400px;
}
}
}
}
import { useCallback, useEffect, useRef, useState } from "react";
import { getOrders, type OrderRow, type OrdersListResult } from "./api";
import { buildOrdersQuery, type OrdersFilters, type OrdersQuery } from "./query";
const INITIAL_PAGE = 1;
const INITIAL_PAGE_SIZE = 10;
function clearedFilter(previous: OrdersFilters, next: OrdersFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<
keyof OrdersFilters
>;
return [...keys].some((key) => previous[key] !== next[key] && !next[key]);
}
export function useOrdersList() {
const [filters, setFilterState] = useState<OrdersFilters>({});
const [page, setPage] = useState(INITIAL_PAGE);
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE);
const [rows, setRows] = useState<OrderRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const requestId = useRef(0);
const lastQuery = useRef<OrdersQuery>(
buildOrdersQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
);
const load = useCallback(async (query: OrdersQuery) => {
const currentRequest = ++requestId.current;
lastQuery.current = query;
setLoading(true);
setError(null);
try {
const result: OrdersListResult | false = await getOrders(query);
if (currentRequest !== requestId.current) return;
if (result === false) {
setError(new Error("数据获取失败!"));
return;
}
setRows(result.rows);
setTotal(result.total);
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error("数据获取失败!"));
}
} finally {
if (currentRequest === requestId.current) setLoading(false);
}
}, []);
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: OrdersFilters) =>
load(buildOrdersQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
);
useEffect(() => {
void load(lastQuery.current);
return () => {
requestId.current += 1;
};
}, [load]);
const setFilters = useCallback(
(nextFilters: OrdersFilters) => {
const shouldReload = clearedFilter(filters, nextFilters);
setFilterState(nextFilters);
if (shouldReload) void loadFor(page, pageSize, nextFilters);
},
[filters, loadFor, page, pageSize],
);
const search = useCallback(() => {
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, filters);
}, [filters, loadFor, pageSize]);
const reset = useCallback(() => {
setFilterState({});
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, {});
}, [loadFor, pageSize]);
const refresh = useCallback(() => {
void loadFor(page, pageSize, filters);
}, [filters, loadFor, page, pageSize]);
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {});
}, [loadFor, page, pageSize]);
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage);
void loadFor(nextPage, pageSize, filters);
},
[filters, loadFor, pageSize],
);
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE);
setPageSize(nextPageSize);
void loadFor(INITIAL_PAGE, nextPageSize, filters);
},
[filters, loadFor],
);
const retry = useCallback(() => void load(lastQuery.current), [load]);
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
};
}
import { Button, DatePicker, Input, Rate, Select } from "antd";
import type { ColumnsType } from "antd/es/table";
import { useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import { refundOrder, type OrderRow } from "../api";
import RefundDrawer from "../components/RefundDrawer";
import type { OrdersFilters } from "../query";
import { useOrdersList } from "../useOrdersList";
interface RootState {
user: { operationPermissionsList?: string[] };
}
const { RangePicker } = DatePicker;
const wechatIcon = new URL("../../../../assets/images/vx.png", import.meta.url).href;
const alipayIcon = new URL("../../../../assets/images/zhifu.png", import.meta.url).href;
const coinIcon = new URL("../../../../assets/images/money.png", import.meta.url).href;
export default function OrdersListView() {
const list = useOrdersList();
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? []);
const [refundId, setRefundId] = useState<number | null>(null);
const submitRefund = async ({ refund_reason }: { refund_reason?: string }) => {
if (refundId === null) return;
const success = await refundOrder(refundId, refund_reason);
if (!success) return;
list.refreshUnfiltered();
setRefundId(null);
};
const columns: ColumnsType<OrderRow> = [
{ title: "订单编号", key: "id", dataIndex: "id", align: "center" },
{ title: "书籍名称", key: "book_name", dataIndex: "book_name", align: "center", width: 150 },
{ title: "购买人", key: "members_name", dataIndex: "members_name", align: "center" },
{ title: "总金额", key: "total_price", dataIndex: "total_price", align: "center" },
{
title: "优惠券抵扣金额",
key: "coupon_price",
dataIndex: "coupon_price",
align: "center",
width: 140,
},
{
title: "积分抵扣金额",
key: "integral_price",
dataIndex: "integral_price",
align: "center",
width: 140,
},
{
title: "实际付款金额",
key: "final_total_price",
dataIndex: "final_total_price",
align: "center",
width: 140,
},
{ title: "下单时间", key: "create_time", dataIndex: "create_time", align: "center" },
{ title: "支付时间", key: "pay_time", dataIndex: "pay_time", align: "center" },
{ title: "结算状态", key: "status_name", dataIndex: "status_name", align: "center" },
{
title: "支付方式",
key: "pay_type_name",
dataIndex: "pay_type_name",
align: "center",
width: 100,
render: (_, record) => {
const source =
record.pay_type_name === "微信"
? wechatIcon
: record.pay_type_name === "支付宝"
? alipayIcon
: coinIcon;
return <img src={source} width={30} alt={record.pay_type_name ?? "支付方式"} />;
},
},
{
title: "评价",
key: "rating",
dataIndex: "rating",
align: "center",
width: 135,
render: (_, record) => (
<Rate disabled allowHalf style={{ width: 135 }} value={record.rating} />
),
},
{
title: "操作",
key: "handle",
dataIndex: "handle",
align: "center",
render: (_, record) =>
permissions.includes("/books/order-management/refund") && Number(record.status) === 3 ? (
<Button onClick={() => setRefundId(record.id)}>退款</Button>
) : null,
},
];
const filterItems: AppListFilterItem<OrdersFilters>[] = [
{
name: "book_name",
label: "书籍名称",
render: (field) => (
<Input
allowClear
autoComplete="off"
placeholder="请输入书籍名称"
id="name"
value={field.value ?? ""}
onChange={(event) => field.onChange(event.target.value)}
/>
),
},
{
name: "status",
label: "支付状态",
render: (field) => (
<Select
allowClear
placeholder="请选择支付状态"
value={field.value}
onChange={(value) => field.onChange(value)}
style={{ width: 160 }}
options={[
{ value: "1", label: "待支付" },
{ value: "2", label: "取消支付" },
{ value: "3", label: "已支付" },
{ value: "4", label: "已退款" },
]}
/>
),
},
{
name: "pay_type",
label: "支付方式",
render: (field) => (
<Select
allowClear
placeholder="请选择支付方式"
value={field.value}
onChange={(value) => field.onChange(value)}
style={{ width: 160 }}
options={[
{ value: "1", label: "微信" },
{ value: "2", label: "支付宝" },
{ value: "3", label: "紫荆币" },
]}
/>
),
},
{
name: "pay_time",
label: "支付时间",
render: (field) => (
<RangePicker
id="createTime"
allowClear
autoComplete="off"
value={field.value}
onChange={(value) => field.onChange(value)}
/>
),
},
];
return (
<>
<AppList<OrderRow, OrdersFilters>
rowKey="id"
columns={columns}
data={{ rows: list.rows, total: list.total, loading: list.loading, error: list.error }}
filters={{
value: list.filters,
items: filterItems,
onChange: list.setFilters,
onSearch: list.search,
onReset: list.reset,
onRefresh: list.refresh,
}}
pagination={{
page: list.page,
pageSize: list.pageSize,
pageSizeOptions: [10, 20, 30, 40],
onChange: list.changePage,
onPageSizeChange: list.changePageSize,
}}
onRetry={list.retry}
/>
<RefundDrawer
open={refundId !== null}
onClose={() => setRefundId(null)}
onSubmit={submitRefund}
/>
</>
);
}
import { Button, Empty, Spin } from "antd";
import { useCallback, useEffect, useState } from "react";
import { getSalesStatistics, type SalesStatisticsResult } from "../api";
import SalesDashboard from "../components/SalesDashboard";
export default function SalesStatisticsView() {
const [data, setData] = useState<SalesStatisticsResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
const result = await getSalesStatistics();
if (result === false) setError(new Error("数据获取失败!"));
else setData(result);
setLoading(false);
}, []);
useEffect(() => {
void load();
}, [load]);
if (loading) return <Spin />;
if (error) {
return (
<Empty description={error.message}>
<Button type="primary" danger onClick={() => void load()}>
重试
</Button>
</Empty>
);
}
return data ? <SalesDashboard data={data} /> : null;
}
......@@ -10,9 +10,7 @@ const LabelRoutes = lazy(() => import("./label/routes"));
const SaleRoutes = lazy(() => import("./sale/routes"));
const QuestionBankRoutes = lazy(() => import("./question-bank/routes"));
const DiscussionRoutes = lazy(() => import("./discussion/routes"));
const OrderManagementLayout = lazy(() => import("@/pages/books/order-management/index"));
const OrderTable = lazy(() => import("@/pages/books/order-management/tab/table"));
const SaleStatistics = lazy(() => import("@/pages/books/order-management/tab/salesStatistics"));
const OrdersRoutes = lazy(() => import("./orders/routes"));
const Design = lazy(() => import("@/pages/books/design/index"));
export default function BooksRoutes() {
......@@ -28,15 +26,7 @@ export default function BooksRoutes() {
{ path: "sale/*", Component: SaleRoutes },
{ path: "question-bank/*", Component: QuestionBankRoutes },
{ path: "discussion/*", Component: DiscussionRoutes },
{
path: "order-management",
Component: OrderManagementLayout,
children: [
{ index: true, Component: OrderTable },
{ path: "dataset", Component: OrderTable },
{ path: "sale-statistics", Component: SaleStatistics },
],
},
{ path: "order-management/*", Component: OrdersRoutes },
{ path: "design", Component: Design },
],
},
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论