- 前端
- UI组件
【免费下载链接】handsontable
JavaScript Data Grid / Data Table with a Spreadsheet Look & Feel. Works with React, Angular, and Vue. Supported by the Handsontable team ⚡
本教程演示如何将 Handsontable 的dataProvider插件接入 Django REST Framework(DRF)后端:分页、排序、过滤全部在服务端完成,前端网格展示结果并把所有编辑操作回写 API。读者将掌握dataProvider的fetchRows/onRowsCreate/onRowsUpdate/onRowsRemove完整协议、DRF 端的分页形状映射与批量@action设计,以及 Django CSRF 在前端 fetch 场景下的透明处理方案。
难度:中级 | 预计耗时:约 30 分钟 | 后端环境:Python 3.11+、Django 4+、Django REST Framework 3.14+ 本教程对应的完整可运行示例位于仓库的 docs/content/recipes/data-management/server-side-django 目录下(
server/为 Django 后端源码,javascript/、react/、angular/为各框架前端示例)。
构建目标:一个员工通讯录网格
本教程最终交付一个"员工通讯录"数据网格,它具备以下能力:
- 从 DRF API 逐页加载数据(分页在服务端完成)
- 点击任意列头,在服务端按该列排序
- 按列值在服务端过滤行
- 通过批量 API 端点创建、更新、删除行
- 透明处理 Django 的 CSRF 防护
为什么需要服务端数据模式
当数据量达到数万甚至数十万行时,把全部数据一次性塞进浏览器既不现实也不高效。Handsontable 的dataProvider插件正是为此设计:它把网格的"数据源"从本地数组切换为外部服务端。
从源码结构看,dataProvider 插件 的核心约定是:
rowId指定标识行的字段名;fetchRows(queryParameters, options)负责拉取{ page, pageSize, sort, filters }对应的数据,返回{ rows, totalRows };onRowsCreate/onRowsUpdate/onRowsRemove三个回调把网格上的增、改、删操作转发给服务端。
当dataProvider配置为完整的服务端配置(上述键全部存在且合法)时,Handsontable 通过fetchRows加载行、通过回调执行变更,并且内部钩子hasExternalDataSource返回true,使 Filters、Pagination 等插件把网格当作服务端驱动模式处理(见 dataProvider.ts)。此外,fetchRows收到的AbortSignal会传递给底层fetch(),从而在用户连续排序/过滤时取消过期请求(见 dataProvider.ts)。
本教程把 Django 作为这个服务端数据源,走通完整链路。
架构总览
浏览器 (Vite dev server, :5173) │ /api/* 由 Vite 代理转发 ▼ Django REST Framework (:8000,运行在 Docker 内) │ ▼ PostgreSQL(Docker 容器)- 前端:Vite 开发服务器 + Handsontable +
dataProvider插件 - 后端:Django + DRF + PostgreSQL,全部跑在 Docker Compose 中
- 开发期通过 Vite 代理
/api/*到http://localhost:8000,浏览器与 Django 同源,CSRF Cookie 无需额外 CORS 配置即可访问
开始之前
需要安装:
- Docker 与 Docker Compose
- Node.js 与 npm
无需本地 Python 环境——Django 后端和 PostgreSQL 数据库都运行在 Docker 容器内。
Step 1 — 搭建 Django 应用
运行bash setup.sh(或make setup)即可完成:脚本通过 Docker Compose 构建并启动 PostgreSQL 与 Django、执行数据库迁移、写入 50 条员工种子数据,并在http://localhost:5173打开 Vite 开发服务器。
Django 项目使用一个独立的employees应用来承载员工模块。
为什么单独建一个 app?Django 的 app 是自包含的模块。把员工模型、序列化器和视图集中在一个 app 内,便于独立扩展与测试。
在settings.py中注册 app 与所需第三方包(完整片段见 server/settings-snippet.py):
# settings.py -- 只列出与本教程相关的增量配置 INSTALLED_APPS = [ # ... 你已有的 apps ... "rest_framework", "corsheaders", # django-cors-headers "django_filters", # django-filter(可选,用于 DjangoFilterBackend) "employees", # 本项目 app ] MIDDLEWARE = [ # CorsMiddleware 必须放在 CommonMiddleware 之前 "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", # ... 其余 middleware ... ] # --- CORS --- # 允许来自前端开发服务器的请求。 # 生产环境请限制为你的真实域名。 CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", # Vite dev server "http://localhost:3000", # Create React App / Next.js dev server "https://your-production-domain.com", ] # 如果前端需要携带凭证(Cookie、Authorization 头),还要设置: # CORS_ALLOW_CREDENTIALS = True # --- Django REST Framework --- REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "employees.pagination.EmployeePagination", "PAGE_SIZE": 10, "DEFAULT_FILTER_BACKENDS": [ "rest_framework.filters.OrderingFilter", "rest_framework.filters.SearchFilter", # 如需按字段精确匹配可启用 DjangoFilterBackend: # "django_filters.rest_framework.DjangoFilterBackend", ], }Step 2 — 定义 Employee 模型
在employees/models.py中创建模型(完整代码见 server/models.py):
from django.db import models class Employee(models.Model): """员工通讯录演示模型。 字段与 Handsontable 前端的列定义一一对应: id 由 Django 自动生成,用作前端 rowId。 """ DEPARTMENT_CHOICES = [ ("Engineering", "Engineering"), ("Marketing", "Marketing"), ("Sales", "Sales"), ("HR", "HR"), ("Finance", "Finance"), ("Operations", "Operations"), ] first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) department = models.CharField(max_length=100, choices=DEPARTMENT_CHOICES) role = models.CharField(max_length=100) # DecimalField 避免货币金额的浮点舍入误差 salary = models.DecimalField(max_digits=10, decimal_places=2) class Meta: # 默认按姓氏排序,保证网格首次展示顺序可预期 ordering = ["last_name", "first_name"] def __str__(self): return f"{self.first_name} {self.last_name} ({self.department})"设计要点:
DecimalField以定点数存储薪资,避免浮点数舍入误差——这对货币值至关重要;Meta.ordering设置默认查询顺序,用户点击列头排序时由视图中的排序逻辑覆盖(详见 Step 6);- Django 自动添加
id主键,它将成为前端rowId的值。
创建数据库表,执行迁移:
python manage.py makemigrations employees python manage.py migrateStep 3 — 填充种子数据
创建种子命令文件employees/management/commands/seed.py(完整代码见 server/seed_command.py)。该文件位于标准的 management 命令目录结构中:
employees/management/__init__.py (空文件) employees/management/commands/__init__.py (空文件) employees/management/commands/seed.py (本命令)运行:
python manage.py seed命令会插入 50 条贴近现实的员工记录(覆盖 Engineering、Marketing、Sales、HR、Finance、Operations 六个部门)。它会在插入前检查Employee.objects.exists(),因此重复运行不会产生重复数据——已存在数据时输出 "Database already seeded -- skipping." 并直接返回。
Step 4 — 编写序列化器
创建employees/serializers.py(完整代码见 server/serializers.py):
from rest_framework import serializers from .models import Employee class EmployeeSerializer(serializers.ModelSerializer): """Employee 模型的序列化器。 ModelSerializer 会自动从模型定义生成字段与校验规则, 无需手动声明字段。`id` 字段只读,作为前端 rowId。 """ class Meta: model = Employee fields = ["id", "first_name", "last_name", "department", "role", "salary"] read_only_fields = ["id"]设计要点:
ModelSerializer自动检查模型并生成字段定义与校验规则;id为只读:数据库负责分配主键,前端新建行时无需也不允许提交id;fields列表决定了 API 响应中出现哪些列,也就决定了 Handsontable 接收哪些列——前后端列集合由此保持一致。
Step 5 — 配置分页
创建employees/pagination.py(完整代码见 server/pagination.py):
from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response class EmployeePagination(PageNumberPagination): page_size = 10 page_size_query_param = "pageSize" # 与 Handsontable 默认参数名一致 max_page_size = 100 def get_paginated_response(self, data): """返回 dataProvider 期望的响应形状:{ rows: [...], totalRows: N }""" return Response( { "rows": data, "totalRows": self.page.paginator.count, } ) def get_paginated_response_schema(self, schema): return { "type": "object", "properties": { "totalRows": {"type": "integer"}, "rows": schema, }, }为什么要自定义分页类?
DRF 默认的响应形状是{ count, next, previous, results },而 Handsontable 的dataProvider期望{ rows, totalRows }。通过重写get_paginated_response在服务端完成形状映射,前端fetchRows回调里就可以直接return res.json(),无需任何额外转换。
为什么设置page_size_query_param = 'pageSize'?
Handsontable 会自动发送?pageSize=10。DRF 默认的查询参数名是page_size,将其改为pageSize后 DRF 可以直接读取 Handsontable 发出的值,fetchRows中就不需要做任何 URL 参数翻译。
Step 6 — 编写 ViewSet
创建employees/views.py,这是整个后端最核心的部分(完整代码见 server/views.py)。关键包括排序翻译、过滤翻译和三个批量 CRUD 动作。
排序翻译
Handsontable 发送?sort[prop]=salary&sort[order]=desc。在get_queryset中完成翻译:
sort_prop = self.request.query_params.get('sort[prop]') sort_order = self.request.query_params.get('sort[order]', 'asc') if sort_prop and sort_prop in ALLOWED_ORDERING_FIELDS: prefix = '' if sort_order == 'asc' else '-' queryset = queryset.order_by(f'{prefix}{sort_prop}')原理拆解:
- 读取 Handsontable 的
sort[prop]和sort[order]参数; - 降序时在字段名前加
-前缀(Django ORM 约定); - 直接调用
queryset.order_by()。ALLOWED_ORDERING_FIELDS是一个白名单集合({'first_name', 'last_name', 'department', 'role', 'salary'}),只有白名单内的字段才允许进入order_by,从而阻止通过任意字段名进行的 ORM 注入。
过滤翻译
dataProvider把过滤条件编码为单个filters查询参数,其中是 JSON 数组,每个元素是一个DataProviderFilterColumn对象:
[ { "prop": "department", "operation": "conjunction", "conditions": [{ "name": "eq", "args": ["Engineering"] }] } ]后端用json.loads()解码并构造 DjangoQ对象。从源码看,完整翻译逻辑如下(server/views.py):
filters_json = self.request.query_params.get('filters') if filters_json: try: filter_cols = json.loads(filters_json) q = Q() for col in filter_cols: prop = col.get('prop', '') operation = col.get('operation', 'conjunction') conditions = col.get('conditions') or [] if prop not in ALLOWED_ORDERING_FIELDS: continue # ... 逐个条件构造 Q 对象 ... # 列内条件按 operation(conjunction=AND / disjunction=OR)组合 # 列与列之间始终用 AND 组合 queryset = queryset.filter(q) except (json.JSONDecodeError, TypeError, KeyError): pass原理拆解:
json.loads(filters_json)把单个filters参数解码为列过滤对象列表;- 每个列对象包含
prop、operation(conjunction= AND,disjunction= OR)和conditions列表; - 同一列内的条件按
operation组合,不同列之间始终以 AND 组合; eq/neq:数值字段(salary)用exact(DecimalField 拒绝iexact),文本字段用iexact;empty/not_empty:文本字段同时处理null与空字符串两种情况,数值字段则用isnull判断;prop同样经过ALLOWED_ORDERING_FIELDS白名单校验,防止 ORM 注入。
源码中还定义了一张条件名到 Django ORM 查找后缀的映射表(server/views.py),供过滤翻译复用:
| Handsontable 条件名 | Django ORM 查找 | 是否取反 |
|---|---|---|
contains | icontains | 否 |
not_contains | icontains | 是 |
begins_with | istartswith | 否 |
ends_with | iendswith | 否 |
gte | gte | 否 |
lte | lte | 否 |
gt | gt | 否 |
lt | lt | 否 |
批量 CRUD 端点
标准 REST 约定是单资源端点(POST /employees/、DELETE /employees/{id}/),但 Handsontable 的dataProvider会把所有变更以数组形式放进一次请求。DRF 的@action装饰器可以优雅地解决这个问题,无需手工编写额外 URL 路由。三个动作的完整实现见 server/views.py:
@action(detail=False, methods=['post'], url_path='create-rows') @transaction.atomic def create_rows(self, request): """POST /api/employees/create-rows/ 载荷形状:{ rowsAmount: 2 }""" rows_amount = max(1, int(request.data.get('rowsAmount') or 1)) employees = Employee.objects.bulk_create([ Employee(first_name='', last_name='', department='', role='', salary=0) for _ in range(rows_amount) ]) serializer = EmployeeSerializer(employees, many=True) return Response(serializer.data, status=201) @action(detail=False, methods=['patch'], url_path='update-rows') @transaction.atomic def update_rows(self, request): """PATCH /api/employees/update-rows/ 载荷形状:[{ id: 7, changes: { salary: 102000 } }, ...]""" updated = [] for row in request.data: employee = Employee.objects.get(pk=row['id']) serializer = EmployeeSerializer(employee, data=row['changes'], partial=True) serializer.is_valid(raise_exception=True) serializer.save() updated.append(serializer.data) return Response(updated) @action(detail=False, methods=['delete'], url_path='remove-rows') def remove_rows(self, request): """DELETE /api/employees/remove-rows/ 载荷形状:[3, 7, 14]""" deleted_count, _ = Employee.objects.filter(pk__in=request.data).delete() return Response({'deleted': deleted_count})原理拆解:
detail=False把动作注册在列表 URL(/api/employees/)下,而不是详情 URL(/api/employees/{id}/);create_rows从请求读取rowsAmount,用bulk_create在一条 SQL 语句中插入指定数量的空行,并把带新id的行返回给前端,让dataProvider更新内部行映射;update_rows中partial=True允许只更新部分字段(row['changes']),不必提交全部字段;filter(pk__in=ids).delete()用一条 SQL 语句删除多行;- 两个写动作都加了
@transaction.atomic,保证批量操作的事务性。
为什么不逐行调用标准DELETE /api/employees/{id}/?删除 N 行需要 N 次请求;一次批量请求更快,大幅减少网络往返。
Step 7 — 注册 URL
创建employees/urls.py(完整代码见 server/urls.py):
from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import EmployeeViewSet router = DefaultRouter() router.register(r"employees", EmployeeViewSet, basename="employee") urlpatterns = [ path("api/", include(router.urls)), ]DefaultRouter自动生成 ViewSet 的全部标准与自定义动作路由:
| 方法 | 路径 | 对应动作 |
|---|---|---|
| GET | /api/employees/ | list()(分页列表) |
| POST | /api/employees/ | create()(单行创建;批量请用create-rows/) |
| GET | /api/employees/{id}/ | retrieve() |
| PUT / PATCH | /api/employees/{id}/ | update()/partial_update() |
| DELETE | /api/employees/{id}/ | destroy() |
| POST | /api/employees/create-rows/ | create_rows(批量创建) |
| PATCH | /api/employees/update-rows/ | update_rows(批量更新) |
| DELETE | /api/employees/remove-rows/ | remove_rows(批量删除) |
在项目根urls.py中引入:
from django.urls import include, path urlpatterns = [ path('', include('employees.urls')), ]浏览器访问http://localhost:8000/api/可以查看 DRF 自动生成的 API 路由清单。
Step 8 — 配置 CORS
浏览器默认会拦截跨域请求。需要安装django-cors-headers,并在settings.py中把它加入INSTALLED_APPS和MIDDLEWARE(配置见 server/settings-snippet.py)。
为什么CorsMiddleware必须放在CommonMiddleware之前?CorsMiddleware需要在 Django 的路由逻辑处理预检OPTIONS请求之前拦截它。如果放在CommonMiddleware之后,预检响应可能缺失 CORS 头,导致浏览器拒绝跨域请求。
生产环境注意事项:把开发服务器的来源替换为真实生产域名。绝不要在生产环境设置CORS_ALLOW_ALL_ORIGINS = True。
Step 9 — 前端处理 CSRF
Django 会保护所有变更类端点。它在每个响应中设置csrftokencookie,前端需要读取该 token 并在每次POST、PATCH或DELETE请求中携带X-CSRFToken请求头,否则 Django 返回 403 Forbidden。getCsrfToken的实现见 Step 11 的代码文件:
function getCsrfToken() { return document.cookie .split('; ') .find((row) => row.startsWith('csrftoken=')) ?.split('=')[1]; }为什么用 cookie 而不是隐藏表单字段?Handsontable 使用fetch()而非 HTML 表单提交。从 cookie 读取 token(SameSite + CSRF 双提交模式)对任何 JavaScript HTTP 客户端都通用,无需修改服务端模板。
Step 10 — 构建 fetchRows 的 URL
dataProvider会以{ page, pageSize, sort, filters }对象调用fetchRows,buildUrl把它转换为 DRF 能理解的查询串(完整实现见 javascript/example1.js):
const API_BASE = '/api/employees/'; function buildUrl({ page, pageSize, sort, filters }) { const params = new URLSearchParams(); params.set('page', page); params.set('pageSize', pageSize); if (sort?.prop) { params.set('sort[prop]', sort.prop); params.set('sort[order]', sort.order ?? 'asc'); } // 把完整过滤载荷编码为 JSON 字符串,Django 用 json.loads() 解析嵌套 conditions if (filters?.length) { params.set('filters', JSON.stringify(filters)); } return `${API_BASE}?${params.toString()}`; }原理拆解:
page和pageSize原样发送。因为 Step 5 中设置了page_size_query_param = 'pageSize',DRF 直接读取pageSize;sort拆分为sort[prop]和sort[order],Django 视图读取两个参数并直接调用queryset.order_by()(见 Step 6);filters用JSON.stringify序列化。dataProvider传递完整的DataProviderFilterColumn数组(含operation和嵌套的conditions),Django 用json.loads()解码;API_BASE使用相对路径(/api/employees/)。开发环境下 Vite 把/api/*代理到http://localhost:8000,浏览器与 Django 同源,CSRF cookie 无需额外 CORS 配置即可访问。
Step 11 — 初始化 Handsontable
后端与 Vite 开发服务器运行后(bash setup.sh),打开http://localhost:5173即可看到网格。Django API 运行在 Docker 内的http://localhost:8000,Vite 把所有/api/*请求代理过去。完整前端代码见:
- JavaScript: javascript/example1.js
- TypeScript: javascript/example1.ts
- React: react/example1.jsx
- Angular: angular/example1.ts + angular/example1.html
核心初始化代码(JavaScript 版,其他框架封装方式略有差异但dataProvider配置一致):
import Handsontable from 'handsontable/base'; import { registerPlugin, DataProvider, ContextMenu, Dialog, DropdownMenu, Filters, ColumnSorting, Pagination, EmptyDataState, Notification, } from 'handsontable/plugins'; import { registerAllCellTypes } from 'handsontable/cellTypes'; registerPlugin(DataProvider); registerPlugin(ContextMenu); registerPlugin(Dialog); registerPlugin(DropdownMenu); registerPlugin(Filters); registerPlugin(ColumnSorting); registerPlugin(Pagination); registerPlugin(EmptyDataState); registerPlugin(Notification); registerAllCellTypes(); const container = document.querySelector('#example1'); let removeConfirmed = false; const hot = new Handsontable(container, { dataProvider: { rowId: 'id', fetchRows: async ({ page, pageSize, sort, filters }, { signal }) => { const url = buildUrl({ page, pageSize, sort, filters }); const res = await fetch(url, { signal }); if (!res.ok) { throw new Error(`Fetch failed: ${res.status}`); } // pagination.py 已把 DRF 的 { count, results } 映射为 // { rows, totalRows },因此可以直接返回 res.json() return res.json(); }, onRowsCreate: async ({ rowsAmount }) => { const res = await fetch(`${API_BASE}create-rows/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify({ rowsAmount }), }); if (!res.ok) throw new Error(`Create failed: ${res.status}`); const data = await res.json(); return data; // 返回服务端分配的新行 id,供 dataProvider 更新内部行映射 }, onRowsUpdate: async (rows) => { const res = await fetch(`${API_BASE}update-rows/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(rows), }); if (!res.ok) throw new Error(`Update failed: ${res.status}`); }, onRowsRemove: async (rowIds) => { const res = await fetch(`${API_BASE}remove-rows/`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(rowIds), }); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); }, }, beforeRowsMutation(operation, payload) { // 删除前弹出确认对话框;用户确认后重新发起删除 if (operation === 'remove' && !removeConfirmed) { const count = payload.rowsRemove.length; const notification = hot.getPlugin('notification'); const id = notification.showMessage({ variant: 'warning', title: 'Delete rows', message: `Delete ${count} row${count !== 1 ? 's' : ''}? This cannot be undone.`, duration: 0, actions: [ { label: 'Delete', type: 'primary', callback: () => { notification.hide(id); removeConfirmed = true; hot.getPlugin('dataProvider').removeRows(payload.rowsRemove).finally(() => { removeConfirmed = false; }); }, }, { label: 'Cancel', type: 'secondary', callback: () => notification.hide(id), }, ], }); return false; } }, pagination: { pageSize: 10 }, columnSorting: true, filters: true, dropdownMenu: ['filter_by_condition', 'filter_action_bar'], contextMenu: true, emptyDataState: true, notification: true, dialog: true, colHeaders: ['First Name', 'Last Name', 'Department', 'Role', 'Salary'], columns: [ { data: 'first_name', type: 'text' }, { data: 'last_name', type: 'text' }, { data: 'department', type: 'text' }, { data: 'role', type: 'text' }, { data: 'salary', type: 'numeric', numericFormat: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } }, ], rowHeaders: true, height: 400, width: '100%', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation', });关键选项说明
| 选项 | 作用 |
|---|---|
rowId: 'id' | 告诉dataProvider哪个字段标识一行,必须与序列化器字段名一致 |
fetchRows中的{ signal } | 把AbortSignal传给fetch(),用户在上一响应返回前排序/过滤时可取消进行中的请求 |
onRowsCreate中return res.json() | 返回服务端响应,dataProvider据此用服务端分配的id更新内部行映射 |
pagination: { pageSize: 10 } | 启用分页工具栏,dataProvider自动把当前页与每页条数传给fetchRows |
columnSorting: true | 启用列头点击排序,排序状态变化时传给fetchRows |
filters: true | 启用列过滤 UI,生效条件变化时传给fetchRows |
emptyDataState: true | fetchRows返回零行时(例如过滤无匹配)显示友好插画 |
notification: true | fetchRows或变更回调抛错时自动弹出错误提示;拉取失败附带Refetch重试按钮 |
beforeRowsMutation | 在变更发送前拦截操作,此处用于弹出删除确认对话框,确认后重新发起删除 |
contextMenu: true | 启用右键菜单,包含 "Insert row above / below" 与 "Remove row" |
完整工作流:一次请求如何走通
- 首次加载:
dataProvider调用fetchRows({ page: 1, pageSize: 10 }),视图返回前 10 行与总行数; - 用户点击列头:
columnSorting更新排序状态,dataProvider携带sort: { prop: 'salary', order: 'desc' }再次调用fetchRows,Django 视图翻译为?ordering=-salary交给OrderingFilter; - 用户应用列过滤:过滤 UI 更新条件列表,
dataProvider携带filters数组调用fetchRows,Django 视图用json.loads()解码filtersJSON 串,并为每列的conditions构造 DjangoQ对象; - 用户翻到第 2 页:
dataProvider调用fetchRows({ page: 2, pageSize: 10, ... }); - 用户编辑单元格:
dataProvider收集该行所有变更单元格,调用onRowsUpdate发送[{ id: 7, salary: 102000 }],update-rows端点执行局部更新; - 用户新增行:
dataProvider携带新行值调用onRowsCreate,create-rows端点插入行并返回带id的行,dataProvider更新内部映射,后续编辑使用正确的 id; - 用户删除行:
dataProvider携带所选行 id 调用onRowsRemove,remove-rows端点用一条 SQL 语句删除所有匹配行。
核心要点回顾
- DRF 默认响应形状(
{ count, results })与dataProvider期望({ rows, totalRows })不同:在自定义分页类中重写get_paginated_response在服务端完成形状映射; - 设置
page_size_query_param = 'pageSize',让 DRF 直接读取 Handsontable 的参数名; - 在
get_queryset中读取sort[prop]与sort[order]并直接调用queryset.order_by(),用ALLOWED_ORDERING_FIELDS白名单防止 ORM 注入; - 把
filters参数作为 JSON 字符串接收并用json.loads()解码,每条是{ prop, operation, conditions: [{ name, args }] }——列内条件用 AND 或 OR 组合,列间始终用 AND; - 用 DRF
@action端点实现批量 CRUD,而非单资源 REST 路由; - 从
csrftokencookie 读取 Django CSRF token,并在所有变更请求中携带X-CSRFToken头; - 把
CorsMiddleware放在CommonMiddleware之前,确保预检请求获得 CORS 头。
后续深入阅读
- Server-side data 文档(
dataProvider完整 API 参考) - Rows pagination 指南(行分页)
- Column filter 指南(列过滤)
- Rows sorting 指南(行排序)
- 前端
- UI组件
【免费下载链接】handsontable
JavaScript Data Grid / Data Table with a Spreadsheet Look & Feel. Works with React, Angular, and Vue. Supported by the Handsontable team ⚡
相关推荐
在 Handsontable 中接入 NestJS 服务端数据:dataProvider 插件 + TypeORM 实现分页、排序、过滤与 CRUD
在 Handsontable 中接入 NestJS 服务端数据:dataProvider 插件 + TypeORM 实现分页、排序、过滤与 CRUD 本篇指南演
前端UI组件JMRoundedCorner进阶技巧:自定义圆角边框、背景色与内容模式配置
JMRoundedCorner进阶技巧:自定义圆角边框、背景色与内容模式配置 JMRoundedCorner是一款专为iOS开发者打造的高效圆角处理工具,能够帮
前端UI组件苹果触控板接上Windows变"笨"了?mac-precision-touchpad终极指南:一个驱动解锁原生Windows Precision Touchpad手势
苹果触控板接上Windows变"笨"了?mac precision touchpad终极指南:一个驱动解锁原生Windows Precision Touchpa
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考