跳到主要内容

报表页:SQL 实现销售统计

Filter API 能满足常规的 CRUD 操作,但涉及跨表关联、分组聚合、复杂统计时就不够用了。这时候需要自定义 SQL

这篇以销售数据报表为例,演示如何在 Lovrabet 平台上编写 SQL 语句,实现复杂的数据查询和统计。

本节你将学到
  • 什么时候需要自定义 SQL
  • 在平台上创建和管理 SQL
  • 前端调用自定义 SQL
  • 使用 sqlSafe 简化错误处理

需求

实现一个销售数据报表,包含:销售总额统计、按商品分组统计销量、按日期统计销售趋势,支持日期范围筛选。

最终效果:

销售报表页面


什么时候使用自定义 SQL?

场景推荐 API
简单的 CRUD 操作Dataset API (filtergetOne)
单表条件查询Filter API
跨表关联查询自定义 SQL ✅
复杂聚合统计自定义 SQL ✅
数据分组求和自定义 SQL ✅

实现步骤

步骤 1:在平台上创建自定义 SQL

在 Lovrabet 平台创建以下 SQL 查询:

SQL 1:销售概览统计

SQL 名称salesSummary

SELECT
COUNT(*) as orderCount,
COALESCE(SUM(total_amount), 0) as totalAmount
FROM dataset_orders
WHERE 1=1
<if test="startDate">
AND order_date >= #{startDate}
</if>
<if test="endDate">
AND order_date &lt;= #{endDate}
</if>

SQL 2:商品销量排行

SQL 名称productRanking

SELECT
p.product_name,
SUM(oi.quantity) as totalQuantity,
SUM(oi.subtotal) as totalAmount
FROM dataset_order_items oi
JOIN dataset_orders o ON oi.order_id = o.id
JOIN dataset_products p ON oi.product_id = p.id
WHERE 1=1
<if test="startDate">
AND o.order_date >= #{startDate}
</if>
<if test="endDate">
AND o.order_date &lt;= #{endDate}
</if>
GROUP BY p.product_name
ORDER BY totalAmount DESC
LIMIT 10

SQL 3:每日销售趋势

SQL 名称dailySalesTrend

SELECT
DATE(order_date) as saleDate,
COUNT(*) as orderCount,
COALESCE(SUM(total_amount), 0) as totalAmount
FROM dataset_orders
WHERE 1=1
<if test="startDate">
AND order_date >= #{startDate}
</if>
<if test="endDate">
AND order_date &lt;= #{endDate}
</if>
GROUP BY DATE(order_date)
ORDER BY saleDate

关键点

  • 使用 #{paramName} 进行参数化查询
  • 使用 <if test=""> 实现动态 SQL
  • 注意 XML 转义:<= 需要写成 &lt;=

步骤 2:创建报表页面组件

// src/pages/sales-report.tsx

/**
* Title: 销售报表
*/
import { useState, useEffect } from "react";
import { lovrabetClient, sqlSafe } from "@lovrabet/sdk";

interface SalesSummary {
orderCount: number;
totalAmount: number;
}

interface ProductRanking {
product_name: string;
totalQuantity: number;
totalAmount: number;
}

interface DailyTrend {
saleDate: string;
orderCount: number;
totalAmount: number;
}

export default function SalesReport() {
const [dateRange, setDateRange] = useState({
startDate: getMonthStart(),
endDate: new Date().toISOString().split("T")[0],
});

const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<SalesSummary | null>(null);
const [ranking, setRanking] = useState<ProductRanking[]>([]);
const [trend, setTrend] = useState<DailyTrend[]>([]);

// 获取报表数据
const fetchReport = async () => {
setLoading(true);
try {
// 并行查询三个 SQL
const [summaryResult, rankingResult, trendResult] = await Promise.all([
querySummary(),
queryRanking(),
queryTrend(),
]);

setSummary(summaryResult);
setRanking(rankingResult);
setTrend(trendResult);
} catch (error) {
console.error("获取报表失败:", error);
alert("获取报表失败,请重试");
} finally {
setLoading(false);
}
};

// 查询销售概览
const querySummary = async (): Promise<SalesSummary> => {
const { data, error } = await sqlSafe(async () =>
lovrabetClient.sql.execute({
sqlCode: "salesSummary",
params: dateRange,
})
);

if (error || !data || data.length === 0) {
return { orderCount: 0, totalAmount: 0 };
}

return data[0];
};

// 查询商品排行
const queryRanking = async (): Promise<ProductRanking[]> => {
const { data, error } = await sqlSafe(async () =>
lovrabetClient.sql.execute({
sqlCode: "productRanking",
params: dateRange,
})
);

return data || [];
};

// 查询销售趋势
const queryTrend = async (): Promise<DailyTrend[]> => {
const { data, error } = await sqlSafe(async () =>
lovrabetClient.sql.execute({
sqlCode: "dailySalesTrend",
params: dateRange,
})
);

return data || [];
};

// 初始加载
useEffect(() => {
fetchReport();
}, []);

// 格式化金额
const formatMoney = (amount: number) => {
return `¥${amount.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`;
};

// 导出 Excel
const handleExport = async () => {
try {
const fileUrl = await lovrabetClient.models.dataset_orders.excelExport({
startDate: dateRange.startDate,
endDate: dateRange.endDate,
});
window.open(fileUrl, "_blank");
} catch (error) {
console.error("导出失败:", error);
alert("导出失败,请重试");
}
};

return (
<div className="sales-report">
<div className="header">
<h1>销售报表</h1>
<button onClick={handleExport}>导出Excel</button>
</div>

{/* 日期筛选 */}
<div className="filters">
<label>
日期范围:
<input
type="date"
value={dateRange.startDate}
onChange={(e) =>
setDateRange({ ...dateRange, startDate: e.target.value })
}
/>

<input
type="date"
value={dateRange.endDate}
onChange={(e) =>
setDateRange({ ...dateRange, endDate: e.target.value })
}
/>
</label>
<button onClick={fetchReport} disabled={loading}>
{loading ? "查询中..." : "查询"}
</button>
</div>

{/* 销售概览 */}
{summary && (
<div className="summary-card">
<h3>销售概览</h3>
<div className="stats">
<div className="stat-item">
<span className="label">订单数量</span>
<span className="value">
{summary.orderCount.toLocaleString()}
</span>
</div>
<div className="stat-item">
<span className="label">销售总额</span>
<span className="value">{formatMoney(summary.totalAmount)}</span>
</div>
</div>
</div>
)}

{/* 商品排行 */}
{ranking.length > 0 && (
<div className="ranking-card">
<h3>商品销量排行</h3>
<table>
<thead>
<tr>
<th>排名</th>
<th>商品名称</th>
<th>销量</th>
<th>销售额</th>
</tr>
</thead>
<tbody>
{ranking.map((item, index) => (
<tr key={index}>
<td>{index + 1}</td>
<td>{item.product_name}</td>
<td>{item.totalQuantity}</td>
<td>{formatMoney(item.totalAmount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{/* 销售趋势 */}
{trend.length > 0 && (
<div className="trend-card">
<h3>销售趋势</h3>
<table className="trend-table">
<thead>
<tr>
<th>日期</th>
<th>订单数</th>
<th>销售额</th>
</tr>
</thead>
<tbody>
{trend.map((item, index) => (
<tr key={index}>
<td>{item.saleDate}</td>
<td>{item.orderCount}</td>
<td>{formatMoney(item.totalAmount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

// 获取月初日期
function getMonthStart(): string {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1)
.toISOString()
.split("T")[0];
}

知识点整理

SQL execute API

执行自定义 SQL 查询:

// 使用 sqlSafe 语法糖(推荐)
import { sqlSafe } from "@lovrabet/sdk";

const { data, error } = await sqlSafe(async () =>
client.sql.execute({
sqlCode: "xxxxx-xxxxx", // SQL 代码
params: { startDate: "2024-01-01" }, // SQL 参数
})
);

if (error) {
console.error("查询失败:", error.message);
return;
}

// data 是查询结果数组
data.forEach((row) => console.log(row));
参数类型说明
sqlCodestringSQL 代码(平台创建时生成)
paramsRecord<string, any>参数对象(对应 SQL 中的 #{param}

返回值

interface SqlExecuteResult<T> {
execSuccess: boolean; // SQL 是否执行成功
execResult?: T[]; // 查询结果数组
}

sqlSafe 语法糖

SDK 提供了 sqlSafe 函数,简化错误处理:

// 不使用 sqlSafe:需要手动检查 execSuccess
const result = await client.sql.execute({ sqlCode: "xxx" });
if (result.execSuccess && result.execResult) {
console.log(result.execResult);
}

// 使用 sqlSafe:一次检查
const { data, error } = await sqlSafe(() =>
client.sql.execute({ sqlCode: "xxx" })
);

if (error) {
console.error("失败:", error.message);
return;
}

console.log(data); // 直接是结果数组

MyBatis 动态 SQL 语法

if 条件

SELECT * FROM dataset_orders
WHERE 1=1
<if test="status">
AND status = #{status}
</if>
<if test="startDate">
AND create_time >= #{startDate}
</if>

注意 XML 转义

字符转义后
<&lt;
>&gt;
&&amp;
'&apos;
"&quot;
<!-- 正确:使用转义 -->
AND date &lt;= #{endDate}

<!-- 错误:直接写 <= 会被解析为 XML -->
AND date <= #{endDate}

常见报表 SQL 模板

分组统计

SELECT
category,
COUNT(*) as count,
SUM(amount) as total
FROM dataset_orders
GROUP BY category

关联查询

SELECT
o.id,
o.order_date,
c.customer_name
FROM dataset_orders o
LEFT JOIN dataset_customers c ON o.customer_id = c.id
WHERE o.status = 'completed'

排名查询

SELECT
product_name,
sales_count,
RANK() OVER (ORDER BY sales_count DESC) as rank
FROM (
SELECT
product_name,
COUNT(*) as sales_count
FROM dataset_orders
GROUP BY product_name
) t

日期范围统计

SELECT
DATE(create_time) as date,
COUNT(*) as count
FROM dataset_orders
WHERE create_time >= #{startDate}
AND create_time &lt; #{endDate}
GROUP BY DATE(create_time)

几个好的做法

使用参数化查询

-- ✅ 推荐:参数化查询,防止 SQL 注射
SELECT * FROM dataset_orders WHERE id = #{orderId}

-- ❌ 不推荐:字符串拼接(不安全)
SELECT * FROM dataset_orders WHERE id = ${orderId}

合理使用 LIMIT

-- 查询所有数据(可能很慢)
SELECT * FROM large_table

-- 使用 LIMIT 限制返回行数
SELECT * FROM large_table LIMIT 1000

只查询需要的字段

-- ❌ 查询所有字段
SELECT * FROM dataset_orders

-- ✅ 只查询需要的字段
SELECT id, order_date, total_amount FROM dataset_orders

前端并行查询

// 并行执行多个 SQL,提高性能
const [summary, ranking, trend] = await Promise.all([
querySummary(),
queryRanking(),
queryTrend(),
]);

常见问题

Q: SQL 执行失败,execSuccess 为 false?

A: 常见原因:

  1. SQL 语法错误(检查 XML 转义)
  2. 表或字段名不正确
  3. 参数类型不匹配

调试方法:在平台的 SQL 管理页面先测试 SQL。

Q: sqlSafe 报错找不到?

A: 确认 SDK 版本 >= 1.2.10,或使用传统方式:

import { sqlSafe } from "@lovrabet/sdk"; // v1.2.10+

Q: 如何处理大数据量报表?

A

  1. 使用 LIMIT 限制返回行数
  2. 添加日期范围筛选
  3. 使用分页查询
  4. 考虑使用后台任务预计算

Q: 前端和 BFF 中 SQL API 返回值有什么区别?

A

环境返回值数据获取方式
前端 SDK{ execSuccess, execResult }需检查 execSuccess
Backend Function直接返回数组直接使用结果
// 前端 SDK
const result = await client.sql.execute({ sqlCode: "xxx" });
if (result.execSuccess && result.execResult) {
result.execResult.forEach(...);
}

// Backend Function
const rows = await context.client.sql.execute({ sqlCode: "xxx" });
rows.forEach(...); // 直接是数组

本节小结

恭喜你学会了自定义 SQL 查询!关键知识点:

知识点说明
sql.execute()执行自定义 SQL
sqlSafe()简化错误处理的语法糖
MyBatis 语法#{param} 参数化、<if test=""> 动态 SQL
XML 转义<= 要写成 &lt;=
最佳实践
  • 使用参数化查询防止 SQL 注入
  • 只查询需要的字段,使用 LIMIT 限制行数
  • 前端使用 Promise.all 并行查询多个 SQL
  • 复杂报表考虑使用 Backend Function 预计算

下一步

相关阅读

核心文档

进阶主题


难度等级:L2 | 预计耗时:40 分钟