报表页:SQL 实现销售统计
Filter API 能满足常规的 CRUD 操作,但涉及跨表关联、分组聚合、复杂统计时就不够用了。这时候需要自定义 SQL。
这篇以销售数据报表为例,演示如何在 Lovrabet 平台上编写 SQL 语句,实现复杂的数据查询和统计。
本节你将学到
- 什么时候需要自定义 SQL
- 在平台上创建和管理 SQL
- 前端调用自定义 SQL
- 使用 sqlSafe 简化错误处理
需求
实现一个销售数据报表,包含:销售总额统计、按商品分组统计销量、按日期统计销售趋势,支持日期范围筛选。
最终效果:

什么时候使用自定义 SQL?
| 场景 | 推荐 API |
|---|---|
| 简单的 CRUD 操作 | Dataset API (filter、getOne) |
| 单表条件查询 | 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 <= #{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 <= #{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 <= #{endDate}
</if>
GROUP BY DATE(order_date)
ORDER BY saleDate
关键点:
- 使用
#{paramName}进行参数化查询 - 使用
<if test="">实现动态 SQL - 注意 XML 转义:
<=需要写成<=
步骤 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));
| 参数 | 类型 | 说明 |
|---|---|---|
sqlCode | string | SQL 代码(平台创建时生成) |
params | Record<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 转义
| 字符 | 转义后 |
|---|---|
< | < |
> | > |
& | & |
' | ' |
" | " |
<!-- 正确:使用转义 -->
AND date <= #{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 < #{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: 常见原因:
- SQL 语法错误(检查 XML 转义)
- 表或字段名不正确
- 参数类型不匹配
调试方法:在平台的 SQL 管理页面先测试 SQL。
Q: sqlSafe 报错找不到?
A: 确认 SDK 版本 >= 1.2.10,或使用传统方式:
import { sqlSafe } from "@lovrabet/sdk"; // v1.2.10+
Q: 如何处理大数据量报表?
A:
- 使用
LIMIT限制返回行数 - 添加日期范围筛选
- 使用分页查询
- 考虑使用后台任务预计算
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 转义 | <= 要写成 <= |
最佳实践
- 使用参数化查询防止 SQL 注入
- 只查询需要的字段,使用 LIMIT 限制行数
- 前端使用 Promise.all 并行查询多个 SQL
- 复杂报表考虑使用 Backend Function 预计算
下一步
- 数据保存前校验 — 学习在数据操作前进行自动校验
相关阅读
核心文档
- SQL API 使用指南 — 自定义 SQL 查询完整说明
- SQL 教程 — SQL 最佳实践和性能优化
- 语法糖 — sqlSafe 等便捷函数
进阶主题
- Backend Function 调用 SQL — BF 调用自定义 SQL
- 多表关联查询 — filter 多表查询和 SQL 对比
- Filter API 完整指南 — filter API 高级查询
难度等级:L2 | 预计耗时:40 分钟