feat: add event waveform export selection
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
# Event List Export Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rename the existing event list export action to “事件导出”, add independent waveform export selection and download behavior, and show the visible event columns in the requested order with “暂降/暂升幅值(%)” before “持续时间(s)”.
|
||||
|
||||
**Architecture:** Keep all UI orchestration in `frontend/src/views/event/eventList/index.vue`, because the change is narrow and the page already owns table columns, export handlers, and row actions. Add one event-list API method and one request type for waveform export. Extend the existing eventList contract scripts so selection semantics and export payloads are checked without introducing a new test framework.
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`, Element Plus, TypeScript, existing `ProTable`, existing download hook, Node-based contract scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `frontend/src/views/event/eventList/check-search-layout-contract.mjs`: assert the event export button text and event export parameter source.
|
||||
- Modify `frontend/src/views/event/eventList/check-visible-contract.mjs`: assert the waveform selection column and selection rules.
|
||||
- Create `frontend/src/views/event/eventList/check-export-contract.mjs`: assert the new waveform export API, payload shape, button state, and cleanup behavior.
|
||||
- Modify `frontend/src/api/event/eventList/interface/index.ts`: add `TransientWaveformExportParams`.
|
||||
- Modify `frontend/src/api/event/eventList/index.ts`: add `exportTransientWaveforms`.
|
||||
- Modify `frontend/src/views/event/eventList/index.vue`: add independent waveform selection UI and handlers, rename event export, and wire waveform export.
|
||||
|
||||
## Task 1: Contract Checks For Export Behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/event/eventList/check-search-layout-contract.mjs`
|
||||
- Modify: `frontend/src/views/event/eventList/check-visible-contract.mjs`
|
||||
- Create: `frontend/src/views/event/eventList/check-export-contract.mjs`
|
||||
|
||||
- [ ] **Step 1: Update search layout contract for event export naming**
|
||||
|
||||
In `frontend/src/views/event/eventList/check-search-layout-contract.mjs`, keep the existing current-search assertion and add a check that the button is now labeled “事件导出”:
|
||||
|
||||
```js
|
||||
[
|
||||
'event export button is named explicitly',
|
||||
/<el-button[\s\S]*@click="handleEventExport"[\s\S]*>事件导出<\/el-button>/
|
||||
],
|
||||
[
|
||||
'event export uses the same current search params as the table request',
|
||||
/exportTransientEvents,[\s\S]*buildEventQueryParams\(resolveCurrentSearchParams\(searchParam\)\)/
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update visible contract for waveform selection column**
|
||||
|
||||
In `frontend/src/views/event/eventList/check-visible-contract.mjs`, add expectations that identify the manual waveform selection column and confirm the old business columns remain:
|
||||
|
||||
```js
|
||||
[
|
||||
'waveform selection column is rendered near the left side',
|
||||
/prop:\s*'waveformSelection'[\s\S]*label:\s*'波形选择'[\s\S]*headerRender:\s*renderWaveformSelectionHeader[\s\S]*render:\s*renderWaveformSelectionCell/
|
||||
],
|
||||
[
|
||||
'waveform selection requires event id, waveform flag and waveform path',
|
||||
/const isWaveformExportable[\s\S]*Boolean\(row\.eventId\)[\s\S]*Number\(row\.fileFlag\)\s*===\s*1[\s\S]*Boolean\(row\.wavePath\)/
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create export contract script**
|
||||
|
||||
Create `frontend/src/views/event/eventList/check-export-contract.mjs` with:
|
||||
|
||||
```js
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, 'index.vue')
|
||||
const apiFile = path.resolve(currentDir, '../../../api/event/eventList/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../api/event/eventList/interface/index.ts')
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['waveform export api is imported by the page', /import \{[\s\S]*exportTransientWaveforms[\s\S]*\} from '@\/api\/event\/eventList'/],
|
||||
['waveform export button is present and disabled without selection', /<el-button[\s\S]*:disabled="!selectedWaveformRows\.length"[\s\S]*@click="handleWaveformExport"[\s\S]*>波形导出<\/el-button>/],
|
||||
['event export button does not depend on waveform selection', /<el-button type="primary" plain :icon="Download" @click="handleEventExport">事件导出<\/el-button>/],
|
||||
['waveform selected rows are tracked independently', /const selectedWaveformRows\s*=\s*ref<EventList\.TransientEventRecord\[\]>\(\[\]\)/],
|
||||
['waveform export payload uses event ids only', /eventIds:\s*exportableRows\.map\(row => row\.eventId\)/],
|
||||
['waveform export uses server filename download hook', /useDownloadWithServerFileName\(exportTransientWaveforms,\s*'事件波形导出'/],
|
||||
['waveform selection is cleaned after table reload triggers', /const clearWaveformSelection[\s\S]*selectedWaveformRows\.value\s*=\s*\[\]/],
|
||||
['waveform export api method exists', /export const exportTransientWaveforms\s*=\s*\(params:\s*EventList\.TransientWaveformExportParams\)/.test(apiSource)],
|
||||
['waveform export api path is stable', /downloadWithHeaders\('\/event\/list\/transient\/waveform\/export',\s*params\)/.test(apiSource)],
|
||||
['waveform export params type contains eventIds', /export interface TransientWaveformExportParams\s*\{[\s\S]*eventIds:\s*string\[\][\s\S]*\}/.test(interfaceSource)]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList export contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList export contract check passed')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run contracts and verify red**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-search-layout-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-visible-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Existing checks may pass or fail depending on current user edits.
|
||||
- `check-export-contract.mjs` must fail with missing waveform export API, button, selection state, and payload expectations.
|
||||
|
||||
- [ ] **Step 5: Commit contract red state**
|
||||
|
||||
Do not commit a failing red state unless the user explicitly asks for granular commits. Keep it as local verification before implementation.
|
||||
|
||||
## Task 2: API Type And Export Method
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/event/eventList/interface/index.ts`
|
||||
- Modify: `frontend/src/api/event/eventList/index.ts`
|
||||
|
||||
- [ ] **Step 1: Add waveform export request type**
|
||||
|
||||
In `frontend/src/api/event/eventList/interface/index.ts`, add this interface inside `export namespace EventList` after `TransientPageParams`:
|
||||
|
||||
```ts
|
||||
export interface TransientWaveformExportParams {
|
||||
eventIds: string[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add waveform export API method**
|
||||
|
||||
In `frontend/src/api/event/eventList/index.ts`, add:
|
||||
|
||||
```ts
|
||||
export const exportTransientWaveforms = (params: EventList.TransientWaveformExportParams) => {
|
||||
return http.downloadWithHeaders('/event/list/transient/waveform/export', params)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run export contract and verify API checks improve**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
```
|
||||
|
||||
Expected: still fails because page UI and handlers are not implemented, but API method and type failures disappear.
|
||||
|
||||
## Task 3: Waveform Selection UI And State
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/event/eventList/index.vue`
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
Change imports in `index.vue`:
|
||||
|
||||
```ts
|
||||
import { ElButton, ElCheckbox, ElRadioButton, ElRadioGroup } from 'element-plus'
|
||||
import { Download, RefreshRight, View } from '@element-plus/icons-vue'
|
||||
import {
|
||||
exportTransientEvents,
|
||||
exportTransientWaveforms,
|
||||
getTransientEventDetail,
|
||||
getTransientEventPage
|
||||
} from '@/api/event/eventList'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add waveform selection state**
|
||||
|
||||
Add near the existing page refs:
|
||||
|
||||
```ts
|
||||
const selectedWaveformRows = ref<EventList.TransientEventRecord[]>([])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add exportable row helpers**
|
||||
|
||||
Add before `columns`:
|
||||
|
||||
```ts
|
||||
const isWaveformExportable = (row: EventList.TransientEventRecord) => {
|
||||
return Boolean(row.eventId) && Number(row.fileFlag) === 1 && Boolean(row.wavePath)
|
||||
}
|
||||
|
||||
const currentWaveformRows = computed(() => proTable.value?.tableData || [])
|
||||
const currentExportableWaveformRows = computed(() => currentWaveformRows.value.filter(isWaveformExportable))
|
||||
const selectedWaveformIds = computed(() => selectedWaveformRows.value.map(row => row.eventId))
|
||||
const selectedWaveformIdSet = computed(() => new Set(selectedWaveformIds.value))
|
||||
const isAllCurrentWaveformsSelected = computed(() => {
|
||||
const rows = currentExportableWaveformRows.value
|
||||
return rows.length > 0 && rows.every(row => selectedWaveformIdSet.value.has(row.eventId))
|
||||
})
|
||||
const isCurrentWaveformSelectionIndeterminate = computed(() => {
|
||||
const rows = currentExportableWaveformRows.value
|
||||
if (!rows.length) return false
|
||||
const selectedCount = rows.filter(row => selectedWaveformIdSet.value.has(row.eventId)).length
|
||||
return selectedCount > 0 && selectedCount < rows.length
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add selection mutators**
|
||||
|
||||
Add after the helper computed values:
|
||||
|
||||
```ts
|
||||
const clearWaveformSelection = () => {
|
||||
selectedWaveformRows.value = []
|
||||
}
|
||||
|
||||
const toggleWaveformRowSelection = (row: EventList.TransientEventRecord, checked: boolean) => {
|
||||
if (!isWaveformExportable(row)) return
|
||||
|
||||
if (checked) {
|
||||
if (!selectedWaveformIdSet.value.has(row.eventId)) {
|
||||
selectedWaveformRows.value = [...selectedWaveformRows.value, row]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(item => item.eventId !== row.eventId)
|
||||
}
|
||||
|
||||
const toggleCurrentWaveformSelection = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
const currentIds = new Set(currentExportableWaveformRows.value.map(row => row.eventId))
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(row => !currentIds.has(row.eventId))
|
||||
return
|
||||
}
|
||||
|
||||
const nextRows = [...selectedWaveformRows.value]
|
||||
const nextIds = new Set(nextRows.map(row => row.eventId))
|
||||
currentExportableWaveformRows.value.forEach(row => {
|
||||
if (!nextIds.has(row.eventId)) {
|
||||
nextRows.push(row)
|
||||
nextIds.add(row.eventId)
|
||||
}
|
||||
})
|
||||
selectedWaveformRows.value = nextRows
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add render functions for the manual selection column**
|
||||
|
||||
Add:
|
||||
|
||||
```ts
|
||||
const renderWaveformSelectionHeader = () =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: isAllCurrentWaveformsSelected.value,
|
||||
indeterminate: isCurrentWaveformSelectionIndeterminate.value,
|
||||
disabled: currentExportableWaveformRows.value.length === 0,
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleCurrentWaveformSelection(Boolean(value))
|
||||
})
|
||||
|
||||
const renderWaveformSelectionCell = ({ row }: { row: EventList.TransientEventRecord }) =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: selectedWaveformIdSet.value.has(row.eventId),
|
||||
disabled: !isWaveformExportable(row),
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleWaveformRowSelection(row, Boolean(value))
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add waveform selection column**
|
||||
|
||||
In the `columns` array, add this before the index column:
|
||||
|
||||
```ts
|
||||
{
|
||||
prop: 'waveformSelection',
|
||||
label: '波形选择',
|
||||
fixed: 'left',
|
||||
width: 90,
|
||||
isSetting: false,
|
||||
headerRender: renderWaveformSelectionHeader,
|
||||
render: renderWaveformSelectionCell
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Place amplitude before duration in visible columns**
|
||||
|
||||
In the `columns` array, keep the visible event column order as `发生时刻`, `监测点名称`, `暂降/暂升幅值(%)`, `持续时间(s)`, `事件类型`, `相别` by placing the `featureAmplitude` column before the duration column.
|
||||
|
||||
- [ ] **Step 8: Clear waveform selection on reset and table request**
|
||||
|
||||
Update `handleSearchReset`:
|
||||
|
||||
```ts
|
||||
const handleSearchReset = () => {
|
||||
eventTimeUnit.value = 'month'
|
||||
eventTimeBaseDate.value = new Date()
|
||||
clearWaveformSelection()
|
||||
syncEventTimeRange()
|
||||
}
|
||||
```
|
||||
|
||||
Update `getTableList`:
|
||||
|
||||
```ts
|
||||
const getTableList = (params: EventSearchParams) => {
|
||||
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
||||
clearWaveformSelection()
|
||||
return getTransientEventPage(buildEventQueryParams(resolveCurrentSearchParams(params)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run visible and export contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-visible-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
```
|
||||
|
||||
Expected: visible contract passes for the new column; export contract still fails until buttons and export handlers are renamed/wired.
|
||||
|
||||
## Task 4: Export Buttons And Handlers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/event/eventList/index.vue`
|
||||
|
||||
- [ ] **Step 1: Replace table header buttons**
|
||||
|
||||
Replace the existing table header slot with:
|
||||
|
||||
```vue
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" plain :icon="Download" @click="handleEventExport">事件导出</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Download"
|
||||
:disabled="!selectedWaveformRows.length"
|
||||
@click="handleWaveformExport"
|
||||
>
|
||||
波形导出
|
||||
</el-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rename event export handler**
|
||||
|
||||
Replace `handleExport` with:
|
||||
|
||||
```ts
|
||||
const handleEventExport = () => {
|
||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
||||
useDownloadWithServerFileName(
|
||||
exportTransientEvents,
|
||||
'暂态事件列表',
|
||||
buildEventQueryParams(resolveCurrentSearchParams(searchParam)),
|
||||
false
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add waveform export handler**
|
||||
|
||||
Add after `handleEventExport`:
|
||||
|
||||
```ts
|
||||
const handleWaveformExport = () => {
|
||||
const exportableRows = selectedWaveformRows.value.filter(isWaveformExportable)
|
||||
|
||||
if (!exportableRows.length) {
|
||||
ElMessage.warning('请先选择存在波形的事件')
|
||||
return
|
||||
}
|
||||
|
||||
useDownloadWithServerFileName(
|
||||
exportTransientWaveforms,
|
||||
'事件波形导出',
|
||||
{
|
||||
eventIds: exportableRows.map(row => row.eventId)
|
||||
},
|
||||
false,
|
||||
'.zip'
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all eventList contract scripts**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node frontend/src/views/event/eventList/check-search-layout-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-visible-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-export-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-query-params-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-route-contract.mjs
|
||||
node frontend/src/views/event/eventList/check-time-range-contract.mjs
|
||||
```
|
||||
|
||||
Expected: all scripts print `passed`.
|
||||
|
||||
## Task 5: Type And Lint Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only; no planned file edits.
|
||||
|
||||
- [ ] **Step 1: Run TypeScript check**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: command completes without TypeScript errors.
|
||||
|
||||
- [ ] **Step 2: Run lint if lightweight enough for the workspace**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: command completes without ESLint errors. If lint changes unrelated files because it uses `--fix`, inspect `git status --short` and keep only changes directly related to this task.
|
||||
|
||||
- [ ] **Step 3: Inspect final diff**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff -- frontend/src/views/event/eventList frontend/src/api/event/eventList
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Event list page contains the new independent waveform selection column.
|
||||
- API files contain only the waveform export type and method.
|
||||
- Existing unrelated worktree changes are not reverted or folded into this task.
|
||||
|
||||
## Task 6: Commit Implementation
|
||||
|
||||
**Files:**
|
||||
- Commit only files modified for this task.
|
||||
|
||||
- [ ] **Step 1: Stage task files only**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add -- frontend/src/views/event/eventList/index.vue frontend/src/views/event/eventList/check-search-layout-contract.mjs frontend/src/views/event/eventList/check-visible-contract.mjs frontend/src/views/event/eventList/check-export-contract.mjs frontend/src/api/event/eventList/index.ts frontend/src/api/event/eventList/interface/index.ts docs/superpowers/plans/2026-05-15-event-list-export-implementation-plan.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git commit -m "feat: add event waveform export selection"
|
||||
```
|
||||
|
||||
Expected: commit succeeds and includes only the implementation, contracts, API method, and this plan.
|
||||
@@ -12,3 +12,7 @@ export const getTransientEventDetail = (eventId: string) => {
|
||||
export const exportTransientEvents = (params: EventList.TransientPageParams) => {
|
||||
return http.downloadWithHeaders('/event/list/transient/export', params)
|
||||
}
|
||||
|
||||
export const exportTransientWaveforms = (params: EventList.TransientWaveformExportParams) => {
|
||||
return http.downloadWithHeaders('/event/list/transient/waveform/export', params)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export namespace EventList {
|
||||
lineName?: string
|
||||
}
|
||||
|
||||
export interface TransientWaveformExportParams {
|
||||
eventIds: string[]
|
||||
}
|
||||
|
||||
export interface TransientEventRecord {
|
||||
eventId: string
|
||||
measurementPointId?: string
|
||||
|
||||
46
frontend/src/views/event/eventList/check-export-contract.mjs
Normal file
46
frontend/src/views/event/eventList/check-export-contract.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageFile = path.join(currentDir, 'index.vue')
|
||||
const apiFile = path.resolve(currentDir, '../../../api/event/eventList/index.ts')
|
||||
const interfaceFile = path.resolve(currentDir, '../../../api/event/eventList/interface/index.ts')
|
||||
const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||
const eventExportButtonBlocks = (source.match(/<el-button[\s\S]*?<\/el-button>/g) || []).filter(
|
||||
block => /@click="handleEventExport"/.test(block) && />事件导出<\/el-button>/.test(block)
|
||||
)
|
||||
|
||||
const expectations = [
|
||||
['waveform export api is imported by the page', /import \{[\s\S]*exportTransientWaveforms[\s\S]*\} from '@\/api\/event\/eventList'/],
|
||||
['waveform export button is present and disabled without selection', /<el-button[\s\S]*:disabled="!selectedWaveformRows\.length"[\s\S]*@click="handleWaveformExport"[\s\S]*>波形导出<\/el-button>/],
|
||||
['event export button is named explicitly', /<el-button[\s\S]*@click="handleEventExport"[\s\S]*>事件导出<\/el-button>/],
|
||||
['event export button does not depend on waveform selection', eventExportButtonBlocks.some(block => !/selectedWaveformRows/.test(block))],
|
||||
['waveform selected rows are tracked independently', /const selectedWaveformRows\s*=\s*ref<EventList\.TransientEventRecord\[\]>\(\[\]\)/],
|
||||
['waveform export payload uses event ids only', /eventIds:\s*exportableRows\.map\(row => row\.eventId\)/],
|
||||
['waveform export uses server filename download hook', /useDownloadWithServerFileName\(exportTransientWaveforms,\s*'事件波形导出'/],
|
||||
['waveform selection clear helper exists', /const clearWaveformSelection\s*=\s*\(\)\s*=>\s*\{[\s\S]*selectedWaveformRows\.value\s*=\s*\[\][\s\S]*\}/],
|
||||
['table request clears waveform selection before reload', /const getTableList\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]*clearWaveformSelection\(\)[\s\S]*return getTransientEventPage/],
|
||||
['search reset clears waveform selection', /const handleSearchReset\s*=\s*\(\)\s*=>\s*\{[\s\S]*clearWaveformSelection\(\)[\s\S]*syncEventTimeRange\(\)/],
|
||||
['waveform export api method exists', /export const exportTransientWaveforms\s*=\s*\(params:\s*EventList\.TransientWaveformExportParams\)/.test(apiSource)],
|
||||
['waveform export api path is stable', /downloadWithHeaders\('\/event\/list\/transient\/waveform\/export',\s*params\)/.test(apiSource)],
|
||||
['waveform export params type contains eventIds', /export interface TransientWaveformExportParams\s*\{[\s\S]*eventIds:\s*string\[\][\s\S]*\}/.test(interfaceSource)]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => {
|
||||
if (typeof pattern === 'boolean') return !pattern
|
||||
return !pattern.test(source)
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('eventList export contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('eventList export contract check passed')
|
||||
@@ -14,7 +14,19 @@ const expectations = [
|
||||
['event time search field only takes one grid column', /key:\s*'startTimeRange',\s*span:\s*1,/],
|
||||
['event time search imports shared TimePeriodSearch component', /import TimePeriodSearch from '@\/views\/components\/TimePeriodSearch\/index\.vue'/],
|
||||
['event time search imports shared period helpers', /from '@\/views\/components\/TimePeriodSearch\/timePeriod'/],
|
||||
['event time search renders shared TimePeriodSearch', /h\(TimePeriodSearch,[\s\S]*unit:\s*eventTimeUnit\.value,[\s\S]*modelValue:\s*eventTimeBaseDate\.value/]
|
||||
['event time search renders shared TimePeriodSearch', /h\(TimePeriodSearch,[\s\S]*unit:\s*eventTimeUnit\.value,[\s\S]*modelValue:\s*eventTimeBaseDate\.value/],
|
||||
[
|
||||
'event list requests preserve explicit time range and fallback to current visible range',
|
||||
/const resolveCurrentSearchParams[\s\S]*startTimeRange:\s*params\.startTimeRange\s*\?\?\s*buildTimePeriodRange\(eventTimeUnit\.value,\s*eventTimeBaseDate\.value\)[\s\S]*getTransientEventPage\(buildEventQueryParams\(resolveCurrentSearchParams\(params\)\)\)/
|
||||
],
|
||||
[
|
||||
'event export button is named explicitly',
|
||||
/<el-button[\s\S]*@click="handleEventExport"[\s\S]*>事件导出<\/el-button>/
|
||||
],
|
||||
[
|
||||
'event export uses the same current search params as the table request',
|
||||
/exportTransientEvents,[\s\S]*buildEventQueryParams\(resolveCurrentSearchParams\(searchParam\)\)/
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => !pattern.test(source))
|
||||
|
||||
@@ -10,13 +10,21 @@ const source = fs.readFileSync(pageFile, 'utf8')
|
||||
const querySource = fs.readFileSync(queryFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'waveform selection column is rendered near the left side',
|
||||
/prop:\s*'waveformSelection'[\s\S]*label:\s*'波形选择'[\s\S]*headerRender:\s*renderWaveformSelectionHeader[\s\S]*render:\s*renderWaveformSelectionCell/
|
||||
],
|
||||
[
|
||||
'waveform selection requires event id, waveform flag and waveform path',
|
||||
/const isWaveformExportable[\s\S]*Boolean\(row\.eventId\)[\s\S]*Number\(row\.fileFlag\)\s*===\s*1[\s\S]*Boolean\(row\.wavePath\)/
|
||||
],
|
||||
[
|
||||
'table keeps requested visible event column order',
|
||||
/label:\s*'发生时刻'[\s\S]*label:\s*'监测点名称'[\s\S]*label:\s*'持续时间\(s\)'[\s\S]*label:\s*'暂降\/暂升幅值\(%\)'[\s\S]*label:\s*'事件类型'[\s\S]*label:\s*'相别'/
|
||||
/label:\s*'发生时刻'[\s\S]*label:\s*'监测点名称'[\s\S]*label:\s*'暂降\/暂升幅值\(%\)'[\s\S]*label:\s*'持续时间\(s\)'[\s\S]*label:\s*'事件类型'[\s\S]*label:\s*'相别'/
|
||||
],
|
||||
[
|
||||
'event type column displays name but searches by eventType code',
|
||||
/prop:\s*'eventTypeName'[\s\S]*label:\s*'事件类型'[\s\S]*enum:\s*eventTypeOptions[\s\S]*fieldNames:\s*\{\s*label:\s*'name',\s*value:\s*'id'\s*\}[\s\S]*isFilterEnum:\s*false[\s\S]*search:\s*\{[\s\S]*key:\s*'eventType'[\s\S]*el:\s*'select'/
|
||||
/prop:\s*'eventTypeName'[\s\S]*label:\s*'事件类型'[\s\S]*enum:\s*eventTypeOptions[\s\S]*fieldNames:\s*\{\s*label:\s*'name',\s*value:\s*'code'\s*\}[\s\S]*isFilterEnum:\s*false[\s\S]*search:\s*\{[\s\S]*key:\s*'eventType'[\s\S]*el:\s*'select'/
|
||||
],
|
||||
[
|
||||
'event description defaults hidden in table columns',
|
||||
@@ -28,6 +36,7 @@ const expectations = [
|
||||
['measurement point dialog is present', /measurementPointDialogVisible[\s\S]*title="监测点信息"/],
|
||||
['operation switches between view waveform and supplement waveform', /Number\(row\.fileFlag\)\s*===\s*1[\s\S]*查看波形[\s\S]*波形补招/],
|
||||
['waveform status search uses custom render instead of select', /renderFileFlagSearch[\s\S]*prop:\s*'fileFlag'[\s\S]*search:\s*\{[\s\S]*render:\s*renderFileFlagSearch/],
|
||||
['phase column is not a search field', /prop:\s*'phase'[\s\S]*search:[\s\S]*prop:\s*'event_describe'/.test(source) === false],
|
||||
[
|
||||
'event description is not a search field',
|
||||
/prop:\s*'event_describe'[\s\S]*label:\s*'事件描述'[\s\S]*search:[\s\S]*prop:\s*'sagsource'/.test(source) === false
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
@reset="handleSearchReset"
|
||||
>
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" plain :icon="Download" @click="handleExport">导出</el-button>
|
||||
<el-button type="primary" plain :icon="Download" @click="handleEventExport">事件导出</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Download"
|
||||
:disabled="!selectedWaveformRows.length"
|
||||
@click="handleWaveformExport"
|
||||
>波形导出</el-button>
|
||||
</template>
|
||||
|
||||
<template #fileFlag="{ row }">
|
||||
@@ -22,7 +29,7 @@
|
||||
<el-button v-if="Number(row.fileFlag) === 1" type="primary" link :icon="View" @click="handleViewWaveform(row)">
|
||||
查看波形
|
||||
</el-button>
|
||||
<el-button v-else type="primary" link :icon="RefreshRight" @click="handleSupplementWaveform(row)">
|
||||
<el-button v-else type="primary" link :icon="RefreshRight" @click="handleSupplementWaveform">
|
||||
波形补招
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -46,11 +53,16 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElButton, ElRadioButton, ElRadioGroup } from 'element-plus'
|
||||
import { ElButton, ElCheckbox, ElRadioButton, ElRadioGroup } from 'element-plus'
|
||||
import { Download, RefreshRight, View } from '@element-plus/icons-vue'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { exportTransientEvents, getTransientEventDetail, getTransientEventPage } from '@/api/event/eventList'
|
||||
import {
|
||||
exportTransientEvents,
|
||||
exportTransientWaveforms,
|
||||
getTransientEventDetail,
|
||||
getTransientEventPage
|
||||
} from '@/api/event/eventList'
|
||||
import type { EventList } from '@/api/event/eventList/interface'
|
||||
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
@@ -76,6 +88,7 @@ const dictStore = useDictStore()
|
||||
const measurementPointDialogVisible = ref(false)
|
||||
const measurementPointLoading = ref(false)
|
||||
const measurementPointData = ref<EventList.TransientEventRecord | null>(null)
|
||||
const selectedWaveformRows = ref<EventList.TransientEventRecord[]>([])
|
||||
const eventTimeUnit = ref<TimePeriodUnit>('month')
|
||||
const eventTimeBaseDate = ref(new Date())
|
||||
const defaultStartTimeRange = buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
||||
@@ -113,9 +126,15 @@ const handleEventTimeDateChange = (value: Date | string | number | null) => {
|
||||
const handleSearchReset = () => {
|
||||
eventTimeUnit.value = 'month'
|
||||
eventTimeBaseDate.value = new Date()
|
||||
clearWaveformSelection()
|
||||
syncEventTimeRange()
|
||||
}
|
||||
|
||||
const resolveCurrentSearchParams = (params: EventSearchParams = {}) => ({
|
||||
...params,
|
||||
startTimeRange: params.startTimeRange ?? buildTimePeriodRange(eventTimeUnit.value, eventTimeBaseDate.value)
|
||||
})
|
||||
|
||||
const renderEventTimeSearch = () =>
|
||||
h(TimePeriodSearch, {
|
||||
class: 'event-time-search',
|
||||
@@ -144,7 +163,87 @@ const renderFileFlagSearch = ({ searchParam }: { searchParam: EventSearchParams
|
||||
)
|
||||
}
|
||||
|
||||
const isWaveformExportable = (row: EventList.TransientEventRecord) =>
|
||||
Boolean(row.eventId) && Number(row.fileFlag) === 1 && Boolean(row.wavePath)
|
||||
|
||||
const currentWaveformRows = computed(() => proTable.value?.tableData || [])
|
||||
const currentExportableWaveformRows = computed(() => currentWaveformRows.value.filter(isWaveformExportable))
|
||||
const selectedWaveformIds = computed(() => selectedWaveformRows.value.map(row => row.eventId).filter(Boolean))
|
||||
const selectedWaveformIdSet = computed(() => new Set(selectedWaveformIds.value))
|
||||
const isAllCurrentWaveformsSelected = computed(
|
||||
() =>
|
||||
currentExportableWaveformRows.value.length > 0 &&
|
||||
currentExportableWaveformRows.value.every(row => selectedWaveformIdSet.value.has(row.eventId))
|
||||
)
|
||||
const isCurrentWaveformSelectionIndeterminate = computed(() => {
|
||||
const selectedCount = currentExportableWaveformRows.value.filter(row =>
|
||||
selectedWaveformIdSet.value.has(row.eventId)
|
||||
).length
|
||||
return selectedCount > 0 && selectedCount < currentExportableWaveformRows.value.length
|
||||
})
|
||||
|
||||
const clearWaveformSelection = () => {
|
||||
selectedWaveformRows.value = []
|
||||
}
|
||||
|
||||
const toggleWaveformRowSelection = (row: EventList.TransientEventRecord, checked: boolean) => {
|
||||
if (!isWaveformExportable(row)) return
|
||||
|
||||
if (checked) {
|
||||
if (!selectedWaveformIdSet.value.has(row.eventId)) {
|
||||
selectedWaveformRows.value = [...selectedWaveformRows.value, row]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(item => item.eventId !== row.eventId)
|
||||
}
|
||||
|
||||
const toggleCurrentWaveformSelection = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const currentSelectedIds = new Set(selectedWaveformIds.value)
|
||||
const nextRows = [...selectedWaveformRows.value]
|
||||
|
||||
currentExportableWaveformRows.value.forEach(row => {
|
||||
if (!currentSelectedIds.has(row.eventId)) {
|
||||
nextRows.push(row)
|
||||
currentSelectedIds.add(row.eventId)
|
||||
}
|
||||
})
|
||||
|
||||
selectedWaveformRows.value = nextRows
|
||||
return
|
||||
}
|
||||
|
||||
const currentIds = new Set(currentExportableWaveformRows.value.map(row => row.eventId))
|
||||
selectedWaveformRows.value = selectedWaveformRows.value.filter(row => !currentIds.has(row.eventId))
|
||||
}
|
||||
|
||||
const renderWaveformSelectionHeader = () =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: isAllCurrentWaveformsSelected.value,
|
||||
indeterminate: isCurrentWaveformSelectionIndeterminate.value,
|
||||
disabled: !currentExportableWaveformRows.value.length,
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleCurrentWaveformSelection(Boolean(value))
|
||||
})
|
||||
|
||||
const renderWaveformSelectionCell = ({ row }: { row: EventList.TransientEventRecord }) =>
|
||||
h(ElCheckbox, {
|
||||
modelValue: selectedWaveformIdSet.value.has(row.eventId),
|
||||
disabled: !isWaveformExportable(row),
|
||||
'onUpdate:modelValue': (value: string | number | boolean) => toggleWaveformRowSelection(row, Boolean(value))
|
||||
})
|
||||
|
||||
const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
|
||||
{
|
||||
prop: 'waveformSelection',
|
||||
label: '波形选择',
|
||||
fixed: 'left',
|
||||
width: 90,
|
||||
isSetting: false,
|
||||
headerRender: renderWaveformSelectionHeader,
|
||||
render: renderWaveformSelectionCell
|
||||
},
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'startTime',
|
||||
@@ -187,14 +286,14 @@ const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
|
||||
() => resolveText(row.lineName)
|
||||
)
|
||||
},
|
||||
{ prop: 'duration', label: '持续时间(s)', minWidth: 130 },
|
||||
{ prop: 'featureAmplitude', label: '暂降/暂升幅值(%)', minWidth: 160 },
|
||||
{ prop: 'duration', label: '持续时间(s)', minWidth: 130 },
|
||||
{
|
||||
prop: 'eventTypeName',
|
||||
label: '事件类型',
|
||||
minWidth: 160,
|
||||
enum: eventTypeOptions,
|
||||
fieldNames: { label: 'name', value: 'id' },
|
||||
fieldNames: { label: 'name', value: 'code' },
|
||||
isFilterEnum: false,
|
||||
render: ({ row }) => resolveEventTypeName(row, eventTypeOptions.value),
|
||||
search: {
|
||||
@@ -206,10 +305,7 @@ const columns = reactive<ColumnProps<EventList.TransientEventRecord>[]>([
|
||||
prop: 'phase',
|
||||
label: '相别',
|
||||
minWidth: 90,
|
||||
enum: phaseOptions,
|
||||
search: {
|
||||
el: 'select'
|
||||
}
|
||||
enum: phaseOptions
|
||||
},
|
||||
{
|
||||
prop: 'event_describe',
|
||||
@@ -238,8 +334,9 @@ const resolveText = (value: unknown) => {
|
||||
}
|
||||
|
||||
const getTableList = (params: EventSearchParams) => {
|
||||
clearWaveformSelection()
|
||||
// 分页查询按 API_DEBUG.md 转换时间范围与枚举筛选参数。
|
||||
return getTransientEventPage(buildEventQueryParams(params))
|
||||
return getTransientEventPage(buildEventQueryParams(resolveCurrentSearchParams(params)))
|
||||
}
|
||||
|
||||
const handleViewMeasurementPoint = async (row: EventList.TransientEventRecord) => {
|
||||
@@ -275,14 +372,27 @@ const handleViewWaveform = (row: EventList.TransientEventRecord) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSupplementWaveform = (_row: EventList.TransientEventRecord) => {
|
||||
const handleSupplementWaveform = () => {
|
||||
// 波形补招需要后端补招接口,当前先保留操作入口避免误触发未知流程。
|
||||
ElMessage.warning('暂无波形补招接口,无法发起补招')
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const handleEventExport = () => {
|
||||
const searchParam = (proTable.value?.searchParam || {}) as EventSearchParams
|
||||
useDownloadWithServerFileName(exportTransientEvents, '暂态事件列表', buildEventQueryParams(searchParam), false)
|
||||
useDownloadWithServerFileName(exportTransientEvents, '暂态事件列表', buildEventQueryParams(resolveCurrentSearchParams(searchParam)), false)
|
||||
}
|
||||
|
||||
const handleWaveformExport = () => {
|
||||
const exportableRows = selectedWaveformRows.value.filter(isWaveformExportable)
|
||||
|
||||
if (!exportableRows.length) {
|
||||
ElMessage.warning('请先选择存在波形的事件')
|
||||
return
|
||||
}
|
||||
|
||||
useDownloadWithServerFileName(exportTransientWaveforms, '事件波形导出', {
|
||||
eventIds: exportableRows.map(row => row.eventId)
|
||||
}, false, '.zip')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user