跳到主要内容

8. 报表页

报表页:SQL 实现销售统计

Filter API 能满足常规的 CRUD 操作,但涉及跨表关联、分组聚合、复杂统计时就不够用了。这时候需要自定义 SQL。这篇以销售数据报表为例,演示如何在 Lovrabet 平台上编写 SQL 语句,实现复杂的数据查询和统计。

本节你将学到

  • 什么时候需要自定义 SQL

  • 用 CLI 创建和管理自定义 SQL

  • 前端调用自定义 SQL

  • 使用 sqlSafe 简化错误处理

需求

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

销售报表页面

什么时候使用自定义 SQL?

场景

推荐 API

简单的 CRUD 操作

Dataset API (filtergetOne)

单表条件查询

Filter API

跨表关联查询

自定义 SQL ✅

复杂聚合统计

自定义 SQL ✅

数据分组求和

自定义 SQL ✅


AI 辅助开发(推荐)

**为什么 AI 能帮你做报表页?**没有 rabetbase,每个报表都需要后端专门写一个接口。销售汇总 = 一个接口 + 一条 SQL。商品排行 = 再一个。销售趋势 = 再来一个。后端写了 3 条 SQL,字段名改了 4 遍(orderId 不是 order\_idprice 不是 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 自动完成以下工作:

  1. 查询订单、订单明细、商品数据集结构(rabetbase dataset detail

  2. 创建 3 个 SQL 文件并保存到平台(rabetbase sql validate + rabetbase sql save

  3. 生成完整的前端报表页面组件(含查询、图表、导出)完成后即可在前端页面调用 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\} 进行参数化查询

  • 使用 \&lt;if test=\&\#34;\&\#34;\&gt; 实现动态 SQL

  • 注意 XML 转义:\&lt;= 需要写成 \&lt;=手动验证并保存:

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));


参数

类型

说明

sqlCode

string

SQL 代码(CLI 保存时自动生成)

params

Record\&lt;string, any\&gt;

参数对象(对应 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;

\&lt;

\&gt;

\&gt;

\&amp;

\&amp;

\&\#39;

\&amp;apos;

\&\#34;

\&\#34;

<!-- 正确:使用转义 -->
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: 常见原因:

  1. SQL 语法错误(检查 XML 转义)

  2. 表或字段名不正确

  3. 参数类型不匹配调试方法:先用 CLI 执行 SQL 确认语法正确:

rabetbase sql exec --sqlcode <sqlcode> --params '{}' --format json


Q: sqlSafe 报错找不到?

A: 确认 SDK 版本 &gt;= 1.3.7,或使用传统方式::

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


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\} 参数化、\&lt;if test=\&\#34;\&\#34;\&gt; 动态 SQL

XML 转义

\&lt;= 要写成 \&lt;=

最佳实践

  • 使用参数化查询防止 SQL 注入

  • 只查询需要的字段,使用 LIMIT 限制行数

  • 前端使用 Promise.all 并行查询多个 SQL

  • 复杂报表考虑使用 Backend Function 预计算

下一步

相关阅读

核心文档

  • SQL API 使用指南 — 自定义 SQL 查询完整说明

  • SQL 教程 — SQL 最佳实践和性能优化

  • 语法糖 — sqlSafe 等便捷函数

进阶主题


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