8. 报表页
报表页:SQL 实现销售统计
Filter API 能满足常规的 CRUD 操作,但涉及跨表关联、分组聚合、复杂统计时就不够用了。这时候需要自定义 SQL。这篇以销售数据报表为例,演示如何在 Lovrabet 平台上编写 SQL 语句,实现复杂的数据查询和统计。
本节你将学到
-
什么时候需要自定义 SQL
-
用 CLI 创建和管理自定义 SQL
-
前端调用自定义 SQL
-
使用 sqlSafe 简化错误处理
需求
实现一个销售数据报表,包含:销售总额统计、按商品分组统计销量、按日期统计销售趋势,支持日期范围筛选。最终效果:
销售报表页面
什么时候使用自定义 SQL?
场景 | 推荐 API |
简单的 CRUD 操作 | Dataset API ( |
单表条件查询 | Filter API |
跨表关联查询 | 自定义 SQL ✅ |
复杂聚合统计 | 自定义 SQL ✅ |
数据分组求和 | 自定义 SQL ✅ |
AI 辅助开发(推荐)
**为什么 AI 能帮你做报表页?**没有 rabetbase,每个报表都需要后端专门写一个接口。销售汇总 = 一个接口 + 一条 SQL。商品排行 = 再一个。销售趋势 = 再来一个。后端写了 3 条 SQL,字段名改了 4 遍(orderId 不是 order\_id,price 不是 unit\_price,金额存的是分不是元)。SQL 调通了还得包装成 API:加认证、参数校验、错误处理、配跨域。3 个接口后端搞了 3 天,前端又等了 3 天。联调时参数占位符 ? 和 \{\{param\}\} 混用又报错。3 个报表,前后端加起来花了 2 周。有了 rabetbase:不用写后端接口。CLI 的 sql validate 校验 SQL 语法和字段名(直接报错告诉你哪个字段名不对),sql save 保存到平台。前端用 sql\.execute\(\&\#39;sqlCode\&\#39;, params\) 一行调用。3 条 SQL + 1 个报表页面,不用包装 API、不用等后端、不用联调。半天搞定。
在 Claude Code 中输入:
用 rabetbase CLI 帮我创建一个销售报表页面。需要三个自定义 SQL:1) salesSummary 统计订单数和总额;2) productRanking 商品销量排行 TOP 10;3) dailySalesTrend 每日销售趋势。都支持按日期范围筛选。然后生成报表页面组件。
AI 会做什么
AI 会使用 rabetbase CLI 自动完成以下工作:
-
查询订单、订单明细、商品数据集结构(
rabetbase dataset detail) -
创建 3 个 SQL 文件并保存到平台(
rabetbase sql validate+rabetbase sql save) -
生成完整的前端报表页面组件(含查询、图表、导出)完成后即可在前端页面调用 SQL 查询报表数据。下方是 AI 生成的 SQL 和前端代码。
🔧 手动操作(备选)
方式一:手动 CLI
按下方「步骤 1」创建 SQL 文件,然后逐个执行 rabetbase sql validate + rabetbase sql save。
方式二:平台 UI
在平台的 SQL 管理页面创建:https://app\.lovrabet\.com/app/\{appCode\}/data/custom\-sql
实现代码
步骤 1:SQL 文件代码参考
SQL 1:销售概览统计
\.rabetbase/sql/salesSummary\.sql:
-- @lovrabet sqlName=salesSummary description=销售概览统计
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创建本地 SQL 文件 \.rabetbase/sql/productRanking\.sql:
-- @lovrabet sqlName=productRanking description=商品销量排行
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创建本地 SQL 文件 \.rabetbase/sql/dailySalesTrend\.sql:
-- @lovrabet sqlName=dailySalesTrend description=每日销售趋势
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=\&\#34;\&\#34;\>实现动态 SQL -
注意 XML 转义:
\<=需要写成\<=手动验证并保存:
rabetbase sql validate --file .rabetbase/sql/salesSummary.sql --format json
rabetbase sql save --file .rabetbase/sql/salesSummary.sql --db <dbId> --format json
rabetbase sql exec --sqlcode salesSummary --params '{}' --format json
步骤 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));
参数 | 类型 | 说明 |
|
| SQL 代码(CLI 保存时自动生成) |
|
| 参数对象(对应 SQL 中的 |
返回值:
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 转义)
-
表或字段名不正确
-
参数类型不匹配调试方法:先用 CLI 执行 SQL 确认语法正确:
rabetbase sql exec --sqlcode <sqlcode> --params '{}' --format json
Q: sqlSafe 报错找不到?
A: 确认 SDK 版本 >= 1.3.7,或使用传统方式::
import { sqlSafe } from "@lovrabet/sdk"; // v1.3.7+
Q: 如何处理大数据量报表?
A:
-
使用
LIMIT限制返回行数 -
添加日期范围筛选
-
使用分页查询
-
考虑使用后台任务预计算
Q: 前端和 BFF 中 SQL API 返回值有什么区别?
A:
环境 | 返回值 | 数据获取方式 |
前端 SDK |
| 需检查 |
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 |
| 简化错误处理的语法糖 |
MyBatis 语法 |
|
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 分钟