限制轨迹数据查询

This commit is contained in:
2026-06-07 00:22:18 +08:00
parent e1f1ac902a
commit c0ceba93b7
3 changed files with 204 additions and 12 deletions
+18 -5
View File
@@ -55,10 +55,23 @@ async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T> return response.json() as Promise<T>
} }
function listPath(path: string, limit: number, offset: number, nodeId = ''): string { type ListQueryOptions = {
nodeId?: string
since?: string
until?: string
}
function listPath(path: string, limit: number, offset: number, nodeIdOrOptions: string | ListQueryOptions = ''): string {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }) const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
if (nodeId) { const options = typeof nodeIdOrOptions === 'string' ? { nodeId: nodeIdOrOptions } : nodeIdOrOptions
params.set('node_id', nodeId) if (options.nodeId) {
params.set('node_id', options.nodeId)
}
if (options.since) {
params.set('since', options.since)
}
if (options.until) {
params.set('until', options.until)
} }
return `${path}?${params.toString()}` return `${path}?${params.toString()}`
} }
@@ -150,8 +163,8 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`) return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
} }
export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> { export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId)) return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
} }
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> { export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
@@ -21,8 +21,18 @@ const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
const loading = ref(true) const loading = ref(true)
const chatLoadingOlder = ref(false) const chatLoadingOlder = ref(false)
const chatHasMore = ref(true) const chatHasMore = ref(true)
const telemetryLoading = ref(false)
const trajectoryLoading = ref(false)
const trajectoryError = ref('')
const trajectoryTruncated = ref(false)
const error = ref('') const error = ref('')
const chatPageSize = 20 const chatPageSize = 20
const telemetryPageSize = 25
const trajectoryPageSize = 500
const maxTrajectoryPoints = 5000
const telemetryPage = ref(1)
const trajectoryStartDate = ref(toDateInputValue())
const trajectoryEndDate = ref(toDateInputValue())
const chatHistoryRef = ref<HTMLElement | null>(null) const chatHistoryRef = ref<HTMLElement | null>(null)
const scrollOverflowAllowance = 1 const scrollOverflowAllowance = 1
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] } type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
@@ -98,6 +108,27 @@ function formatTime(value: string): string {
return new Date(value).toLocaleString() return new Date(value).toLocaleString()
} }
function toDateInputValue(date = new Date()): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function localDateRange(startDate: string, endDate: string): { since: string; until: string } | null {
if (!startDate || !endDate) {
trajectoryError.value = '请选择开始日期和结束日期'
return null
}
const safeStartDate = startDate <= endDate ? startDate : endDate
const safeEndDate = startDate <= endDate ? endDate : startDate
trajectoryStartDate.value = safeStartDate
trajectoryEndDate.value = safeEndDate
const since = new Date(`${safeStartDate}T00:00:00.000`)
const until = new Date(`${safeEndDate}T23:59:59.999`)
return { since: since.toISOString(), until: until.toISOString() }
}
function metricEntries(value: string | null): Array<[string, unknown]> { function metricEntries(value: string | null): Array<[string, unknown]> {
if (!value) { if (!value) {
return [] return []
@@ -179,6 +210,73 @@ async function optional<T>(request: Promise<T>): Promise<T | null> {
} }
} }
function canTelemetryPrev(): boolean {
return telemetryPage.value > 1
}
function canTelemetryNext(): boolean {
return telemetry.value.length === telemetryPageSize
}
async function loadTelemetryPage() {
telemetryLoading.value = true
try {
const response = await getTelemetry(telemetryPageSize, (telemetryPage.value - 1) * telemetryPageSize, props.nodeId)
telemetry.value = response.items
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
telemetryLoading.value = false
}
}
function changeTelemetryPage(nextPage: number) {
telemetryPage.value = Math.max(1, nextPage)
loadTelemetryPage()
}
async function loadTrajectoryRange() {
const range = localDateRange(trajectoryStartDate.value, trajectoryEndDate.value)
if (!range) {
return
}
trajectoryLoading.value = true
trajectoryError.value = ''
trajectoryTruncated.value = false
positions.value = []
try {
const items: PositionRecord[] = []
for (let offset = 0; offset < maxTrajectoryPoints; offset += trajectoryPageSize) {
const response = await getPositions(trajectoryPageSize, offset, {
nodeId: props.nodeId,
since: range.since,
until: range.until,
})
items.push(...response.items)
if (response.items.length < trajectoryPageSize) {
break
}
if (items.length >= maxTrajectoryPoints) {
trajectoryTruncated.value = true
break
}
}
positions.value = items.slice(0, maxTrajectoryPoints)
} catch (err) {
trajectoryError.value = err instanceof Error ? err.message : String(err)
} finally {
trajectoryLoading.value = false
}
}
function applyTodayTrajectory() {
const today = toDateInputValue()
trajectoryStartDate.value = today
trajectoryEndDate.value = today
loadTrajectoryRange()
}
async function loadInitialMessages() { async function loadInitialMessages() {
const response = await getTextMessages(chatPageSize, 0, props.nodeId) const response = await getTextMessages(chatPageSize, 0, props.nodeId)
messages.value = toChronological(response.items) messages.value = toChronological(response.items)
@@ -403,18 +501,20 @@ function selectMapSource(sourceId: number) {
async function loadDetails() { async function loadDetails() {
loading.value = true loading.value = true
error.value = '' error.value = ''
trajectoryError.value = ''
telemetryPage.value = 1
try { try {
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([ const [nodeData, reportData] = await Promise.all([
optional(getNodeInfoById(props.nodeId)), optional(getNodeInfoById(props.nodeId)),
optional(getMapReportById(props.nodeId)), optional(getMapReportById(props.nodeId)),
getPositions(500, 0, props.nodeId),
getTelemetry(200, 0, props.nodeId),
]) ])
nodeInfo.value = nodeData nodeInfo.value = nodeData
mapReport.value = reportData mapReport.value = reportData
positions.value = positionData.items await Promise.all([
telemetry.value = telemetryData.items loadTrajectoryRange(),
await loadInitialMessages() loadTelemetryPage(),
loadInitialMessages(),
])
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : String(err) error.value = err instanceof Error ? err.message : String(err)
} finally { } finally {
@@ -526,6 +626,23 @@ onBeforeUnmount(() => {
</div> </div>
<span class="badge">{{ positions.length }}</span> <span class="badge">{{ positions.length }}</span>
</div> </div>
<div class="trajectory-toolbar">
<label class="trajectory-date-field">
<span>开始日期</span>
<input v-model="trajectoryStartDate" type="date" :disabled="trajectoryLoading" />
</label>
<label class="trajectory-date-field">
<span>结束日期</span>
<input v-model="trajectoryEndDate" type="date" :disabled="trajectoryLoading" />
</label>
<button type="button" :disabled="trajectoryLoading" @click="loadTrajectoryRange">
{{ trajectoryLoading ? '查询中...' : '查询轨迹' }}
</button>
<button type="button" :disabled="trajectoryLoading" @click="applyTodayTrajectory">今天</button>
</div>
<p v-if="trajectoryError" class="error trajectory-status">{{ trajectoryError }}</p>
<p v-else-if="trajectoryTruncated" class="trajectory-status">轨迹点较多仅显示前 {{ maxTrajectoryPoints }} 请缩小日期范围</p>
<p v-else-if="trajectoryLoading" class="trajectory-status">正在加载轨迹...</p>
<NodeTrajectoryMap <NodeTrajectoryMap
:positions="positions" :positions="positions"
:map-source="mapSource" :map-source="mapSource"
@@ -541,8 +658,9 @@ onBeforeUnmount(() => {
<p class="eyebrow">Telemetry</p> <p class="eyebrow">Telemetry</p>
<h2>遥测数据{{ nodeTitle }}</h2> <h2>遥测数据{{ nodeTitle }}</h2>
</div> </div>
<span class="badge">{{ telemetry.length }}</span> <span class="badge">本页 {{ telemetry.length }}</span>
</div> </div>
<div v-if="telemetryLoading" class="admin-loading">正在加载遥测数据...</div>
<div class="node-table-wrap"> <div class="node-table-wrap">
<table class="node-table"> <table class="node-table">
<thead> <thead>
@@ -569,6 +687,12 @@ onBeforeUnmount(() => {
</table> </table>
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div> <div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
</div> </div>
<div class="pagination">
<button :disabled="telemetryLoading || !canTelemetryPrev()" @click="changeTelemetryPage(telemetryPage - 1)">上一页</button>
<span> {{ telemetryPage }} </span>
<span>每页 {{ telemetryPageSize }} </span>
<button :disabled="telemetryLoading || !canTelemetryNext()" @click="changeTelemetryPage(telemetryPage + 1)">下一页</button>
</div>
</div> </div>
<ConfirmDeleteModal <ConfirmDeleteModal
+55
View File
@@ -938,6 +938,61 @@ h3 {
padding: 13px 16px; padding: 13px 16px;
} }
.trajectory-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 0 0 14px;
}
.trajectory-date-field {
display: grid;
gap: 6px;
color: var(--color-muted);
font-size: 12px;
font-weight: 700;
}
.trajectory-date-field input {
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 9px 12px;
color: var(--color-heading);
background: var(--color-surface);
outline: none;
}
.trajectory-date-field input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.trajectory-toolbar button {
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 9px 12px;
color: var(--color-heading);
background: var(--color-surface);
}
.trajectory-toolbar button:not(:disabled):hover {
border-color: var(--color-primary);
color: var(--color-primary-hover);
background: var(--color-primary-soft);
transform: translateY(-1px);
}
.trajectory-toolbar button:disabled,
.trajectory-date-field input:disabled {
opacity: 0.6;
}
.trajectory-status {
margin: 0 0 12px;
color: var(--color-muted);
}
.trajectory-map-shell { .trajectory-map-shell {
position: relative; position: relative;
} }