限制轨迹数据查询
This commit is contained in:
@@ -55,10 +55,23 @@ async function requestJSON<T>(path: string, init?: RequestInit): 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) })
|
||||
if (nodeId) {
|
||||
params.set('node_id', nodeId)
|
||||
const options = typeof nodeIdOrOptions === 'string' ? { nodeId: nodeIdOrOptions } : nodeIdOrOptions
|
||||
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()}`
|
||||
}
|
||||
@@ -150,8 +163,8 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
|
||||
}
|
||||
|
||||
export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
|
||||
export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
|
||||
}
|
||||
|
||||
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
|
||||
|
||||
@@ -21,8 +21,18 @@ const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||
const loading = ref(true)
|
||||
const chatLoadingOlder = ref(false)
|
||||
const chatHasMore = ref(true)
|
||||
const telemetryLoading = ref(false)
|
||||
const trajectoryLoading = ref(false)
|
||||
const trajectoryError = ref('')
|
||||
const trajectoryTruncated = ref(false)
|
||||
const error = ref('')
|
||||
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 scrollOverflowAllowance = 1
|
||||
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
|
||||
@@ -98,6 +108,27 @@ function formatTime(value: string): string {
|
||||
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]> {
|
||||
if (!value) {
|
||||
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() {
|
||||
const response = await getTextMessages(chatPageSize, 0, props.nodeId)
|
||||
messages.value = toChronological(response.items)
|
||||
@@ -403,18 +501,20 @@ function selectMapSource(sourceId: number) {
|
||||
async function loadDetails() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
trajectoryError.value = ''
|
||||
telemetryPage.value = 1
|
||||
try {
|
||||
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([
|
||||
const [nodeData, reportData] = await Promise.all([
|
||||
optional(getNodeInfoById(props.nodeId)),
|
||||
optional(getMapReportById(props.nodeId)),
|
||||
getPositions(500, 0, props.nodeId),
|
||||
getTelemetry(200, 0, props.nodeId),
|
||||
])
|
||||
nodeInfo.value = nodeData
|
||||
mapReport.value = reportData
|
||||
positions.value = positionData.items
|
||||
telemetry.value = telemetryData.items
|
||||
await loadInitialMessages()
|
||||
await Promise.all([
|
||||
loadTrajectoryRange(),
|
||||
loadTelemetryPage(),
|
||||
loadInitialMessages(),
|
||||
])
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
@@ -526,6 +626,23 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<span class="badge">{{ positions.length }}</span>
|
||||
</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
|
||||
:positions="positions"
|
||||
:map-source="mapSource"
|
||||
@@ -541,8 +658,9 @@ onBeforeUnmount(() => {
|
||||
<p class="eyebrow">Telemetry</p>
|
||||
<h2>遥测数据:{{ nodeTitle }}</h2>
|
||||
</div>
|
||||
<span class="badge">{{ telemetry.length }}</span>
|
||||
<span class="badge">本页 {{ telemetry.length }}</span>
|
||||
</div>
|
||||
<div v-if="telemetryLoading" class="admin-loading">正在加载遥测数据...</div>
|
||||
<div class="node-table-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
@@ -569,6 +687,12 @@ onBeforeUnmount(() => {
|
||||
</table>
|
||||
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</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>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
|
||||
@@ -938,6 +938,61 @@ h3 {
|
||||
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 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user