feat(platform): paginate history queries

This commit is contained in:
lingniu
2026-07-03 22:19:14 +08:00
parent ea96b6909a
commit 72aa20b347
4 changed files with 91 additions and 62 deletions

View File

@@ -183,6 +183,12 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer
tdQuery["dateTo"] = value tdQuery["dateTo"] = value
} }
built := buildHistoryLocationSQL(s.tdDatabase, tdQuery) built := buildHistoryLocationSQL(s.tdDatabase, tdQuery)
total := 0
if built.CountText != "" {
if err := s.tdengine.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&total); err != nil {
return Page[HistoryLocationRow]{}, err
}
}
rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...) rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...)
if err != nil { if err != nil {
return Page[HistoryLocationRow]{}, err return Page[HistoryLocationRow]{}, err
@@ -207,7 +213,10 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return Page[HistoryLocationRow]{}, err return Page[HistoryLocationRow]{}, err
} }
return Page[HistoryLocationRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil if built.CountText == "" {
total = len(items)
}
return Page[HistoryLocationRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
} }
func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
@@ -215,6 +224,12 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil
} }
built := buildRawFrameSQL(s.tdDatabase, query) built := buildRawFrameSQL(s.tdDatabase, query)
total := 0
if built.CountText != "" {
if err := s.tdengine.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&total); err != nil {
return Page[RawFrameRow]{}, err
}
}
rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...) rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...)
if err != nil { if err != nil {
return Page[RawFrameRow]{}, err return Page[RawFrameRow]{}, err
@@ -244,7 +259,10 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return Page[RawFrameRow]{}, err return Page[RawFrameRow]{}, err
} }
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil if built.CountText == "" {
total = len(items)
}
return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
} }
func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) { func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {

View File

@@ -80,7 +80,28 @@ func TestBuildRawFrameSQL(t *testing.T) {
if !strings.Contains(built.Text, "lingniu_vehicle_ts.raw_gb32960_") || !strings.Contains(built.Text, "parsed_fields") { if !strings.Contains(built.Text, "lingniu_vehicle_ts.raw_gb32960_") || !strings.Contains(built.Text, "parsed_fields") {
t.Fatalf("SQL = %s", built.Text) t.Fatalf("SQL = %s", built.Text)
} }
if len(built.Args) != 0 || !strings.Contains(built.Text, "protocol = 'GB32960'") || !strings.Contains(built.Text, "vin = 'VIN001'") || !strings.Contains(built.Text, "LIMIT 1 OFFSET 0") { if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
t.Fatalf("count SQL = %s", built.CountText)
}
if len(built.Args) != 0 || !strings.Contains(built.Text, "protocol = 'GB32960'") || !strings.Contains(built.Text, "vin = 'VIN001'") || !strings.Contains(built.Text, "ORDER BY ts DESC, frame_id ASC LIMIT 1 OFFSET 0") {
t.Fatalf("args = %#v", built.Args) t.Fatalf("args = %#v", built.Args)
} }
} }
func TestBuildHistoryLocationSQL(t *testing.T) {
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
"protocol": "JT808",
"vin": "VIN001",
"limit": "2",
"offset": "4",
})
if !strings.Contains(built.Text, "lingniu_vehicle_ts.loc_jt808_") || !strings.Contains(built.Text, "vin = 'VIN001'") {
t.Fatalf("SQL = %s", built.Text)
}
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
t.Fatalf("count SQL = %s", built.CountText)
}
if !strings.Contains(built.Text, "ORDER BY ts DESC, vin ASC, protocol ASC LIMIT 2 OFFSET 4") {
t.Fatalf("SQL should keep stable pagination order: %s", built.Text)
}
}

View File

@@ -29,8 +29,12 @@ func buildRawFrameSQL(database string, query RawFrameQuery) SQLQuery {
if len(where) > 0 { if len(where) > 0 {
text += ` WHERE ` + strings.Join(where, " AND ") text += ` WHERE ` + strings.Join(where, " AND ")
} }
text += ` ORDER BY ts DESC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset) countText := `SELECT COUNT(*) FROM ` + table
return SQLQuery{Text: text, Args: args} if len(where) > 0 {
countText += ` WHERE ` + strings.Join(where, " AND ")
}
text += ` ORDER BY ts DESC, frame_id ASC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
return SQLQuery{Text: text, Args: args, CountText: countText}
} }
func rawFrameWhere(query RawFrameQuery) []string { func rawFrameWhere(query RawFrameQuery) []string {
@@ -71,8 +75,12 @@ func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery
if len(where) > 0 { if len(where) > 0 {
text += ` WHERE ` + strings.Join(where, " AND ") text += ` WHERE ` + strings.Join(where, " AND ")
} }
text += ` ORDER BY ts DESC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset) countText := `SELECT COUNT(*) FROM ` + table
return SQLQuery{Text: text, Args: args} if len(where) > 0 {
countText += ` WHERE ` + strings.Join(where, " AND ")
}
text += ` ORDER BY ts DESC, vin ASC, protocol ASC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
return SQLQuery{Text: text, Args: args, CountText: countText}
} }
func qualifyTDengine(database, table string) string { func qualifyTDengine(database, table string) string {

View File

@@ -1,5 +1,5 @@
import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Toast, Typography } from '@douyinfe/semi-ui'; import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Toast, Typography } from '@douyinfe/semi-ui';
import { IconChevronLeft, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types'; import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types';
@@ -29,6 +29,8 @@ export function History() {
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null); const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
const [loadingLocations, setLoadingLocations] = useState(false); const [loadingLocations, setLoadingLocations] = useState(false);
const [loadingRaw, setLoadingRaw] = useState(false); const [loadingRaw, setLoadingRaw] = useState(false);
const [locationPagination, setLocationPagination] = useState({ currentPage: 1, pageSize: 10 });
const [rawPagination, setRawPagination] = useState({ currentPage: 1, pageSize: 10 });
const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => { const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
@@ -41,18 +43,24 @@ export function History() {
return params; return params;
}; };
const loadLocations = (nextFilters = filters, offset = locations.offset) => { const loadLocations = (nextFilters = filters, page = locationPagination.currentPage, pageSize = locationPagination.pageSize) => {
setLoadingLocations(true); setLoadingLocations(true);
api.historyLocations(buildParams(nextFilters, locations.limit || 10, offset, false)) api.historyLocations(buildParams(nextFilters, pageSize, (page - 1) * pageSize, false))
.then(setLocations) .then((nextPage) => {
setLocations(nextPage);
setLocationPagination({ currentPage: page, pageSize });
})
.catch((error: Error) => Toast.error(error.message)) .catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingLocations(false)); .finally(() => setLoadingLocations(false));
}; };
const loadRawFrames = (nextFilters = filters, offset = rawFrames.offset) => { const loadRawFrames = (nextFilters = filters, page = rawPagination.currentPage, pageSize = rawPagination.pageSize) => {
setLoadingRaw(true); setLoadingRaw(true);
api.rawFrames(buildParams(nextFilters, rawFrames.limit || 10, offset, true)) api.rawFrames(buildParams(nextFilters, pageSize, (page - 1) * pageSize, true))
.then(setRawFrames) .then((nextPage) => {
setRawFrames(nextPage);
setRawPagination({ currentPage: page, pageSize });
})
.catch((error: Error) => Toast.error(error.message)) .catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingRaw(false)); .finally(() => setLoadingRaw(false));
}; };
@@ -67,19 +75,19 @@ export function History() {
includeFields: Boolean(values.includeFields) includeFields: Boolean(values.includeFields)
}; };
setFilters(nextFilters); setFilters(nextFilters);
loadLocations(nextFilters, 0); loadLocations(nextFilters, 1, locationPagination.pageSize);
loadRawFrames(nextFilters, 0); loadRawFrames(nextFilters, 1, rawPagination.pageSize);
}; };
const reset = () => { const reset = () => {
setFilters(defaultFilters); setFilters(defaultFilters);
loadLocations(defaultFilters, 0); loadLocations(defaultFilters, 1, locationPagination.pageSize);
loadRawFrames(defaultFilters, 0); loadRawFrames(defaultFilters, 1, rawPagination.pageSize);
}; };
useEffect(() => { useEffect(() => {
loadLocations(defaultFilters, 0); loadLocations(defaultFilters, 1, locationPagination.pageSize);
loadRawFrames(defaultFilters, 0); loadRawFrames(defaultFilters, 1, rawPagination.pageSize);
}, []); }, []);
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]); const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
@@ -120,7 +128,14 @@ export function History() {
rowKey="deviceTime" rowKey="deviceTime"
dataSource={locations.items} dataSource={locations.items}
loading={loadingLocations} loading={loadingLocations}
pagination={false} pagination={{
currentPage: locationPagination.currentPage,
pageSize: locationPagination.pageSize,
total: locations.total,
showSizeChanger: true,
onPageChange: (page) => loadLocations(filters, page, locationPagination.pageSize),
onPageSizeChange: (pageSize) => loadLocations(filters, 1, pageSize)
}}
columns={[ columns={[
{ title: 'VIN', dataIndex: 'vin', width: 190 }, { title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '协议', dataIndex: 'protocol', width: 120 }, { title: '协议', dataIndex: 'protocol', width: 120 },
@@ -132,19 +147,20 @@ export function History() {
{ title: '入库时间', dataIndex: 'serverTime', width: 190 } { title: '入库时间', dataIndex: 'serverTime', width: 190 }
]} ]}
/> />
<HistoryPager
page={locations}
loading={loadingLocations}
onPrev={() => loadLocations(filters, Math.max(0, locations.offset - locations.limit))}
onNext={() => loadLocations(filters, locations.offset + locations.limit)}
/>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab="RAW 帧" itemKey="raw"> <Tabs.TabPane tab="RAW 帧" itemKey="raw">
<Table <Table
rowKey="id" rowKey="id"
dataSource={rawFrames.items} dataSource={rawFrames.items}
loading={loadingRaw} loading={loadingRaw}
pagination={false} pagination={{
currentPage: rawPagination.currentPage,
pageSize: rawPagination.pageSize,
total: rawFrames.total,
showSizeChanger: true,
onPageChange: (page) => loadRawFrames(filters, page, rawPagination.pageSize),
onPageSizeChange: (pageSize) => loadRawFrames(filters, 1, pageSize)
}}
columns={[ columns={[
{ title: 'ID', dataIndex: 'id', width: 260 }, { title: 'ID', dataIndex: 'id', width: 260 },
{ title: 'VIN', dataIndex: 'vin', width: 190 }, { title: 'VIN', dataIndex: 'vin', width: 190 },
@@ -156,12 +172,6 @@ export function History() {
{ title: '操作', width: 100, render: (_: unknown, row: RawFrameRow) => <Button onClick={() => setSelectedRaw(row)}></Button> } { title: '操作', width: 100, render: (_: unknown, row: RawFrameRow) => <Button onClick={() => setSelectedRaw(row)}></Button> }
]} ]}
/> />
<HistoryPager
page={rawFrames}
loading={loadingRaw}
onPrev={() => loadRawFrames(filters, Math.max(0, rawFrames.offset - rawFrames.limit))}
onNext={() => loadRawFrames(filters, rawFrames.offset + rawFrames.limit)}
/>
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
</Card> </Card>
@@ -179,31 +189,3 @@ export function History() {
</div> </div>
); );
} }
function HistoryPager<T>({
page,
loading,
onPrev,
onNext
}: {
page: Page<T>;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
const start = page.offset + 1;
const end = page.offset + page.items.length;
const hasPrevious = page.offset > 0;
const hasNext = page.items.length >= page.limit;
return (
<div className="vp-table-footer">
<Typography.Text type="tertiary">
{page.items.length === 0 ? 0 : start}-{end} {page.limit}
</Typography.Text>
<Space>
<Button icon={<IconChevronLeft />} disabled={!hasPrevious || loading} onClick={onPrev}></Button>
<Button icon={<IconChevronRight />} disabled={!hasNext || loading} onClick={onNext}></Button>
</Space>
</div>
);
}