refactor(frontend): canonicalize active panel files
- replace rewrite/clean transitional panel files with canonical filenames - update imports and navigation architecture notes to match runtime truth - ignore local .codex_tmp workspace artifacts
This commit is contained in:
@@ -56,6 +56,7 @@ nginx/temp/
|
|||||||
experiments/**/scratch/
|
experiments/**/scratch/
|
||||||
backend/quality_model.pkl
|
backend/quality_model.pkl
|
||||||
/_par_win_*.xls
|
/_par_win_*.xls
|
||||||
|
.codex_tmp/
|
||||||
|
|
||||||
# Large local datasets / installers
|
# Large local datasets / installers
|
||||||
Data/
|
Data/
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# INIT
|
# INIT
|
||||||
|
|
||||||
|
> 说明:本文件是开发/运维工作笔记,不是当前系统架构、部署或运行事实的最高依据。
|
||||||
|
> 优先阅读 `README.md`、`docs/INDEX.md` 与 `docs/DOCUMENTATION_GOVERNANCE.md`。
|
||||||
|
|
||||||
## 1. 当前开发机已确认的本机环境
|
## 1. 当前开发机已确认的本机环境
|
||||||
|
|
||||||
- Windows 开发机 Python 解释器:`C:\ProgramData\anaconda3\envs\InSAR\python.exe`
|
- Windows 开发机 Python 解释器:`C:\ProgramData\anaconda3\envs\InSAR\python.exe`
|
||||||
@@ -33,23 +36,32 @@
|
|||||||
- 修复 D-InSAR 运行时目录处理逻辑,避免继续依赖历史开发机 `Z:\` 路径。
|
- 修复 D-InSAR 运行时目录处理逻辑,避免继续依赖历史开发机 `Z:\` 路径。
|
||||||
- 当前原则:路径统一走 `.env`,不允许把开发机专用盘符写入跟踪代码。
|
- 当前原则:路径统一走 `.env`,不允许把开发机专用盘符写入跟踪代码。
|
||||||
|
|
||||||
### 2.3 前端乱码修复
|
### 2.3 前端编码与临时文件收口
|
||||||
|
|
||||||
本轮已清理以下面板中的残留乱码与混杂英文:
|
本轮已清理以下面板中的残留乱码与混杂英文:
|
||||||
|
|
||||||
- `frontend/src/DinsarProductionPanel.jsx`
|
- `frontend/src/DinsarProductionPanel.jsx`
|
||||||
- `frontend/src/DinsarProductsPanel.jsx`
|
- `frontend/src/DinsarProductsPanel.jsx`
|
||||||
|
|
||||||
日志管理原文件 `frontend/src/LogManagementPanel.jsx` 本身存在历史乱码内容,而且开发过程中目标文件一度被占用,当前采用的稳定方案是:
|
此前前端存在一批过渡文件名,例如:
|
||||||
|
|
||||||
- 新增干净版本:`frontend/src/LogManagementPanel.clean.jsx`
|
- `*.rewrite.jsx`
|
||||||
- 由 `frontend/src/HealthCheckPanel.jsx` 暂时改为导入 `LogManagementPanel.clean`
|
- `LogManagementPanel.clean.jsx`
|
||||||
|
|
||||||
这样做的目的:
|
这些文件名现在已经完成收口,当前应以正式文件名作为事实来源:
|
||||||
|
|
||||||
- 先保证页面可用、无乱码
|
- `frontend/src/DinsarProductsPanel.jsx`
|
||||||
- 不在文件被占用时强行覆盖旧文件
|
- `frontend/src/components/DinsarCatalogPanel.jsx`
|
||||||
- 后续如果旧文件占用解除,再考虑把 clean 版本回收为正式文件名
|
- `frontend/src/panels/DinsarResultPanel.jsx`
|
||||||
|
- `frontend/src/components/ResultExportModal.jsx`
|
||||||
|
- `frontend/src/components/panels/DinsarResultRow.jsx`
|
||||||
|
- `frontend/src/LogManagementPanel.jsx`
|
||||||
|
|
||||||
|
当前原则:
|
||||||
|
|
||||||
|
- 不再让“rewrite / clean”文件名长期停留在主分支
|
||||||
|
- 当前被界面实际使用的实现,应恢复为正式文件名
|
||||||
|
- 如后续再次出现临时过渡文件,必须在功能稳定后尽快收口
|
||||||
|
|
||||||
## 3. PowerShell 使用注意事项
|
## 3. PowerShell 使用注意事项
|
||||||
|
|
||||||
@@ -99,4 +111,3 @@ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|||||||
- 运行监控
|
- 运行监控
|
||||||
- 处理模板
|
- 处理模板
|
||||||
- D-InSAR 产物说明文案
|
- D-InSAR 产物说明文案
|
||||||
|
|
||||||
|
|||||||
@@ -2,166 +2,205 @@
|
|||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
|
|
||||||
This document is the source of truth for the left-side navigation structure in the frontend.
|
This document is the source of truth for the left-side navigation structure and the production workspace view model in the frontend.
|
||||||
|
|
||||||
It explains:
|
It explains:
|
||||||
|
|
||||||
- the first-level menu groups
|
- the first-level menu groups
|
||||||
- the second-level working domains
|
- the sectioned groups and their leaf tabs
|
||||||
- the leaf tabs bound to actual pages
|
- the special-case production workspace entry
|
||||||
- the reserved entries for future modules
|
- the reserved entries and legacy route aliases
|
||||||
- the files that must be updated when navigation changes
|
- the files that must be updated when navigation changes
|
||||||
|
|
||||||
The goal is to keep module boundaries stable as the system expands beyond D-InSAR into PS-InSAR and broader AI analysis workflows.
|
The goal is to keep module boundaries stable as the system expands across D-InSAR, time-series InSAR, AI analysis, and operations workflows.
|
||||||
|
|
||||||
## 2. Current First-Level Groups
|
## 2. Current First-Level Groups
|
||||||
|
|
||||||
The current first-level menu groups are:
|
The current first-level menu groups are:
|
||||||
|
|
||||||
- `data`: 数据管理
|
- `data`: 数据管理
|
||||||
- `production`: 生产规划
|
- `production_planning`: 生产规划
|
||||||
|
- `production_management`: 生产管理
|
||||||
- `insar_analysis`: InSAR形变分析
|
- `insar_analysis`: InSAR形变分析
|
||||||
- `ai_analysis`: AI分析
|
- `ai_analysis`: AI分析
|
||||||
- `water`: 水体监测
|
- `water`: 水体监测
|
||||||
- `ops`: 运行维护
|
- `ops`: 运行维护
|
||||||
|
|
||||||
Definition file:
|
Definition files:
|
||||||
|
|
||||||
- `frontend/src/config/appConstants.js`
|
- `frontend/src/config/appConstants.js`
|
||||||
|
- `frontend/src/utils/appUiHelpers.js`
|
||||||
|
|
||||||
## 3. Three-Level Structure
|
## 3. Navigation Model
|
||||||
|
|
||||||
Only some first-level groups use second-level working domains.
|
The current frontend uses two navigation patterns:
|
||||||
|
|
||||||
|
1. Sectioned navigation:
|
||||||
|
first-level group -> second-level section -> leaf tab
|
||||||
|
2. Workspace navigation:
|
||||||
|
first-level group -> single leaf tab -> internal workspace view switcher
|
||||||
|
|
||||||
### 3.1 生产规划
|
### 3.1 生产规划
|
||||||
|
|
||||||
|
This is a sectioned group.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
生产规划
|
生产规划
|
||||||
├─ 规划编组
|
├─ 规划编组
|
||||||
│ ├─ 配对规划
|
│ ├─ 配对规划 (`pairing`)
|
||||||
│ ├─ 任务规划
|
│ ├─ 任务规划 (`pairs`)
|
||||||
│ ├─ PS时序栈
|
│ ├─ 时序候选栈 (`ps_results`)
|
||||||
│ └─ 任务批次
|
│ └─ 任务批次 (`batches`)
|
||||||
├─ 数据分发
|
└─ 数据分发
|
||||||
│ └─ 数据分发
|
└─ 数据分发 (`copier`)
|
||||||
├─ D-InSAR
|
|
||||||
│ ├─ D-InSAR生产
|
|
||||||
│ └─ D-InSAR产物
|
|
||||||
└─ PS-InSAR
|
|
||||||
├─ PS-InSAR生产
|
|
||||||
└─ PS-InSAR产物
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `PS时序栈` belongs to production planning, not analysis.
|
- `ps_results` here means planning-stage candidate stacks, not analysis-facing result pages.
|
||||||
- `D-InSAR产物` is distinct from `D-InSAR结果`.
|
- This group no longer hosts D-InSAR production or product pages.
|
||||||
- `PS-InSAR生产` and `PS-InSAR产物` are reserved entries for future implementation.
|
|
||||||
|
|
||||||
### 3.2 InSAR形变分析
|
### 3.2 生产管理
|
||||||
|
|
||||||
|
This is a workspace group, not a multi-tab planning tree.
|
||||||
|
|
||||||
|
```text
|
||||||
|
生产管理
|
||||||
|
└─ 生产管理 (`production_management`)
|
||||||
|
├─ D-InSAR运行 (`dinsar_runs`)
|
||||||
|
├─ 时序InSAR运行 (`timeseries_runs`)
|
||||||
|
├─ D-InSAR产物 (`dinsar_products`)
|
||||||
|
└─ 时序InSAR产物 (`timeseries_products`)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The left navigation contains only one tab for this group: `production_management`.
|
||||||
|
- Internal workspace views are controlled by `PRODUCTION_WORKSPACE_VIEWS`.
|
||||||
|
- Route alias mapping is controlled by `PRODUCTION_WORKSPACE_ENTRY_TO_VIEW`.
|
||||||
|
- Legacy route tabs such as `dinsar_production` and `ps_products` map into this workspace and should not be treated as standalone left-nav entries.
|
||||||
|
|
||||||
|
### 3.3 InSAR形变分析
|
||||||
|
|
||||||
|
This is a sectioned group.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
InSAR形变分析
|
InSAR形变分析
|
||||||
├─ D-InSAR
|
├─ D-InSAR
|
||||||
│ ├─ D-InSAR结果
|
│ ├─ D-InSAR结果 (`dinsar_results`)
|
||||||
│ └─ D-InSAR分析
|
│ └─ D-InSAR分析 (`dinsar_analysis`)
|
||||||
└─ PS-InSAR
|
└─ 时序InSAR
|
||||||
├─ PS-InSAR结果
|
├─ 时序InSAR结果 (`psinsar_results`)
|
||||||
└─ PS-InSAR分析
|
└─ 时序InSAR分析 (`psinsar_analysis`)
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- This group is for business-facing result browsing and deformation analysis.
|
- This group is for business-facing result browsing and interpretation.
|
||||||
- AI diagnosis no longer belongs here.
|
- AI diagnosis does not belong here.
|
||||||
- `D-InSAR分析`, `PS-InSAR结果`, and `PS-InSAR分析` are currently reserved placeholders.
|
- `dinsar_analysis`, `psinsar_results`, and `psinsar_analysis` are currently reserved placeholders.
|
||||||
|
|
||||||
### 3.3 AI分析
|
### 3.4 AI分析
|
||||||
|
|
||||||
|
This is a sectioned group.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
AI分析
|
AI分析
|
||||||
├─ 形变智能分析
|
├─ 形变智能分析
|
||||||
│ ├─ AI质量评估
|
│ ├─ AI质量评估 (`ai_quality`)
|
||||||
│ └─ D-InSAR诊断
|
│ └─ D-InSAR诊断 (`ai_diagnosis`)
|
||||||
└─ 遥感视觉分析
|
└─ 遥感视觉分析
|
||||||
├─ 滑坡语义分割
|
├─ 滑坡语义分割 (`landslide_segmentation`)
|
||||||
└─ 无人机影像分析
|
└─ 无人机影像分析 (`uav_image_analysis`)
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- This group owns model-centric and intelligent-analysis capabilities.
|
- `ai_diagnosis` is the actual tab key; its display label is `D-InSAR诊断`.
|
||||||
- `D-InSAR诊断` is the renamed placement of the old AI diagnosis page.
|
- `landslide_segmentation` and `uav_image_analysis` remain reserved placeholders.
|
||||||
- `滑坡语义分割` and `无人机影像分析` are reserved placeholders for future AI modules.
|
|
||||||
|
|
||||||
## 4. Navigation Design Rules
|
### 3.5 无二级分组的一级入口
|
||||||
|
|
||||||
|
The following groups do not define second-level sections:
|
||||||
|
|
||||||
|
- `data`
|
||||||
|
leaf tabs: `ingest`, `data`, `hazard`
|
||||||
|
- `water`
|
||||||
|
leaf tabs: `water`
|
||||||
|
- `ops`
|
||||||
|
leaf tabs: `health`, `users`, `audit`
|
||||||
|
|
||||||
|
## 4. Source-Of-Truth Rules
|
||||||
|
|
||||||
The navigation follows these rules:
|
The navigation follows these rules:
|
||||||
|
|
||||||
- First-level groups represent stable business domains.
|
- `LEFT_GROUP_LABELS` defines the first-level group vocabulary.
|
||||||
- Second-level domains represent workflow clusters inside a domain.
|
- `LEFT_GROUP_SECTIONS` defines second-level sections where they exist.
|
||||||
- Leaf tabs represent actual pages bound to `leftPanelTab`.
|
- `LEFT_GROUP_TABS` defines which leaf tabs belong to each group.
|
||||||
- The state source of truth remains `leftPanelTab`; group and section are derived from the tab key.
|
- `LEFT_TAB_GROUP` and `LEFT_TAB_SECTION` are derived maps and should not be edited manually.
|
||||||
- New features should be added as leaf tabs under an existing domain whenever possible.
|
- `leftPanelTab` remains the route/state source of truth for the selected leaf entry.
|
||||||
- A new first-level group should be introduced only when the feature becomes a long-term standalone capability cluster.
|
- `production_management` is a special case: one left-nav tab owns multiple internal workspace views.
|
||||||
|
- New features should be added under an existing group whenever possible.
|
||||||
|
- A new first-level group should be introduced only for a durable, independent capability area.
|
||||||
|
|
||||||
## 5. Naming Rules
|
## 5. Naming Rules
|
||||||
|
|
||||||
To avoid future ambiguity, follow these naming constraints:
|
To avoid future ambiguity, use these naming constraints:
|
||||||
|
|
||||||
- Use `结果` for result browsing, querying, and visualization pages.
|
- Use `结果` for browsing, querying, and result-facing visualization pages.
|
||||||
- Use `产物` for extraction, packaging, publishing, and catalog management pages.
|
- Use `产物` for extraction, publishing, packaging, and catalog-management pages.
|
||||||
- Use `分析` for interpretation, statistics,专题分析, and human-facing analytical workflows.
|
- Use `运行` for task submission, engine selection, execution control, and runtime monitoring views.
|
||||||
- Use `诊断` for model-assisted fault analysis or AI-driven case reasoning.
|
- Use `分析` for interpretation, statistics, and analyst-facing thematic workflows.
|
||||||
- Do not reuse `PS结果` as a generic label.
|
- Use `诊断` for model-assisted fault analysis or AI-driven reasoning pages.
|
||||||
Use `PS时序栈` in production planning.
|
- Use `时序候选栈` only for planning-stage candidate stacks under `production_planning`.
|
||||||
Use `PS-InSAR结果` in analysis.
|
- Use `时序InSAR结果` for analysis-facing result pages under `insar_analysis`.
|
||||||
|
|
||||||
## 6. Files to Update When Navigation Changes
|
## 6. Files To Update When Navigation Changes
|
||||||
|
|
||||||
When adding or moving a tab, update these files together:
|
When adding or moving a tab, update these files together:
|
||||||
|
|
||||||
- `frontend/src/config/appConstants.js`
|
- `frontend/src/config/appConstants.js`
|
||||||
Defines first-level groups, second-level sections, tab ownership, and visibility.
|
Defines first-level groups, sections, tab ownership, workspace view mappings, and admin-only visibility.
|
||||||
- `frontend/src/utils/appUiHelpers.js`
|
- `frontend/src/utils/appUiHelpers.js`
|
||||||
Defines display labels for leaf tabs.
|
Defines display labels for leaf tabs.
|
||||||
|
- `frontend/src/components/app/AppSidePanel.jsx`
|
||||||
|
Renders the side-panel navigation and group/section switching behavior.
|
||||||
- `frontend/src/App.jsx`
|
- `frontend/src/App.jsx`
|
||||||
Renders the group tabs, section tabs, and page content.
|
Connects route state with panel rendering.
|
||||||
|
- `frontend/src/ProductionWorkspace.jsx`
|
||||||
|
Owns the internal production workspace view switcher.
|
||||||
- `frontend/src/App.css`
|
- `frontend/src/App.css`
|
||||||
Styles the first-level, second-level, and leaf-tab navigation.
|
Styles the navigation hierarchy and workspace entry state.
|
||||||
|
|
||||||
If the new tab is a real page instead of a placeholder, also add or update the corresponding panel component.
|
If the new tab is a real page instead of a placeholder, also add or update the corresponding panel component.
|
||||||
|
|
||||||
## 7. Reserved Leaf Tabs
|
## 7. Reserved Entries And Legacy Route Aliases
|
||||||
|
|
||||||
The following leaf tabs are intentionally reserved for future work:
|
Reserved leaf tabs:
|
||||||
|
|
||||||
- `ps_production`
|
|
||||||
- `ps_products`
|
|
||||||
- `dinsar_analysis`
|
- `dinsar_analysis`
|
||||||
- `psinsar_results`
|
- `psinsar_results`
|
||||||
- `psinsar_analysis`
|
- `psinsar_analysis`
|
||||||
- `landslide_segmentation`
|
- `landslide_segmentation`
|
||||||
- `uav_image_analysis`
|
- `uav_image_analysis`
|
||||||
|
|
||||||
Reserved tabs should remain visible in the information architecture if they help stabilize the long-term module layout.
|
Legacy route aliases mapped into `production_management`:
|
||||||
|
|
||||||
## 8. Implementation Notes
|
- `dinsar_production`
|
||||||
|
- `dinsar_products`
|
||||||
|
- `ps_production`
|
||||||
|
- `ps_products`
|
||||||
|
|
||||||
- Admin-only visibility is controlled by `ADMIN_ONLY_TABS` in `appConstants.js`.
|
These aliases exist for compatibility, but they are not first-class left-nav entries anymore.
|
||||||
- Section ownership is derived by `LEFT_TAB_SECTION`.
|
|
||||||
- Group ownership is derived by `LEFT_TAB_GROUP`.
|
|
||||||
- Groups without second-level sections still render as a two-level navigation.
|
|
||||||
- Groups with configured sections render as three-level navigation.
|
|
||||||
|
|
||||||
## 9. Future Extension Guidance
|
## 8. Future Extension Guidance
|
||||||
|
|
||||||
Recommended future additions:
|
Recommended future additions:
|
||||||
|
|
||||||
- Put PS-InSAR processing forms, task submission, and runtime monitoring under `ps_production`.
|
- Put new production execution or product-governance capability under `production_management` as an internal workspace view unless a separate first-level domain is clearly required.
|
||||||
- Put PS-InSAR extraction, packaging, registration, and catalog maintenance under `ps_products`.
|
- Put planning, batching, pairing, and dispatch preparation capability under `production_planning`.
|
||||||
- Put thematic deformation analysis and reporting under `dinsar_analysis` and `psinsar_analysis`.
|
- Put result browsing and analyst-facing deformation interpretation under `insar_analysis`.
|
||||||
- Put optical, UAV, or segmentation-based AI modules under `ai_analysis`, not under `insar_analysis`.
|
- Put intelligent interpretation, diagnosis, segmentation, and computer-vision modules under `ai_analysis`.
|
||||||
|
|
||||||
If a new feature belongs to intelligent interpretation or computer vision, prefer adding it under `AI分析`.
|
If a new feature belongs to intelligent interpretation or computer vision, prefer `AI分析`.
|
||||||
If a new feature belongs to result browsing or deformation business analysis, prefer adding it under `InSAR形变分析`.
|
If a new feature belongs to result browsing or deformation business analysis, prefer `InSAR形变分析`.
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { scanDinsarResults } from './api/dinsar';
|
import { scanDinsarResults } from './api/dinsar';
|
||||||
import { extractDispResults } from './api/idl';
|
import { extractDispResults } from './api/idl';
|
||||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
||||||
|
|
||||||
const card = {
|
|
||||||
background: '#fff',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
marginBottom: '12px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRODUCT_TASK_TYPES = [
|
const PRODUCT_TASK_TYPES = [
|
||||||
'SCAN_DINSAR',
|
'SCAN_DINSAR',
|
||||||
'PUBLISH_DINSAR_PRODUCTS',
|
'PUBLISH_DINSAR_PRODUCTS',
|
||||||
@@ -20,9 +12,9 @@ const PRODUCT_TASK_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TASK_TYPE_LABEL = {
|
const TASK_TYPE_LABEL = {
|
||||||
SCAN_DINSAR: 'D-InSAR结果扫描',
|
SCAN_DINSAR: 'D-InSAR 结果扫描',
|
||||||
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR产物发布',
|
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR 产物发布',
|
||||||
REBUILD_DINSAR_CATALOG: 'D-InSAR目录重建',
|
REBUILD_DINSAR_CATALOG: 'D-InSAR 目录重建',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL = {
|
const STATUS_LABEL = {
|
||||||
@@ -42,6 +34,18 @@ function formatStatus(status) {
|
|||||||
return STATUS_LABEL[status] || status || '-';
|
return STATUS_LABEL[status] || status || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMessageTone(message, fallbackError = false) {
|
||||||
|
if (fallbackError) return 'error';
|
||||||
|
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogTone(level) {
|
||||||
|
const normalized = String(level || '').toUpperCase();
|
||||||
|
if (normalized === 'ERROR') return 'error';
|
||||||
|
if (normalized === 'WARNING' || normalized === 'WARN') return 'warn';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||||
const [extractRootDir, setExtractRootDir] = useState('');
|
const [extractRootDir, setExtractRootDir] = useState('');
|
||||||
const [extractDestDir, setExtractDestDir] = useState('');
|
const [extractDestDir, setExtractDestDir] = useState('');
|
||||||
@@ -60,12 +64,13 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
const monitoredTask = activeTask || recentTask;
|
const monitoredTask = activeTask || recentTask;
|
||||||
const logTaskId = monitoredTask?.task_id || '';
|
const logTaskId = monitoredTask?.task_id || '';
|
||||||
const showingRecentTask = !activeTask && !!recentTask;
|
const showingRecentTask = !activeTask && !!recentTask;
|
||||||
|
const actionTone = getMessageTone(actionMessage, actionError);
|
||||||
|
|
||||||
const loadActiveTask = useCallback(async () => {
|
const loadActiveTask = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getActiveTasks();
|
const data = await getActiveTasks();
|
||||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||||
const relevantTask = tasks.find(task => PRODUCT_TASK_TYPES.includes(task.task_type)) || null;
|
const relevantTask = tasks.find((task) => PRODUCT_TASK_TYPES.includes(task.task_type)) || null;
|
||||||
setActiveTask(relevantTask);
|
setActiveTask(relevantTask);
|
||||||
return relevantTask;
|
return relevantTask;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -86,7 +91,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadTaskLogs = useCallback(async taskId => {
|
const loadTaskLogs = useCallback(async (taskId) => {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
setTaskLogs([]);
|
setTaskLogs([]);
|
||||||
return;
|
return;
|
||||||
@@ -102,7 +107,20 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteTaskLog = useCallback(async logId => {
|
const refreshMonitor = useCallback(async () => {
|
||||||
|
const [nextActiveTask, nextRecentTask] = await Promise.all([
|
||||||
|
loadActiveTask(),
|
||||||
|
loadRecentTask(),
|
||||||
|
]);
|
||||||
|
const nextTaskId = nextActiveTask?.task_id || nextRecentTask?.task_id || '';
|
||||||
|
await loadTaskLogs(nextTaskId);
|
||||||
|
}, [loadActiveTask, loadRecentTask, loadTaskLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshMonitor();
|
||||||
|
}, [refreshMonitor]);
|
||||||
|
|
||||||
|
const handleDeleteTaskLog = useCallback(async (logId) => {
|
||||||
const taskId = logTaskId;
|
const taskId = logTaskId;
|
||||||
if (!taskId || !logId || taskLogActionLoading) return;
|
if (!taskId || !logId || taskLogActionLoading) return;
|
||||||
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
||||||
@@ -138,19 +156,6 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
}
|
}
|
||||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||||
|
|
||||||
const refreshMonitor = useCallback(async () => {
|
|
||||||
const [nextActiveTask, nextRecentTask] = await Promise.all([
|
|
||||||
loadActiveTask(),
|
|
||||||
loadRecentTask(),
|
|
||||||
]);
|
|
||||||
const nextTaskId = nextActiveTask?.task_id || nextRecentTask?.task_id || '';
|
|
||||||
await loadTaskLogs(nextTaskId);
|
|
||||||
}, [loadActiveTask, loadRecentTask, loadTaskLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshMonitor();
|
|
||||||
}, [refreshMonitor]);
|
|
||||||
|
|
||||||
const handleExtract = async () => {
|
const handleExtract = async () => {
|
||||||
if (!extractRootDir.trim()) return;
|
if (!extractRootDir.trim()) return;
|
||||||
setExtracting(true);
|
setExtracting(true);
|
||||||
@@ -174,267 +179,203 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
setActionError(false);
|
setActionError(false);
|
||||||
try {
|
try {
|
||||||
const result = await scanDinsarResults();
|
const result = await scanDinsarResults();
|
||||||
setActionMessage(result?.message || `D-InSAR结果扫描任务已入队:${result?.task_id || '-'}`);
|
setActionMessage(result?.message || `D-InSAR 结果扫描任务已入队:${result?.task_id || '-'}`);
|
||||||
if (result?.task_id) {
|
if (result?.task_id) {
|
||||||
onJobQueued?.(result.task_id);
|
onJobQueued?.(result.task_id);
|
||||||
}
|
}
|
||||||
await refreshMonitor();
|
await refreshMonitor();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(true);
|
setActionError(true);
|
||||||
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR结果扫描失败');
|
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR 结果扫描失败');
|
||||||
} finally {
|
} finally {
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const monitorTone = useMemo(() => {
|
||||||
|
if (!monitoredTask) return 'neutral';
|
||||||
|
if (showingRecentTask) return 'info';
|
||||||
|
return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral';
|
||||||
|
}, [monitoredTask, showingRecentTask]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '16px', maxWidth: 960 }}>
|
<div className="dinsar-products-page">
|
||||||
<div style={card}>
|
<div className="dinsar-products-hero">
|
||||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>D-InSAR 产物提取与重扫</strong>
|
<div>
|
||||||
|
<strong>D-InSAR 结果提取与标准目录</strong>
|
||||||
<div
|
<p>
|
||||||
style={{
|
这里负责把生产目录中的位移结果提取为标准成果包,并触发统一扫描、发布和编目。
|
||||||
fontSize: 12,
|
生产运行与参数配置现已收口到“生产管理”工作台中的 “D-InSAR 运行” 子视图。
|
||||||
color: '#475569',
|
</p>
|
||||||
lineHeight: 1.6,
|
|
||||||
marginBottom: 10,
|
|
||||||
padding: '8px 10px',
|
|
||||||
background: '#f8fafc',
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
这里负责把生产目录中的位移结果提取为标准成果包,并触发结果重扫、发布和编目。生产运行与参数配置已独立放到“D-InSAR生产”选项卡。
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="dinsar-products-hero-badges">
|
||||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
|
<span className={`dinsar-status-pill tone-${readOnly ? 'warn' : 'ready'}`}>
|
||||||
<input
|
{readOnly ? '只读模式' : '可执行写操作'}
|
||||||
value={extractRootDir}
|
</span>
|
||||||
onChange={event => setExtractRootDir(event.target.value)}
|
<span className="dinsar-status-pill tone-info">日志改为手动刷新</span>
|
||||||
placeholder="结果根目录"
|
|
||||||
style={{ flex: 2, minWidth: 220, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={extractDestDir}
|
|
||||||
onChange={event => setExtractDestDir(event.target.value)}
|
|
||||||
placeholder="目标目录(可选)"
|
|
||||||
style={{ flex: 1, minWidth: 180, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleExtract}
|
|
||||||
disabled={extracting || !extractRootDir.trim()}
|
|
||||||
style={{ padding: '5px 14px', borderRadius: 4, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 13 }}
|
|
||||||
>
|
|
||||||
{extracting ? '提取中...' : '提取位移结果'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleScan}
|
|
||||||
disabled={readOnly || scanning}
|
|
||||||
style={{
|
|
||||||
padding: '5px 14px',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
background: '#f8fafc',
|
|
||||||
cursor: readOnly ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scanning ? '重扫中...' : '重扫结果'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionMessage && (
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: actionError ? '#dc2626' : '#166534' }}>
|
|
||||||
{actionMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{extractResult && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
padding: '6px 10px',
|
|
||||||
background: extractResult.error ? '#fef2f2' : '#f0fdf4',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{extractResult.error ? (
|
|
||||||
<span style={{ color: '#ef4444' }}>提取失败:{extractResult.error}</span>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, color: '#16a34a' }}>
|
|
||||||
<span>
|
|
||||||
提取完成:复制 {extractResult.copied || 0} 个文件,覆盖 {extractResult.overwritten || 0} 个文件。
|
|
||||||
</span>
|
|
||||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'ok' && (
|
|
||||||
<span style={{ color: '#166534' }}>
|
|
||||||
成果目录已同步:发布 {extractResult.catalog?.publish?.processed || 0} 项,重建登记 {extractResult.catalog?.rebuild?.registered || 0} 项。
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'error' && (
|
|
||||||
<span style={{ color: '#b45309' }}>
|
|
||||||
标准成果包目录同步失败:{extractResult.catalog?.message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={card}>
|
<div className="dinsar-products-top-grid">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
<section className="dinsar-products-card">
|
||||||
<strong style={{ fontSize: 14 }}>产物任务监控</strong>
|
<div className="dinsar-products-card-head">
|
||||||
<button
|
<div>
|
||||||
onClick={refreshMonitor}
|
<strong>结果提取与重扫</strong>
|
||||||
style={{
|
<span>先提取标准结果包,再按统一目录登记</span>
|
||||||
fontSize: 12,
|
</div>
|
||||||
padding: '3px 10px',
|
</div>
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: '#f8fafc',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 10 }}>
|
|
||||||
任务状态与日志改为手动刷新,避免页面持续轮询。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!monitoredTask ? (
|
<div className="dinsar-products-form-grid">
|
||||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有正在执行的产物处理任务。</div>
|
<label className="dinsar-products-field dinsar-products-field-wide">
|
||||||
) : (
|
<span>结果根目录</span>
|
||||||
<div
|
<input
|
||||||
style={{
|
value={extractRootDir}
|
||||||
padding: '8px 10px',
|
onChange={(event) => setExtractRootDir(event.target.value)}
|
||||||
background: showingRecentTask ? '#eff6ff' : '#fefce8',
|
placeholder="例如:D:\\Task_Pool\\DInSAR"
|
||||||
borderRadius: 6,
|
/>
|
||||||
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
|
</label>
|
||||||
}}
|
<label className="dinsar-products-field">
|
||||||
>
|
<span>目标目录(可选)</span>
|
||||||
<div
|
<input
|
||||||
style={{
|
value={extractDestDir}
|
||||||
fontSize: 12,
|
onChange={(event) => setExtractDestDir(event.target.value)}
|
||||||
fontWeight: 600,
|
placeholder="留空则使用系统默认"
|
||||||
color: showingRecentTask ? '#1d4ed8' : '#92400e',
|
/>
|
||||||
marginBottom: 4,
|
</label>
|
||||||
}}
|
</div>
|
||||||
|
|
||||||
|
<div className="dinsar-products-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={handleExtract}
|
||||||
|
disabled={extracting || !extractRootDir.trim()}
|
||||||
>
|
>
|
||||||
{showingRecentTask ? '最近一次任务' : '当前任务'}
|
{extracting ? '提取中...' : '提取位移结果'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScan}
|
||||||
|
disabled={readOnly || scanning}
|
||||||
|
>
|
||||||
|
{scanning ? '重扫中...' : '重扫结果'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionMessage && (
|
||||||
|
<div className={`dinsar-products-message tone-${actionTone}`}>
|
||||||
|
{actionMessage}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: showingRecentTask ? '#1e40af' : '#78350f', wordBreak: 'break-all' }}>
|
)}
|
||||||
{monitoredTask.task_id} - {formatTaskType(monitoredTask.task_type)} - {formatStatus(monitoredTask.status)} - {monitoredTask.message}
|
|
||||||
|
{extractResult && (
|
||||||
|
<div className={`dinsar-products-result-card ${extractResult.error ? 'error' : 'success'}`}>
|
||||||
|
{extractResult.error ? (
|
||||||
|
<span>提取失败:{extractResult.error}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>提取完成:复制 {extractResult.copied || 0} 个文件,覆盖 {extractResult.overwritten || 0} 个文件。</div>
|
||||||
|
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'ok' && (
|
||||||
|
<div>
|
||||||
|
标准结果目录已同步:发布 {extractResult.catalog?.publish?.processed || 0} 项,
|
||||||
|
重建登记 {extractResult.catalog?.rebuild?.registered || 0} 项。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'error' && (
|
||||||
|
<div>标准结果目录同步失败:{extractResult.catalog?.message}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{monitoredTask.progress != null && (
|
)}
|
||||||
<div style={{ marginTop: 6 }}>
|
</section>
|
||||||
<div
|
|
||||||
style={{
|
<section className={`dinsar-products-card monitor tone-${monitorTone}`}>
|
||||||
height: 6,
|
<div className="dinsar-products-card-head">
|
||||||
background: showingRecentTask ? '#dbeafe' : '#fde68a',
|
<div>
|
||||||
borderRadius: 3,
|
<strong>产物任务监控</strong>
|
||||||
overflow: 'hidden',
|
<span>当前不轮询,按需手动刷新</span>
|
||||||
}}
|
</div>
|
||||||
>
|
<button type="button" onClick={refreshMonitor}>刷新</button>
|
||||||
<div
|
</div>
|
||||||
style={{
|
|
||||||
height: '100%',
|
{!monitoredTask ? (
|
||||||
width: `${monitoredTask.progress}%`,
|
<div className="dinsar-products-empty">当前没有正在执行的产物处理任务。</div>
|
||||||
background: showingRecentTask ? '#3b82f6' : '#f59e0b',
|
) : (
|
||||||
transition: 'width 0.3s',
|
<div className="dinsar-monitor-card">
|
||||||
}}
|
<div className="dinsar-monitor-top">
|
||||||
/>
|
<div>
|
||||||
|
<strong>{showingRecentTask ? '最近一次任务' : '当前任务'}</strong>
|
||||||
|
<span>{formatTaskType(monitoredTask.task_type)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#92400e', marginTop: 2 }}>{monitoredTask.progress}%</div>
|
<StatusSummary status={monitoredTask.status} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div className="dinsar-monitor-task-id">{monitoredTask.task_id}</div>
|
||||||
style={{
|
<div className="dinsar-monitor-message">{monitoredTask.message || '-'}</div>
|
||||||
marginTop: 8,
|
|
||||||
background: '#fff',
|
{monitoredTask.progress != null && (
|
||||||
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
|
<div className="dinsar-monitor-progress">
|
||||||
borderRadius: 6,
|
<div className="dinsar-monitor-progress-track">
|
||||||
padding: '8px 10px',
|
<div
|
||||||
}}
|
className="dinsar-monitor-progress-bar"
|
||||||
>
|
style={{ width: `${monitoredTask.progress}%` }}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
/>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: showingRecentTask ? '#1d4ed8' : '#92400e' }}>
|
</div>
|
||||||
{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}
|
<span>{monitoredTask.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dinsar-monitor-log-head">
|
||||||
|
<strong>{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}</strong>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleClearTaskLogs}
|
onClick={handleClearTaskLogs}
|
||||||
disabled={taskLogActionLoading || taskLogs.length === 0}
|
disabled={taskLogActionLoading || taskLogs.length === 0}
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid #fcd34d',
|
|
||||||
background: taskLogActionLoading || taskLogs.length === 0 ? '#fef3c7' : '#fff7ed',
|
|
||||||
color: '#9a3412',
|
|
||||||
cursor: taskLogActionLoading || taskLogs.length === 0 ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
|
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{taskLogsLoading ? (
|
{taskLogsLoading ? (
|
||||||
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#a16207' }}>加载中...</div>
|
<div className="dinsar-products-empty">日志加载中...</div>
|
||||||
) : taskLogs.length === 0 ? (
|
) : taskLogs.length === 0 ? (
|
||||||
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#a16207' }}>暂无日志。</div>
|
<div className="dinsar-products-empty">暂无日志。</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="dinsar-monitor-log-list">
|
||||||
{taskLogs.map((log, index) => (
|
{taskLogs.map((log, index) => {
|
||||||
<div
|
const tone = getLogTone(log.level);
|
||||||
key={log.id || `${log.timestamp || 'log'}-${index}`}
|
return (
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
key={log.id || `${log.timestamp || 'log'}-${index}`}
|
||||||
flex: 1,
|
className={`dinsar-monitor-log-item tone-${tone}`}
|
||||||
minWidth: 0,
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.45,
|
|
||||||
color: log.level === 'ERROR' ? '#b91c1c' : log.level === 'WARNING' ? '#b45309' : '#334155',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ color: '#64748b' }}>
|
<div className="dinsar-monitor-log-main">
|
||||||
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
<div className="dinsar-monitor-log-time">
|
||||||
|
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
||||||
|
</div>
|
||||||
|
<div className="dinsar-monitor-log-message">{log.message}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</div>
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDeleteTaskLog(log.id)}
|
||||||
|
disabled={taskLogActionLoading || !log.id}
|
||||||
|
>
|
||||||
|
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
);
|
||||||
<button
|
})}
|
||||||
onClick={() => handleDeleteTaskLog(log.id)}
|
|
||||||
disabled={taskLogActionLoading || !log.id}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid #fecaca',
|
|
||||||
background: '#fef2f2',
|
|
||||||
color: '#b91c1c',
|
|
||||||
cursor: taskLogActionLoading || !log.id ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DinsarCatalogPanel
|
<DinsarCatalogPanel
|
||||||
@@ -446,3 +387,19 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusSummary({ status }) {
|
||||||
|
const normalized = String(status || '').toUpperCase();
|
||||||
|
const tone = normalized === 'RUNNING'
|
||||||
|
? 'warn'
|
||||||
|
: normalized === 'FAILED'
|
||||||
|
? 'error'
|
||||||
|
: normalized === 'COMPLETED'
|
||||||
|
? 'ready'
|
||||||
|
: 'neutral';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`dinsar-status-pill tone-${tone}`}>
|
||||||
|
{formatStatus(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,405 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { scanDinsarResults } from './api/dinsar';
|
|
||||||
import { extractDispResults } from './api/idl';
|
|
||||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
|
||||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel.rewrite';
|
|
||||||
|
|
||||||
const PRODUCT_TASK_TYPES = [
|
|
||||||
'SCAN_DINSAR',
|
|
||||||
'PUBLISH_DINSAR_PRODUCTS',
|
|
||||||
'REBUILD_DINSAR_CATALOG',
|
|
||||||
];
|
|
||||||
|
|
||||||
const TASK_TYPE_LABEL = {
|
|
||||||
SCAN_DINSAR: 'D-InSAR 结果扫描',
|
|
||||||
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR 产物发布',
|
|
||||||
REBUILD_DINSAR_CATALOG: 'D-InSAR 目录重建',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL = {
|
|
||||||
PENDING: '等待中',
|
|
||||||
RUNNING: '运行中',
|
|
||||||
COMPLETED: '已完成',
|
|
||||||
FAILED: '失败',
|
|
||||||
CANCELLED: '已取消',
|
|
||||||
CANCELED: '已取消',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTaskType(taskType) {
|
|
||||||
return TASK_TYPE_LABEL[taskType] || taskType || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStatus(status) {
|
|
||||||
return STATUS_LABEL[status] || status || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageTone(message, fallbackError = false) {
|
|
||||||
if (fallbackError) return 'error';
|
|
||||||
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLogTone(level) {
|
|
||||||
const normalized = String(level || '').toUpperCase();
|
|
||||||
if (normalized === 'ERROR') return 'error';
|
|
||||||
if (normalized === 'WARNING' || normalized === 'WARN') return 'warn';
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|
||||||
const [extractRootDir, setExtractRootDir] = useState('');
|
|
||||||
const [extractDestDir, setExtractDestDir] = useState('');
|
|
||||||
const [extractResult, setExtractResult] = useState(null);
|
|
||||||
const [extracting, setExtracting] = useState(false);
|
|
||||||
const [actionMessage, setActionMessage] = useState('');
|
|
||||||
const [actionError, setActionError] = useState(false);
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
|
|
||||||
const [activeTask, setActiveTask] = useState(null);
|
|
||||||
const [recentTask, setRecentTask] = useState(null);
|
|
||||||
const [taskLogs, setTaskLogs] = useState([]);
|
|
||||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
|
||||||
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
|
|
||||||
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
|
|
||||||
const monitoredTask = activeTask || recentTask;
|
|
||||||
const logTaskId = monitoredTask?.task_id || '';
|
|
||||||
const showingRecentTask = !activeTask && !!recentTask;
|
|
||||||
const actionTone = getMessageTone(actionMessage, actionError);
|
|
||||||
|
|
||||||
const loadActiveTask = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await getActiveTasks();
|
|
||||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
|
||||||
const relevantTask = tasks.find((task) => PRODUCT_TASK_TYPES.includes(task.task_type)) || null;
|
|
||||||
setActiveTask(relevantTask);
|
|
||||||
return relevantTask;
|
|
||||||
} catch {
|
|
||||||
setActiveTask(null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadRecentTask = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const tasks = await getRecentTasks(PRODUCT_TASK_TYPES, [], 1, 0);
|
|
||||||
const nextTask = Array.isArray(tasks) ? (tasks[0] || null) : null;
|
|
||||||
setRecentTask(nextTask);
|
|
||||||
return nextTask;
|
|
||||||
} catch {
|
|
||||||
setRecentTask(null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadTaskLogs = useCallback(async (taskId) => {
|
|
||||||
if (!taskId) {
|
|
||||||
setTaskLogs([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTaskLogsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await getTaskLogs(taskId, 50, 0);
|
|
||||||
setTaskLogs(data?.logs || []);
|
|
||||||
} catch {
|
|
||||||
setTaskLogs([]);
|
|
||||||
} finally {
|
|
||||||
setTaskLogsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshMonitor = useCallback(async () => {
|
|
||||||
const [nextActiveTask, nextRecentTask] = await Promise.all([
|
|
||||||
loadActiveTask(),
|
|
||||||
loadRecentTask(),
|
|
||||||
]);
|
|
||||||
const nextTaskId = nextActiveTask?.task_id || nextRecentTask?.task_id || '';
|
|
||||||
await loadTaskLogs(nextTaskId);
|
|
||||||
}, [loadActiveTask, loadRecentTask, loadTaskLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshMonitor();
|
|
||||||
}, [refreshMonitor]);
|
|
||||||
|
|
||||||
const handleDeleteTaskLog = useCallback(async (logId) => {
|
|
||||||
const taskId = logTaskId;
|
|
||||||
if (!taskId || !logId || taskLogActionLoading) return;
|
|
||||||
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
|
||||||
|
|
||||||
setTaskLogDeletingId(logId);
|
|
||||||
setTaskLogActionLoading(true);
|
|
||||||
try {
|
|
||||||
await deleteTaskLog(taskId, logId);
|
|
||||||
await loadTaskLogs(taskId);
|
|
||||||
} catch (error) {
|
|
||||||
setActionMessage(`删除日志失败:${error?.response?.data?.detail || error.message}`);
|
|
||||||
setActionError(true);
|
|
||||||
} finally {
|
|
||||||
setTaskLogDeletingId(null);
|
|
||||||
setTaskLogActionLoading(false);
|
|
||||||
}
|
|
||||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading]);
|
|
||||||
|
|
||||||
const handleClearTaskLogs = useCallback(async () => {
|
|
||||||
const taskId = logTaskId;
|
|
||||||
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
|
||||||
if (!window.confirm(`确定要清空任务 ${taskId} 的全部日志吗?`)) return;
|
|
||||||
|
|
||||||
setTaskLogActionLoading(true);
|
|
||||||
try {
|
|
||||||
await clearTaskLogs(taskId);
|
|
||||||
await loadTaskLogs(taskId);
|
|
||||||
} catch (error) {
|
|
||||||
setActionMessage(`清空日志失败:${error?.response?.data?.detail || error.message}`);
|
|
||||||
setActionError(true);
|
|
||||||
} finally {
|
|
||||||
setTaskLogActionLoading(false);
|
|
||||||
}
|
|
||||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
|
||||||
|
|
||||||
const handleExtract = async () => {
|
|
||||||
if (!extractRootDir.trim()) return;
|
|
||||||
setExtracting(true);
|
|
||||||
setExtractResult(null);
|
|
||||||
setActionMessage('');
|
|
||||||
setActionError(false);
|
|
||||||
try {
|
|
||||||
const result = await extractDispResults(extractRootDir.trim(), extractDestDir.trim() || null);
|
|
||||||
setExtractResult(result);
|
|
||||||
} catch (err) {
|
|
||||||
setExtractResult({ error: err?.response?.data?.detail || err.message });
|
|
||||||
} finally {
|
|
||||||
setExtracting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScan = async () => {
|
|
||||||
if (readOnly) return;
|
|
||||||
setScanning(true);
|
|
||||||
setActionMessage('');
|
|
||||||
setActionError(false);
|
|
||||||
try {
|
|
||||||
const result = await scanDinsarResults();
|
|
||||||
setActionMessage(result?.message || `D-InSAR 结果扫描任务已入队:${result?.task_id || '-'}`);
|
|
||||||
if (result?.task_id) {
|
|
||||||
onJobQueued?.(result.task_id);
|
|
||||||
}
|
|
||||||
await refreshMonitor();
|
|
||||||
} catch (err) {
|
|
||||||
setActionError(true);
|
|
||||||
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR 结果扫描失败');
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const monitorTone = useMemo(() => {
|
|
||||||
if (!monitoredTask) return 'neutral';
|
|
||||||
if (showingRecentTask) return 'info';
|
|
||||||
return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral';
|
|
||||||
}, [monitoredTask, showingRecentTask]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dinsar-products-page">
|
|
||||||
<div className="dinsar-products-hero">
|
|
||||||
<div>
|
|
||||||
<strong>D-InSAR 结果提取与标准目录</strong>
|
|
||||||
<p>
|
|
||||||
这里负责把生产目录中的位移结果提取为标准成果包,并触发统一扫描、发布和编目。
|
|
||||||
生产运行与参数配置现已收口到“生产管理”工作台中的 “D-InSAR 运行” 子视图。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-products-hero-badges">
|
|
||||||
<span className={`dinsar-status-pill tone-${readOnly ? 'warn' : 'ready'}`}>
|
|
||||||
{readOnly ? '只读模式' : '可执行写操作'}
|
|
||||||
</span>
|
|
||||||
<span className="dinsar-status-pill tone-info">日志改为手动刷新</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-products-top-grid">
|
|
||||||
<section className="dinsar-products-card">
|
|
||||||
<div className="dinsar-products-card-head">
|
|
||||||
<div>
|
|
||||||
<strong>结果提取与重扫</strong>
|
|
||||||
<span>先提取标准结果包,再按统一目录登记</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-products-form-grid">
|
|
||||||
<label className="dinsar-products-field dinsar-products-field-wide">
|
|
||||||
<span>结果根目录</span>
|
|
||||||
<input
|
|
||||||
value={extractRootDir}
|
|
||||||
onChange={(event) => setExtractRootDir(event.target.value)}
|
|
||||||
placeholder="例如:D:\\Task_Pool\\DInSAR"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="dinsar-products-field">
|
|
||||||
<span>目标目录(可选)</span>
|
|
||||||
<input
|
|
||||||
value={extractDestDir}
|
|
||||||
onChange={(event) => setExtractDestDir(event.target.value)}
|
|
||||||
placeholder="留空则使用系统默认"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-products-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="primary"
|
|
||||||
onClick={handleExtract}
|
|
||||||
disabled={extracting || !extractRootDir.trim()}
|
|
||||||
>
|
|
||||||
{extracting ? '提取中...' : '提取位移结果'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleScan}
|
|
||||||
disabled={readOnly || scanning}
|
|
||||||
>
|
|
||||||
{scanning ? '重扫中...' : '重扫结果'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actionMessage && (
|
|
||||||
<div className={`dinsar-products-message tone-${actionTone}`}>
|
|
||||||
{actionMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{extractResult && (
|
|
||||||
<div className={`dinsar-products-result-card ${extractResult.error ? 'error' : 'success'}`}>
|
|
||||||
{extractResult.error ? (
|
|
||||||
<span>提取失败:{extractResult.error}</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>提取完成:复制 {extractResult.copied || 0} 个文件,覆盖 {extractResult.overwritten || 0} 个文件。</div>
|
|
||||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'ok' && (
|
|
||||||
<div>
|
|
||||||
标准结果目录已同步:发布 {extractResult.catalog?.publish?.processed || 0} 项,
|
|
||||||
重建登记 {extractResult.catalog?.rebuild?.registered || 0} 项。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'error' && (
|
|
||||||
<div>标准结果目录同步失败:{extractResult.catalog?.message}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={`dinsar-products-card monitor tone-${monitorTone}`}>
|
|
||||||
<div className="dinsar-products-card-head">
|
|
||||||
<div>
|
|
||||||
<strong>产物任务监控</strong>
|
|
||||||
<span>当前不轮询,按需手动刷新</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={refreshMonitor}>刷新</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!monitoredTask ? (
|
|
||||||
<div className="dinsar-products-empty">当前没有正在执行的产物处理任务。</div>
|
|
||||||
) : (
|
|
||||||
<div className="dinsar-monitor-card">
|
|
||||||
<div className="dinsar-monitor-top">
|
|
||||||
<div>
|
|
||||||
<strong>{showingRecentTask ? '最近一次任务' : '当前任务'}</strong>
|
|
||||||
<span>{formatTaskType(monitoredTask.task_type)}</span>
|
|
||||||
</div>
|
|
||||||
<StatusSummary status={monitoredTask.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-monitor-task-id">{monitoredTask.task_id}</div>
|
|
||||||
<div className="dinsar-monitor-message">{monitoredTask.message || '-'}</div>
|
|
||||||
|
|
||||||
{monitoredTask.progress != null && (
|
|
||||||
<div className="dinsar-monitor-progress">
|
|
||||||
<div className="dinsar-monitor-progress-track">
|
|
||||||
<div
|
|
||||||
className="dinsar-monitor-progress-bar"
|
|
||||||
style={{ width: `${monitoredTask.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span>{monitoredTask.progress}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="dinsar-monitor-log-head">
|
|
||||||
<strong>{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}</strong>
|
|
||||||
{!readOnly && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearTaskLogs}
|
|
||||||
disabled={taskLogActionLoading || taskLogs.length === 0}
|
|
||||||
>
|
|
||||||
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{taskLogsLoading ? (
|
|
||||||
<div className="dinsar-products-empty">日志加载中...</div>
|
|
||||||
) : taskLogs.length === 0 ? (
|
|
||||||
<div className="dinsar-products-empty">暂无日志。</div>
|
|
||||||
) : (
|
|
||||||
<div className="dinsar-monitor-log-list">
|
|
||||||
{taskLogs.map((log, index) => {
|
|
||||||
const tone = getLogTone(log.level);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={log.id || `${log.timestamp || 'log'}-${index}`}
|
|
||||||
className={`dinsar-monitor-log-item tone-${tone}`}
|
|
||||||
>
|
|
||||||
<div className="dinsar-monitor-log-main">
|
|
||||||
<div className="dinsar-monitor-log-time">
|
|
||||||
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-monitor-log-message">{log.message}</div>
|
|
||||||
</div>
|
|
||||||
{!readOnly && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger"
|
|
||||||
onClick={() => handleDeleteTaskLog(log.id)}
|
|
||||||
disabled={taskLogActionLoading || !log.id}
|
|
||||||
>
|
|
||||||
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DinsarCatalogPanel
|
|
||||||
readOnly={readOnly}
|
|
||||||
initialSourceDir={extractRootDir}
|
|
||||||
onTaskQueued={onJobQueued}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusSummary({ status }) {
|
|
||||||
const normalized = String(status || '').toUpperCase();
|
|
||||||
const tone = normalized === 'RUNNING'
|
|
||||||
? 'warn'
|
|
||||||
: normalized === 'FAILED'
|
|
||||||
? 'error'
|
|
||||||
: normalized === 'COMPLETED'
|
|
||||||
? 'ready'
|
|
||||||
: 'neutral';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`dinsar-status-pill tone-${tone}`}>
|
|
||||||
{formatStatus(status)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import { cleanupSessions } from './api/auth';
|
|||||||
import { syncWaterScenesFromDisk } from './api/water';
|
import { syncWaterScenesFromDisk } from './api/water';
|
||||||
import { listEngines, runWslCheck } from './api/dinsarProduction';
|
import { listEngines, runWslCheck } from './api/dinsarProduction';
|
||||||
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
|
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
|
||||||
import LogManagementPanel from './LogManagementPanel.clean';
|
import LogManagementPanel from './LogManagementPanel';
|
||||||
|
|
||||||
const toNumber = (value) => {
|
const toNumber = (value) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { listLogs, getLogContent, deleteLog } from './api/logs';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 1000;
|
|
||||||
|
|
||||||
const LogManagementPanel = ({ isAdmin }) => {
|
|
||||||
const [logs, setLogs] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedLog, setSelectedLog] = useState(null);
|
|
||||||
const [logContent, setLogContent] = useState('');
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [filterType, setFilterType] = useState('');
|
|
||||||
const [totalLines, setTotalLines] = useState(0);
|
|
||||||
const [currentOffset, setCurrentOffset] = useState(0);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const loadLogs = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await listLogs(filterType || null);
|
|
||||||
setLogs(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载日志列表失败:', error);
|
|
||||||
alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filterType]);
|
|
||||||
|
|
||||||
const loadLogContent = useCallback(async (logPath, offset = 0) => {
|
|
||||||
try {
|
|
||||||
const data = await getLogContent(logPath, offset, PAGE_SIZE);
|
|
||||||
setLogContent(data.content || '');
|
|
||||||
setTotalLines(data.total_lines || 0);
|
|
||||||
setCurrentOffset(offset);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载日志内容失败:', error);
|
|
||||||
alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadLogs();
|
|
||||||
}, [loadLogs]);
|
|
||||||
|
|
||||||
const handleViewLog = async log => {
|
|
||||||
setSelectedLog(log);
|
|
||||||
setShowModal(true);
|
|
||||||
setCurrentOffset(0);
|
|
||||||
setSearchTerm('');
|
|
||||||
await loadLogContent(log.path, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLog = async log => {
|
|
||||||
if (!isAdmin) {
|
|
||||||
alert('只有管理员可以删除日志。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.confirm(`确定要删除日志文件“${log.name}”吗?\n\n此操作不可恢复。`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteLog(log.path);
|
|
||||||
alert('日志文件已删除。');
|
|
||||||
await loadLogs();
|
|
||||||
if (selectedLog && selectedLog.path === log.path) {
|
|
||||||
setShowModal(false);
|
|
||||||
setSelectedLog(null);
|
|
||||||
setLogContent('');
|
|
||||||
setTotalLines(0);
|
|
||||||
setCurrentOffset(0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除日志失败:', error);
|
|
||||||
alert(`删除日志失败:${error.response?.data?.detail || error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
if (selectedLog && currentOffset > 0) {
|
|
||||||
const newOffset = Math.max(0, currentOffset - PAGE_SIZE);
|
|
||||||
loadLogContent(selectedLog.path, newOffset);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
|
||||||
if (selectedLog && currentOffset + PAGE_SIZE < totalLines) {
|
|
||||||
const newOffset = currentOffset + PAGE_SIZE;
|
|
||||||
loadLogContent(selectedLog.path, newOffset);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSize = bytes => {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeLabel = type => {
|
|
||||||
const labels = {
|
|
||||||
app: '应用日志',
|
|
||||||
task: '任务日志',
|
|
||||||
error: '错误日志',
|
|
||||||
other: '其他',
|
|
||||||
};
|
|
||||||
return labels[type] || type;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeColor = type => {
|
|
||||||
const colors = {
|
|
||||||
app: '#3b82f6',
|
|
||||||
task: '#10b981',
|
|
||||||
error: '#ef4444',
|
|
||||||
other: '#6b7280',
|
|
||||||
};
|
|
||||||
return colors[type] || '#6b7280';
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredContent = searchTerm
|
|
||||||
? logContent
|
|
||||||
.split('\n')
|
|
||||||
.filter(line => line.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
||||||
.join('\n')
|
|
||||||
: logContent;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px' }}>
|
|
||||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<h3 style={{ margin: 0 }}>日志管理</h3>
|
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
||||||
<label>类型筛选:</label>
|
|
||||||
<select
|
|
||||||
value={filterType}
|
|
||||||
onChange={event => setFilterType(event.target.value)}
|
|
||||||
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }}
|
|
||||||
>
|
|
||||||
<option value="">全部</option>
|
|
||||||
<option value="app">应用日志</option>
|
|
||||||
<option value="task">任务日志</option>
|
|
||||||
<option value="error">错误日志</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={loadLogs}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
padding: '5px 15px',
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: loading ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? '加载中...' : '刷新'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>暂无日志文件</div>
|
|
||||||
) : (
|
|
||||||
<table
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ backgroundColor: '#f3f4f6', borderBottom: '2px solid #e5e7eb' }}>
|
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>文件名</th>
|
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>类型</th>
|
|
||||||
<th style={{ padding: '12px', textAlign: 'right' }}>大小</th>
|
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>修改时间</th>
|
|
||||||
<th style={{ padding: '12px', textAlign: 'center' }}>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{logs.map((log, index) => (
|
|
||||||
<tr key={index} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
|
||||||
<td style={{ padding: '12px', fontFamily: 'monospace', fontSize: '13px' }}>{log.name}</td>
|
|
||||||
<td style={{ padding: '12px' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
backgroundColor: `${getTypeColor(log.type)}20`,
|
|
||||||
color: getTypeColor(log.type),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getTypeLabel(log.type)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px', textAlign: 'right', fontFamily: 'monospace', fontSize: '13px' }}>
|
|
||||||
{formatSize(log.size)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px', fontSize: '13px' }}>{log.modified_at}</td>
|
|
||||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', alignItems: 'center' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewLog(log)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 12px',
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
查看
|
|
||||||
</button>
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteLog(log)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 12px',
|
|
||||||
backgroundColor: '#ef4444',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showModal && selectedLog && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
width: '90%',
|
|
||||||
maxWidth: '1200px',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '20px',
|
|
||||||
borderBottom: '1px solid #e5e7eb',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3>
|
|
||||||
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
|
||||||
大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: '#6b7280',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索日志内容..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={event => setSearchTerm(event.target.value)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '6px 12px',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
|
||||||
显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handlePrevPage}
|
|
||||||
disabled={currentOffset === 0}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
backgroundColor: currentOffset === 0 ? '#e5e7eb' : '#3b82f6',
|
|
||||||
color: currentOffset === 0 ? '#9ca3af' : 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: currentOffset === 0 ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleNextPage}
|
|
||||||
disabled={currentOffset + PAGE_SIZE >= totalLines}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
backgroundColor: currentOffset + PAGE_SIZE >= totalLines ? '#e5e7eb' : '#3b82f6',
|
|
||||||
color: currentOffset + PAGE_SIZE >= totalLines ? '#9ca3af' : 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: currentOffset + PAGE_SIZE >= totalLines ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}>
|
|
||||||
<pre
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
color: '#d4d4d4',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredContent || '(空日志)'}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LogManagementPanel;
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { listLogs, getLogContent, deleteLog } from './api/logs';
|
import { listLogs, getLogContent, deleteLog } from './api/logs';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 1000;
|
||||||
|
|
||||||
const LogManagementPanel = ({ isAdmin }) => {
|
const LogManagementPanel = ({ isAdmin }) => {
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -19,17 +21,29 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
setLogs(data);
|
setLogs(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载日志列表失败:', error);
|
console.error('加载日志列表失败:', error);
|
||||||
alert(`加载日志列表失败: ${error.response?.data?.detail || error.message}`);
|
alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filterType]);
|
}, [filterType]);
|
||||||
|
|
||||||
|
const loadLogContent = useCallback(async (logPath, offset = 0) => {
|
||||||
|
try {
|
||||||
|
const data = await getLogContent(logPath, offset, PAGE_SIZE);
|
||||||
|
setLogContent(data.content || '');
|
||||||
|
setTotalLines(data.total_lines || 0);
|
||||||
|
setCurrentOffset(offset);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载日志内容失败:', error);
|
||||||
|
alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
}, [loadLogs]);
|
}, [loadLogs]);
|
||||||
|
|
||||||
const handleViewLog = async (log) => {
|
const handleViewLog = async log => {
|
||||||
setSelectedLog(log);
|
setSelectedLog(log);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setCurrentOffset(0);
|
setCurrentOffset(0);
|
||||||
@@ -37,83 +51,78 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
await loadLogContent(log.path, 0);
|
await loadLogContent(log.path, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLogContent = async (logPath, offset = 0) => {
|
const handleDeleteLog = async log => {
|
||||||
try {
|
|
||||||
const data = await getLogContent(logPath, offset, 1000);
|
|
||||||
setLogContent(data.content);
|
|
||||||
setTotalLines(data.total_lines);
|
|
||||||
setCurrentOffset(offset);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载日志内容失败:', error);
|
|
||||||
alert(`加载日志内容失败: ${error.response?.data?.detail || error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLog = async (log) => {
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
alert('只有管理员可以删除日志');
|
alert('只有管理员可以删除日志。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.confirm(`确定要删除日志文件 "${log.name}" 吗?\n\n此操作不可恢复!`)) {
|
if (!window.confirm(`确定要删除日志文件“${log.name}”吗?\n\n此操作不可恢复。`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteLog(log.path);
|
await deleteLog(log.path);
|
||||||
alert('日志文件已删除');
|
alert('日志文件已删除。');
|
||||||
loadLogs();
|
await loadLogs();
|
||||||
if (selectedLog && selectedLog.path === log.path) {
|
if (selectedLog && selectedLog.path === log.path) {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
setSelectedLog(null);
|
||||||
|
setLogContent('');
|
||||||
|
setTotalLines(0);
|
||||||
|
setCurrentOffset(0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除日志失败:', error);
|
console.error('删除日志失败:', error);
|
||||||
alert(`删除日志失败: ${error.response?.data?.detail || error.message}`);
|
alert(`删除日志失败:${error.response?.data?.detail || error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
const handlePrevPage = () => {
|
||||||
if (currentOffset > 0) {
|
if (selectedLog && currentOffset > 0) {
|
||||||
const newOffset = Math.max(0, currentOffset - 1000);
|
const newOffset = Math.max(0, currentOffset - PAGE_SIZE);
|
||||||
loadLogContent(selectedLog.path, newOffset);
|
loadLogContent(selectedLog.path, newOffset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPage = () => {
|
const handleNextPage = () => {
|
||||||
if (currentOffset + 1000 < totalLines) {
|
if (selectedLog && currentOffset + PAGE_SIZE < totalLines) {
|
||||||
const newOffset = currentOffset + 1000;
|
const newOffset = currentOffset + PAGE_SIZE;
|
||||||
loadLogContent(selectedLog.path, newOffset);
|
loadLogContent(selectedLog.path, newOffset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatSize = (bytes) => {
|
const formatSize = bytes => {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeLabel = (type) => {
|
const getTypeLabel = type => {
|
||||||
const labels = {
|
const labels = {
|
||||||
app: '应用日志',
|
app: '应用日志',
|
||||||
task: '任务日志',
|
task: '任务日志',
|
||||||
error: '错误日志',
|
error: '错误日志',
|
||||||
other: '其他'
|
other: '其他',
|
||||||
};
|
};
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeColor = (type) => {
|
const getTypeColor = type => {
|
||||||
const colors = {
|
const colors = {
|
||||||
app: '#3b82f6',
|
app: '#3b82f6',
|
||||||
task: '#10b981',
|
task: '#10b981',
|
||||||
error: '#ef4444',
|
error: '#ef4444',
|
||||||
other: '#6b7280'
|
other: '#6b7280',
|
||||||
};
|
};
|
||||||
return colors[type] || '#6b7280';
|
return colors[type] || '#6b7280';
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredContent = searchTerm
|
const filteredContent = searchTerm
|
||||||
? logContent.split('\n').filter(line => line.toLowerCase().includes(searchTerm.toLowerCase())).join('\n')
|
? logContent
|
||||||
|
.split('\n')
|
||||||
|
.filter(line => line.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
.join('\n')
|
||||||
: logContent;
|
: logContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,10 +130,10 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h3 style={{ margin: 0 }}>日志管理</h3>
|
<h3 style={{ margin: 0 }}>日志管理</h3>
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
<label>类型过滤:</label>
|
<label>类型筛选:</label>
|
||||||
<select
|
<select
|
||||||
value={filterType}
|
value={filterType}
|
||||||
onChange={(e) => setFilterType(e.target.value)}
|
onChange={event => setFilterType(event.target.value)}
|
||||||
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }}
|
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
>
|
>
|
||||||
<option value="">全部</option>
|
<option value="">全部</option>
|
||||||
@@ -141,7 +150,7 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: loading ? 'not-allowed' : 'pointer'
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '加载中...' : '刷新'}
|
{loading ? '加载中...' : '刷新'}
|
||||||
@@ -150,11 +159,16 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>
|
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>暂无日志文件</div>
|
||||||
暂无日志文件
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', backgroundColor: 'white', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
<table
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: '#f3f4f6', borderBottom: '2px solid #e5e7eb' }}>
|
<tr style={{ backgroundColor: '#f3f4f6', borderBottom: '2px solid #e5e7eb' }}>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>文件名</th>
|
<th style={{ padding: '12px', textAlign: 'left' }}>文件名</th>
|
||||||
@@ -169,13 +183,15 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
<tr key={index} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={index} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||||
<td style={{ padding: '12px', fontFamily: 'monospace', fontSize: '13px' }}>{log.name}</td>
|
<td style={{ padding: '12px', fontFamily: 'monospace', fontSize: '13px' }}>{log.name}</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: '12px' }}>
|
||||||
<span style={{
|
<span
|
||||||
padding: '2px 8px',
|
style={{
|
||||||
borderRadius: '12px',
|
padding: '2px 8px',
|
||||||
fontSize: '12px',
|
borderRadius: '12px',
|
||||||
backgroundColor: getTypeColor(log.type) + '20',
|
fontSize: '12px',
|
||||||
color: getTypeColor(log.type)
|
backgroundColor: `${getTypeColor(log.type)}20`,
|
||||||
}}>
|
color: getTypeColor(log.type),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{getTypeLabel(log.type)}
|
{getTypeLabel(log.type)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -195,7 +211,7 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
@@ -211,7 +227,7 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -225,42 +241,46 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 日志查看 Modal */}
|
|
||||||
{showModal && selectedLog && (
|
{showModal && selectedLog && (
|
||||||
<div style={{
|
<div
|
||||||
position: 'fixed',
|
style={{
|
||||||
top: 0,
|
position: 'fixed',
|
||||||
left: 0,
|
top: 0,
|
||||||
right: 0,
|
left: 0,
|
||||||
bottom: 0,
|
right: 0,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
bottom: 0,
|
||||||
display: 'flex',
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 9999
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
width: '90%',
|
|
||||||
maxWidth: '1200px',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
|
justifyContent: 'center',
|
||||||
}}>
|
zIndex: 9999,
|
||||||
{/* Modal Header */}
|
}}
|
||||||
<div style={{
|
>
|
||||||
padding: '20px',
|
<div
|
||||||
borderBottom: '1px solid #e5e7eb',
|
style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
maxHeight: '90vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
flexDirection: 'column',
|
||||||
alignItems: 'center'
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3>
|
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3>
|
||||||
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
||||||
大小: {formatSize(selectedLog.size)} | 修改时间: {selectedLog.modified_at} | 总行数: {totalLines}
|
大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -271,30 +291,29 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}>
|
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索日志内容..."
|
placeholder="搜索日志内容..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={event => setSearchTerm(event.target.value)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
border: '1px solid #d1d5db',
|
border: '1px solid #d1d5db',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '13px'
|
fontSize: '13px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
||||||
显示行: {currentOffset + 1} - {Math.min(currentOffset + 1000, totalLines)}
|
显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handlePrevPage}
|
onClick={handlePrevPage}
|
||||||
@@ -306,40 +325,41 @@ const LogManagementPanel = ({ isAdmin }) => {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: currentOffset === 0 ? 'not-allowed' : 'pointer',
|
cursor: currentOffset === 0 ? 'not-allowed' : 'pointer',
|
||||||
fontSize: '13px'
|
fontSize: '13px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleNextPage}
|
onClick={handleNextPage}
|
||||||
disabled={currentOffset + 1000 >= totalLines}
|
disabled={currentOffset + PAGE_SIZE >= totalLines}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
backgroundColor: currentOffset + 1000 >= totalLines ? '#e5e7eb' : '#3b82f6',
|
backgroundColor: currentOffset + PAGE_SIZE >= totalLines ? '#e5e7eb' : '#3b82f6',
|
||||||
color: currentOffset + 1000 >= totalLines ? '#9ca3af' : 'white',
|
color: currentOffset + PAGE_SIZE >= totalLines ? '#9ca3af' : 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: currentOffset + 1000 >= totalLines ? 'not-allowed' : 'pointer',
|
cursor: currentOffset + PAGE_SIZE >= totalLines ? 'not-allowed' : 'pointer',
|
||||||
fontSize: '13px'
|
fontSize: '13px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log Content */}
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}>
|
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}>
|
||||||
<pre style={{
|
<pre
|
||||||
margin: 0,
|
style={{
|
||||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
margin: 0,
|
||||||
fontSize: '12px',
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
lineHeight: '1.5',
|
fontSize: '12px',
|
||||||
color: '#d4d4d4',
|
lineHeight: '1.5',
|
||||||
whiteSpace: 'pre-wrap',
|
color: '#d4d4d4',
|
||||||
wordBreak: 'break-all'
|
whiteSpace: 'pre-wrap',
|
||||||
}}>
|
wordBreak: 'break-all',
|
||||||
{filteredContent || '(空日志)'}
|
}}
|
||||||
|
>
|
||||||
|
{filteredContent || '(空日志)'}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { PanelLoadingBody } from './components/app/AppLoadingFallbacks';
|
|||||||
|
|
||||||
const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
|
const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
|
||||||
const LazyTimeseriesProductionPanel = lazy(() => import('./TimeseriesProductionPanel'));
|
const LazyTimeseriesProductionPanel = lazy(() => import('./TimeseriesProductionPanel'));
|
||||||
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel.rewrite'));
|
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel'));
|
||||||
const LazyPsinsarCatalogPanel = lazy(() => import('./components/PsinsarCatalogPanel'));
|
const LazyPsinsarCatalogPanel = lazy(() => import('./components/PsinsarCatalogPanel'));
|
||||||
|
|
||||||
const shellStyle = {
|
const shellStyle = {
|
||||||
|
|||||||
@@ -8,21 +8,19 @@ import {
|
|||||||
queueDinsarCatalogRebuild,
|
queueDinsarCatalogRebuild,
|
||||||
queueDinsarProductPublish,
|
queueDinsarProductPublish,
|
||||||
} from '../api/dinsarProducts';
|
} from '../api/dinsarProducts';
|
||||||
|
import {
|
||||||
|
DINSAR_ENGINE_ALL,
|
||||||
|
buildDinsarEngineOptions,
|
||||||
|
getDinsarEngineMeta,
|
||||||
|
} from '../utils/dinsarEngines';
|
||||||
|
|
||||||
const panelCardStyle = {
|
const STATUS_TONE_MAP = {
|
||||||
background: '#fff',
|
READY: 'ready',
|
||||||
padding: '12px',
|
PARTIAL: 'warn',
|
||||||
borderRadius: '8px',
|
QUARANTINED: 'error',
|
||||||
border: '1px solid #e2e8f0',
|
WARN: 'warn',
|
||||||
};
|
ERROR: 'error',
|
||||||
|
REBUILDING: 'info',
|
||||||
const statusColorMap = {
|
|
||||||
READY: '#16a34a',
|
|
||||||
PARTIAL: '#b45309',
|
|
||||||
QUARANTINED: '#dc2626',
|
|
||||||
WARN: '#b45309',
|
|
||||||
ERROR: '#dc2626',
|
|
||||||
REBUILDING: '#2563eb',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
@@ -38,37 +36,26 @@ function parseDirectoryList(value) {
|
|||||||
return [...new Set(
|
return [...new Set(
|
||||||
String(value || '')
|
String(value || '')
|
||||||
.split(/[\r\n,;]+/)
|
.split(/[\r\n,;]+/)
|
||||||
.map(item => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)];
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPill({ label, color }) {
|
function getMessageTone(message) {
|
||||||
|
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ label, tone = 'neutral' }) {
|
||||||
|
return <span className={`dinsar-status-pill tone-${tone}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaField({ label, value, multiline = false }) {
|
||||||
|
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
|
||||||
return (
|
return (
|
||||||
<span
|
<div className="dinsar-catalog-meta-field">
|
||||||
style={{
|
<span>{label}</span>
|
||||||
display: 'inline-flex',
|
<strong className={multiline ? 'break-all' : ''}>{displayValue}</strong>
|
||||||
alignItems: 'center',
|
</div>
|
||||||
gap: 6,
|
|
||||||
padding: '2px 10px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: `${color}14`,
|
|
||||||
color,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: color,
|
|
||||||
display: 'inline-block',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +75,16 @@ export default function DinsarCatalogPanel({
|
|||||||
const [actionMessage, setActionMessage] = useState('');
|
const [actionMessage, setActionMessage] = useState('');
|
||||||
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
|
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
|
||||||
const [publishRoot, setPublishRoot] = useState('');
|
const [publishRoot, setPublishRoot] = useState('');
|
||||||
|
const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL);
|
||||||
|
const [queryDraft, setQueryDraft] = useState('');
|
||||||
|
const [queryApplied, setQueryApplied] = useState('');
|
||||||
|
|
||||||
const listLimit = compact ? 6 : 12;
|
const listLimit = compact ? 8 : 24;
|
||||||
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
|
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSourceDir) return;
|
if (!initialSourceDir) return;
|
||||||
setSourceDirectoriesText(current => (current.trim() ? current : initialSourceDir));
|
setSourceDirectoriesText((current) => (current.trim() ? current : initialSourceDir));
|
||||||
}, [initialSourceDir]);
|
}, [initialSourceDir]);
|
||||||
|
|
||||||
const sourceDirectories = useMemo(
|
const sourceDirectories = useMemo(
|
||||||
@@ -102,18 +92,39 @@ export default function DinsarCatalogPanel({
|
|||||||
[sourceDirectoriesText]
|
[sourceDirectoriesText]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const engineOptions = useMemo(
|
||||||
|
() => buildDinsarEngineOptions(products, { includeKnown: true }),
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
const selectedEngineMeta = useMemo(
|
||||||
|
() => (engineFilter === DINSAR_ENGINE_ALL ? null : getDinsarEngineMeta(engineFilter)),
|
||||||
|
[engineFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (engineFilter === DINSAR_ENGINE_ALL) return;
|
||||||
|
if (!engineOptions.some((option) => option.value === engineFilter)) {
|
||||||
|
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||||
|
}
|
||||||
|
}, [engineFilter, engineOptions]);
|
||||||
|
|
||||||
const loadCatalog = useCallback(async () => {
|
const loadCatalog = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statusData, productData] = await Promise.all([
|
const [statusData, productData] = await Promise.all([
|
||||||
getDinsarCatalogStatus(),
|
getDinsarCatalogStatus(),
|
||||||
listDinsarProducts({ limit: listLimit, offset: 0 }),
|
listDinsarProducts({
|
||||||
|
limit: listLimit,
|
||||||
|
offset: 0,
|
||||||
|
engine_code: engineFilter === DINSAR_ENGINE_ALL ? undefined : engineFilter,
|
||||||
|
query: queryApplied || undefined,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
setCatalogStatus(statusData);
|
setCatalogStatus(statusData);
|
||||||
const nextItems = Array.isArray(productData?.items) ? productData.items : [];
|
const nextItems = Array.isArray(productData?.items) ? productData.items : [];
|
||||||
setProducts(nextItems);
|
setProducts(nextItems);
|
||||||
setSelectedProductId(current => {
|
setSelectedProductId((current) => {
|
||||||
if (current && nextItems.some(item => item.id === current)) {
|
if (current && nextItems.some((item) => item.id === current)) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
return nextItems[0]?.id ?? null;
|
return nextItems[0]?.id ?? null;
|
||||||
@@ -126,7 +137,7 @@ export default function DinsarCatalogPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [listLimit]);
|
}, [engineFilter, listLimit, queryApplied]);
|
||||||
|
|
||||||
const loadProductDetail = useCallback(async (productId) => {
|
const loadProductDetail = useCallback(async (productId) => {
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
@@ -155,6 +166,16 @@ export default function DinsarCatalogPanel({
|
|||||||
loadProductDetail(selectedProductId);
|
loadProductDetail(selectedProductId);
|
||||||
}, [loadProductDetail, selectedProductId]);
|
}, [loadProductDetail, selectedProductId]);
|
||||||
|
|
||||||
|
const handleApplyFilters = useCallback(() => {
|
||||||
|
setQueryApplied(queryDraft.trim());
|
||||||
|
}, [queryDraft]);
|
||||||
|
|
||||||
|
const handleResetFilters = useCallback(() => {
|
||||||
|
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||||
|
setQueryDraft('');
|
||||||
|
setQueryApplied('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleQueuePublish = async () => {
|
const handleQueuePublish = async () => {
|
||||||
if (readOnly || sourceDirectories.length === 0) return;
|
if (readOnly || sourceDirectories.length === 0) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -194,7 +215,8 @@ export default function DinsarCatalogPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const catalogColor = statusColorMap[catalogStatus?.status] || '#64748b';
|
const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral';
|
||||||
|
const actionTone = getMessageTone(actionMessage);
|
||||||
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
|
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
|
||||||
const selectedAssets = Array.isArray(selectedProduct?.assets) ? selectedProduct.assets : [];
|
const selectedAssets = Array.isArray(selectedProduct?.assets) ? selectedProduct.assets : [];
|
||||||
const selectedPairingTrace = selectedProduct?.pairing_trace || null;
|
const selectedPairingTrace = selectedProduct?.pairing_trace || null;
|
||||||
@@ -202,191 +224,190 @@ export default function DinsarCatalogPanel({
|
|||||||
const selectedPairingRun = selectedPairingNetwork?.run || null;
|
const selectedPairingRun = selectedPairingNetwork?.run || null;
|
||||||
const selectedPairingEdge = selectedPairingNetwork?.edge || null;
|
const selectedPairingEdge = selectedPairingNetwork?.edge || null;
|
||||||
const selectedPairingMetric = selectedPairingNetwork?.metric || null;
|
const selectedPairingMetric = selectedPairingNetwork?.metric || null;
|
||||||
|
const selectedProductEngine = getDinsarEngineMeta(selectedProduct?.engine_code);
|
||||||
|
const selectedStatusTone = STATUS_TONE_MAP[selectedProduct?.status] || 'neutral';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={panelCardStyle}>
|
<div className={`dinsar-catalog-shell ${compact ? 'compact' : ''}`}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
<div className="dinsar-catalog-header">
|
||||||
<div>
|
<div className="dinsar-catalog-header-copy">
|
||||||
<strong style={{ fontSize: 14 }}>{compact ? '结果目录状态' : '标准结果包目录'}</strong>
|
<strong>{compact ? '结果目录状态' : '标准结果包目录'}</strong>
|
||||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
|
<p>
|
||||||
{compact ? '展示结果包目录与数据库索引状态' : '结果文件以结果包目录为真源,数据库仅保存索引与检索信息'}
|
{compact
|
||||||
</div>
|
? '查看结果包目录与数据库索引是否一致。'
|
||||||
|
: '统一结果目录按 engine + pair + run 管理,便于同一对影像保留多套生产结果并行对比。'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="dinsar-catalog-header-actions">
|
||||||
onClick={loadCatalog}
|
{selectedEngineMeta && (
|
||||||
disabled={loading || actionLoading}
|
<span className={`dinsar-engine-badge tone-${selectedEngineMeta.tone}`}>
|
||||||
style={{
|
{selectedEngineMeta.shortLabel}
|
||||||
fontSize: 12,
|
</span>
|
||||||
padding: '4px 10px',
|
)}
|
||||||
borderRadius: 4,
|
<button onClick={loadCatalog} disabled={loading || actionLoading}>
|
||||||
border: '1px solid #e2e8f0',
|
{loading ? '刷新中...' : '刷新'}
|
||||||
background: '#f8fafc',
|
</button>
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? '刷新中...' : '刷新'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: compact ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: 8, marginBottom: 10 }}>
|
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
|
||||||
<div style={{ fontSize: 11, color: '#64748b' }}>目录状态</div>
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
<StatusPill label={catalogStatus?.status || '未知'} color={catalogColor} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
|
||||||
<div style={{ fontSize: 11, color: '#64748b' }}>需要重建</div>
|
|
||||||
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700, color: catalogStatus?.needs_rebuild ? '#dc2626' : '#16a34a' }}>
|
|
||||||
{catalogStatus?.needs_rebuild ? '是' : '否'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
|
||||||
<div style={{ fontSize: 11, color: '#64748b' }}>Manifest / 数据库</div>
|
|
||||||
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700 }}>
|
|
||||||
{(catalogStatus?.manifest_count ?? 0)} / {(catalogStatus?.db_count ?? 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
|
||||||
<div style={{ fontSize: 11, color: '#64748b' }}>问题数量</div>
|
|
||||||
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700, color: (catalogStatus?.issue_count ?? 0) > 0 ? '#b45309' : '#16a34a' }}>
|
|
||||||
{catalogStatus?.issue_count ?? 0}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8, wordBreak: 'break-all' }}>
|
<div className="dinsar-catalog-summary">
|
||||||
|
<div className="dinsar-catalog-stat-card">
|
||||||
|
<span>目录状态</span>
|
||||||
|
<strong>{catalogStatus?.status || '未知'}</strong>
|
||||||
|
<StatusPill label={catalogStatus?.status || 'UNKNOWN'} tone={catalogTone} />
|
||||||
|
</div>
|
||||||
|
<div className="dinsar-catalog-stat-card">
|
||||||
|
<span>需要重建</span>
|
||||||
|
<strong>{catalogStatus?.needs_rebuild ? '是' : '否'}</strong>
|
||||||
|
<small>{catalogStatus?.needs_rebuild ? 'Manifest 与数据库存在漂移' : '目录登记正常'}</small>
|
||||||
|
</div>
|
||||||
|
<div className="dinsar-catalog-stat-card">
|
||||||
|
<span>Manifest / 数据库</span>
|
||||||
|
<strong>{catalogStatus?.manifest_count ?? 0} / {catalogStatus?.db_count ?? 0}</strong>
|
||||||
|
<small>已登记结果包总量</small>
|
||||||
|
</div>
|
||||||
|
<div className="dinsar-catalog-stat-card">
|
||||||
|
<span>问题数量</span>
|
||||||
|
<strong>{catalogStatus?.issue_count ?? 0}</strong>
|
||||||
|
<small>含缺失文件与健康异常</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dinsar-catalog-meta-strip">
|
||||||
<div><strong>结果包根目录:</strong>{catalogStatus?.storage_root || '-'}</div>
|
<div><strong>结果包根目录:</strong>{catalogStatus?.storage_root || '-'}</div>
|
||||||
<div><strong>最近消息:</strong>{catalogStatus?.last_message || '-'}</div>
|
<div><strong>最近消息:</strong>{catalogStatus?.last_message || '-'}</div>
|
||||||
<div><strong>最近重建:</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
|
<div><strong>最近全量重建:</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{compact && actionMessage && (
|
{actionMessage && (
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: actionMessage.includes('失败') ? '#dc2626' : '#166534' }}>
|
<div className={`dinsar-catalog-message tone-${actionTone}`}>
|
||||||
{actionMessage}
|
{actionMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div style={{ marginBottom: 12, padding: '10px 12px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc' }}>
|
<div className="dinsar-catalog-manage">
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#0f172a', marginBottom: 8 }}>手动发布与重建</div>
|
<div className="dinsar-catalog-manage-copy">
|
||||||
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8 }}>
|
<strong>手动发布与目录重建</strong>
|
||||||
旧的“提取位移结果”入口已经会自动尝试发布标准结果包。这里保留显式入口,方便你对任意目录重新发布和重建索引。
|
<p>
|
||||||
|
这里用于把既有结果目录重新发布为标准结果包,并按最新规则重建目录索引。
|
||||||
|
如果同一对影像存在 ENVI 与 ISCE2 两套结果,它们会依赖 `engine_code` 与 `run_key` 分别登记,不会互相覆盖。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<div className="dinsar-catalog-manage-form">
|
||||||
value={sourceDirectoriesText}
|
<textarea
|
||||||
onChange={event => setSourceDirectoriesText(event.target.value)}
|
value={sourceDirectoriesText}
|
||||||
placeholder="输入一个或多个结果根目录,支持换行、逗号或分号分隔"
|
onChange={(event) => setSourceDirectoriesText(event.target.value)}
|
||||||
disabled={readOnly || actionLoading}
|
placeholder="输入一个或多个结果源目录,支持换行、逗号或分号分隔"
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: 72,
|
|
||||||
resize: 'vertical',
|
|
||||||
padding: '8px 10px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid #cbd5e1',
|
|
||||||
fontSize: 12,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={publishRoot}
|
|
||||||
onChange={event => setPublishRoot(event.target.value)}
|
|
||||||
placeholder="可选:自定义结果包根目录,留空使用系统默认目录"
|
|
||||||
disabled={readOnly || actionLoading}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '6px 10px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid #cbd5e1',
|
|
||||||
fontSize: 12,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
onClick={handleQueuePublish}
|
|
||||||
disabled={readOnly || actionLoading || sourceDirectories.length === 0}
|
|
||||||
style={{
|
|
||||||
padding: '6px 14px',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: 'none',
|
|
||||||
background: '#2563eb',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{actionLoading ? '处理中...' : '发布结果包并重建'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleQueueRebuild}
|
|
||||||
disabled={readOnly || actionLoading}
|
disabled={readOnly || actionLoading}
|
||||||
style={{
|
/>
|
||||||
padding: '6px 14px',
|
<input
|
||||||
borderRadius: 6,
|
value={publishRoot}
|
||||||
border: '1px solid #cbd5e1',
|
onChange={(event) => setPublishRoot(event.target.value)}
|
||||||
background: '#fff',
|
placeholder="可选:自定义标准结果包根目录,留空使用系统配置"
|
||||||
color: '#0f172a',
|
disabled={readOnly || actionLoading}
|
||||||
cursor: 'pointer',
|
/>
|
||||||
fontSize: 12,
|
<div className="dinsar-catalog-manage-actions">
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
仅重建目录索引
|
className="primary"
|
||||||
</button>
|
onClick={handleQueuePublish}
|
||||||
</div>
|
disabled={readOnly || actionLoading || sourceDirectories.length === 0}
|
||||||
{actionMessage && (
|
>
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: actionMessage.includes('失败') ? '#dc2626' : '#166534' }}>
|
{actionLoading ? '处理中...' : '发布结果包并重建'}
|
||||||
{actionMessage}
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleQueueRebuild}
|
||||||
|
disabled={readOnly || actionLoading}
|
||||||
|
>
|
||||||
|
仅重建目录
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'minmax(260px, 360px) 1fr', gap: 12 }}>
|
<div className={`dinsar-catalog-workspace ${compact ? 'compact' : ''}`}>
|
||||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
|
<aside className="dinsar-catalog-list-card">
|
||||||
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
|
<div className="dinsar-catalog-card-head">
|
||||||
最新结果包 ({products.length})
|
<div>
|
||||||
|
<strong>结果包列表</strong>
|
||||||
|
<span>
|
||||||
|
{loading ? '加载中...' : `当前展示 ${products.length} 条`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{queryApplied && <StatusPill label={`检索: ${queryApplied}`} tone="info" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="dinsar-catalog-filter-bar">
|
||||||
|
<label className="dinsar-catalog-filter-field">
|
||||||
|
<span>生产引擎</span>
|
||||||
|
<select value={engineFilter} onChange={(event) => setEngineFilter(event.target.value)}>
|
||||||
|
<option value={DINSAR_ENGINE_ALL}>全部引擎</option>
|
||||||
|
{engineOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="dinsar-catalog-filter-field search">
|
||||||
|
<span>检索</span>
|
||||||
|
<input
|
||||||
|
value={queryDraft}
|
||||||
|
onChange={(event) => setQueryDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleApplyFilters();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="搜索任务名 / pair / run / 引擎"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="dinsar-catalog-filter-actions">
|
||||||
|
<button type="button" onClick={handleApplyFilters}>查询</button>
|
||||||
|
<button type="button" onClick={handleResetFilters}>重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>
|
<div className="dinsar-catalog-empty">
|
||||||
{loading ? '正在加载结果包...' : '当前没有已注册的结果包。'}
|
{loading ? '正在加载结果包...' : '当前筛选条件下没有结果包。'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: compact ? 280 : 360, overflowY: 'auto' }}>
|
<div className="dinsar-catalog-list">
|
||||||
{products.map(item => {
|
{products.map((item) => {
|
||||||
const color = statusColorMap[item.status] || '#64748b';
|
const tone = STATUS_TONE_MAP[item.status] || 'neutral';
|
||||||
|
const engineMeta = getDinsarEngineMeta(item.engine_code);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`dinsar-catalog-list-item ${selectedProductId === item.id ? 'active' : ''}`}
|
||||||
onClick={() => setSelectedProductId(item.id)}
|
onClick={() => setSelectedProductId(item.id)}
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'left',
|
|
||||||
border: 'none',
|
|
||||||
borderTop: '1px solid #f1f5f9',
|
|
||||||
background: selectedProductId === item.id ? '#eff6ff' : '#fff',
|
|
||||||
padding: '10px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
|
<div className="dinsar-catalog-list-item-top">
|
||||||
<strong style={{ fontSize: 12, color: '#0f172a', wordBreak: 'break-all' }}>{item.display_name || item.product_id}</strong>
|
<strong>{item.display_name || item.product_id}</strong>
|
||||||
<span style={{ fontSize: 11, color }}>{item.status}</span>
|
<StatusPill label={item.status || 'UNKNOWN'} tone={tone} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#64748b' }}>
|
<div className="dinsar-catalog-list-item-badges">
|
||||||
{item.engine_code || '-'} · {formatDateTime(item.published_at)}
|
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
|
||||||
|
<span>{formatDateTime(item.published_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2, wordBreak: 'break-all' }}>
|
<div className="dinsar-catalog-list-item-meta">
|
||||||
{(item.task_alias || item.task_name || '-')}{item.run_key ? ` / ${item.run_key}` : ''}
|
{(item.task_alias || item.task_name || '-')}{item.run_key ? ` / ${item.run_key}` : ''}
|
||||||
</div>
|
</div>
|
||||||
{(item.selection_strategy || item.network_run_id || item.network_edge_id) && (
|
<div className="dinsar-catalog-list-item-meta">
|
||||||
<div style={{ fontSize: 11, color: '#475569', marginTop: 2, wordBreak: 'break-all' }}>
|
{item.pair_key || '-'}
|
||||||
|
</div>
|
||||||
|
{(item.selection_strategy || item.network_run_id || item.network_edge_id != null) && (
|
||||||
|
<div className="dinsar-catalog-list-item-trace">
|
||||||
{(item.selection_strategy || 'trace')}
|
{(item.selection_strategy || 'trace')}
|
||||||
{item.network_edge_id ? ` / edge ${item.network_edge_id}` : ''}
|
{item.network_edge_id != null ? ` / edge ${item.network_edge_id}` : ''}
|
||||||
{item.network_run_id ? ` / ${item.network_run_id}` : ''}
|
{item.network_run_id ? ` / ${item.network_run_id}` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -395,167 +416,165 @@ export default function DinsarCatalogPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
|
<section className="dinsar-catalog-detail-card">
|
||||||
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
|
<div className="dinsar-catalog-card-head">
|
||||||
结果包详情
|
<div>
|
||||||
|
<strong>结果包详情</strong>
|
||||||
|
<span>查看选中结果的发布信息、配对溯源与资产健康</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedProductId ? (
|
{!selectedProductId ? (
|
||||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>请选择一个结果包查看详情。</div>
|
<div className="dinsar-catalog-empty">请选择一个结果包查看详情。</div>
|
||||||
) : detailLoading || !selectedProduct ? (
|
) : detailLoading || !selectedProduct ? (
|
||||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>正在加载详情...</div>
|
<div className="dinsar-catalog-empty">正在加载详情...</div>
|
||||||
) : selectedProduct?.error ? (
|
) : selectedProduct?.error ? (
|
||||||
<div style={{ padding: '12px', fontSize: 12, color: '#dc2626' }}>{selectedProduct.error}</div>
|
<div className="dinsar-catalog-empty error">{selectedProduct.error}</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div className="dinsar-catalog-detail-body">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : '180px 1fr', gap: 12 }}>
|
<div className="dinsar-catalog-hero">
|
||||||
<div>
|
<div className="dinsar-catalog-preview-frame">
|
||||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden', background: '#f8fafc' }}>
|
<img
|
||||||
<img
|
src={`${previewBaseUrl}/dinsar-products/${selectedProduct.id}/preview`}
|
||||||
src={`${previewBaseUrl}/dinsar-products/${selectedProduct.id}/preview`}
|
alt={selectedProduct.display_name || selectedProduct.product_id}
|
||||||
alt={selectedProduct.display_name}
|
/>
|
||||||
style={{ display: 'block', width: '100%', minHeight: 120, objectFit: 'cover', background: '#e2e8f0' }}
|
</div>
|
||||||
/>
|
|
||||||
|
<div className="dinsar-catalog-hero-meta">
|
||||||
|
<div className="dinsar-catalog-hero-title-row">
|
||||||
|
<div>
|
||||||
|
<h4>{selectedProduct.display_name || selectedProduct.product_id}</h4>
|
||||||
|
<p>{selectedProduct.task_alias || selectedProduct.task_name || '未命名任务'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="dinsar-catalog-hero-badges">
|
||||||
|
<span className={`dinsar-engine-badge tone-${selectedProductEngine.tone}`}>
|
||||||
|
{selectedProductEngine.shortLabel}
|
||||||
|
</span>
|
||||||
|
<StatusPill label={selectedProduct.status || 'UNKNOWN'} tone={selectedStatusTone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dinsar-catalog-kv-grid">
|
||||||
|
<MetaField label="产品编号" value={selectedProduct.product_id} multiline />
|
||||||
|
<MetaField label="配对标识" value={selectedProduct.pair_key} multiline />
|
||||||
|
<MetaField label="场景配对 UID" value={selectedProduct.pair_uid} multiline />
|
||||||
|
<MetaField label="运行标识" value={selectedProduct.run_key} multiline />
|
||||||
|
<MetaField label="生产配置" value={selectedProduct.profile_code} />
|
||||||
|
<MetaField label="健康状态" value={selectedProduct.health_status} />
|
||||||
|
<MetaField label="主文件" value={selectedProduct.primary_asset_path} multiline />
|
||||||
|
<MetaField label="源文件" value={selectedProduct.source_primary_path} multiline />
|
||||||
|
<MetaField label="结果包目录" value={selectedProduct.publish_dir} multiline />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, color: '#334155' }}>
|
</div>
|
||||||
<div><strong>名称:</strong>{selectedProduct.display_name || '-'}</div>
|
|
||||||
<div><strong>产品编号:</strong>{selectedProduct.product_id || '-'}</div>
|
<div className="dinsar-catalog-detail-grid">
|
||||||
<div><strong>任务别名:</strong>{selectedProduct.task_alias || selectedProduct.task_name || '-'}</div>
|
<div className="dinsar-catalog-section-card">
|
||||||
<div><strong>配对标识:</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.pair_key || '-'}</span></div>
|
<div className="dinsar-catalog-section-title">时空概览</div>
|
||||||
<div><strong>场景对 UID:</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.pair_uid || '-'}</span></div>
|
<MetaField label="主影像日期" value={selectedProduct.profile?.master_imaging_date} />
|
||||||
<div><strong>运行标识:</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.run_key || '-'}</span></div>
|
<MetaField label="从影像日期" value={selectedProduct.profile?.slave_imaging_date} />
|
||||||
<div><strong>生产配置:</strong>{selectedProduct.profile_code || '-'}</div>
|
<MetaField label="时间基线" value={selectedProduct.profile?.time_baseline_days} />
|
||||||
<div><strong>引擎:</strong>{selectedProduct.engine_code || '-'}</div>
|
<MetaField label="空间基线" value={selectedProduct.profile?.spatial_baseline_meters} />
|
||||||
<div><strong>状态:</strong>{selectedProduct.status || '-'} / {selectedProduct.health_status || '-'}</div>
|
</div>
|
||||||
<div><strong>主文件:</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.primary_asset_path || '-'}</span></div>
|
<div className="dinsar-catalog-section-card">
|
||||||
<div><strong>来源文件:</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.source_primary_path || '-'}</span></div>
|
<div className="dinsar-catalog-section-title">空间范围</div>
|
||||||
<div><strong>结果包目录:</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.publish_dir || '-'}</span></div>
|
<MetaField label="最小坐标" value={`${selectedProduct.min_lon ?? '-'}, ${selectedProduct.min_lat ?? '-'}`} />
|
||||||
|
<MetaField label="最大坐标" value={`${selectedProduct.max_lon ?? '-'}, ${selectedProduct.max_lat ?? '-'}`} />
|
||||||
|
<MetaField label="登记时间" value={formatDateTime(selectedProduct.registered_at)} />
|
||||||
|
<MetaField label="发布时间" value={formatDateTime(selectedProduct.published_at)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
|
<div className="dinsar-catalog-section-card">
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', fontSize: 12 }}>
|
<div className="dinsar-catalog-section-title">配对追踪</div>
|
||||||
<div><strong>主影像日期:</strong>{selectedProduct.profile?.master_imaging_date || '-'}</div>
|
|
||||||
<div><strong>辅影像日期:</strong>{selectedProduct.profile?.slave_imaging_date || '-'}</div>
|
|
||||||
<div><strong>时间基线:</strong>{selectedProduct.profile?.time_baseline_days ?? '-'}</div>
|
|
||||||
<div><strong>空间基线:</strong>{selectedProduct.profile?.spatial_baseline_meters ?? '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', fontSize: 12 }}>
|
|
||||||
<div><strong>BBox:</strong></div>
|
|
||||||
<div>{selectedProduct.min_lon ?? '-'}, {selectedProduct.min_lat ?? '-'}</div>
|
|
||||||
<div>{selectedProduct.max_lon ?? '-'}, {selectedProduct.max_lat ?? '-'}</div>
|
|
||||||
<div style={{ marginTop: 4 }}><strong>注册时间:</strong>{formatDateTime(selectedProduct.registered_at)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 12 }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>配对追踪</div>
|
|
||||||
{!selectedPairingTrace?.network_run_id ? (
|
{!selectedPairingTrace?.network_run_id ? (
|
||||||
<div style={{ color: '#94a3b8' }}>当前结果未携带配对网络追踪信息。</div>
|
<div className="dinsar-catalog-empty inline">当前结果未携带完整的配对网络追踪信息。</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div className="dinsar-catalog-detail-grid">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
|
<div className="dinsar-catalog-section-card nested">
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
<MetaField label="network_run_id" value={selectedPairingTrace.network_run_id} multiline />
|
||||||
<div><strong>network_run_id:</strong><span style={{ wordBreak: 'break-all' }}>{selectedPairingTrace.network_run_id || '-'}</span></div>
|
<MetaField label="network_edge_id" value={selectedPairingTrace.network_edge_id} />
|
||||||
<div><strong>network_edge_id:</strong>{selectedPairingTrace.network_edge_id ?? '-'}</div>
|
<MetaField label="pair_uid" value={selectedPairingTrace.pair_uid} multiline />
|
||||||
<div><strong>pair_uid:</strong><span style={{ wordBreak: 'break-all' }}>{selectedPairingTrace.pair_uid || '-'}</span></div>
|
<MetaField label="选择策略" value={selectedPairingTrace.selection_strategy} />
|
||||||
<div><strong>策略:</strong>{selectedPairingTrace.selection_strategy || '-'}</div>
|
<MetaField label="策略版本" value={selectedPairingTrace.policy_version} />
|
||||||
<div><strong>策略版本:</strong>{selectedPairingTrace.policy_version || '-'}</div>
|
</div>
|
||||||
</div>
|
<div className="dinsar-catalog-section-card nested">
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
<MetaField label="网络记录" value={selectedPairingNetwork?.run_found ? '已找到' : '未找到'} />
|
||||||
<div><strong>网络记录:</strong>{selectedPairingNetwork?.run_found ? '已找到' : '未找到'}</div>
|
<MetaField label="边记录" value={selectedPairingNetwork?.edge_found ? '已找到' : '未找到'} />
|
||||||
<div><strong>边记录:</strong>{selectedPairingNetwork?.edge_found ? '已找到' : '未找到'}</div>
|
<MetaField label="运行状态" value={selectedPairingRun?.status} />
|
||||||
<div><strong>运行状态:</strong>{selectedPairingRun?.status || '-'}</div>
|
<MetaField label="候选边数" value={selectedPairingRun?.candidate_count} />
|
||||||
<div><strong>候选边数:</strong>{selectedPairingRun?.candidate_count ?? '-'}</div>
|
<MetaField label="入选边数" value={selectedPairingRun?.selected_edge_count} />
|
||||||
<div><strong>入选边数:</strong>{selectedPairingRun?.selected_edge_count ?? '-'}</div>
|
<MetaField label="告警数" value={selectedPairingRun?.warning_count} />
|
||||||
<div><strong>告警数:</strong>{selectedPairingRun?.warning_count ?? '-'}</div>
|
</div>
|
||||||
</div>
|
<div className="dinsar-catalog-section-card nested">
|
||||||
|
<MetaField label="edge_rank" value={selectedPairingEdge?.edge_rank} />
|
||||||
|
<MetaField label="selection_reason" value={selectedPairingEdge?.selection_reason} multiline />
|
||||||
|
<MetaField label="selection_score" value={selectedPairingEdge?.selection_score} />
|
||||||
|
<MetaField label="reference_edge" value={selectedPairingEdge?.is_reference_edge ? '是' : '否'} />
|
||||||
|
<MetaField label="metric_cache_ref_id" value={selectedPairingEdge?.metric_cache_ref_id} />
|
||||||
|
</div>
|
||||||
|
<div className="dinsar-catalog-section-card nested">
|
||||||
|
<MetaField label="主从日期" value={`${selectedPairingMetric?.master_imaging_date || '-'} / ${selectedPairingMetric?.slave_imaging_date || '-'}`} />
|
||||||
|
<MetaField label="主从卫星" value={`${selectedPairingMetric?.master_satellite || '-'} / ${selectedPairingMetric?.slave_satellite || '-'}`} />
|
||||||
|
<MetaField label="主从模式" value={`${selectedPairingMetric?.master_imaging_mode || '-'} / ${selectedPairingMetric?.slave_imaging_mode || '-'}`} />
|
||||||
|
<MetaField label="主从极化" value={`${selectedPairingMetric?.master_polarization || '-'} / ${selectedPairingMetric?.slave_polarization || '-'}`} />
|
||||||
|
<MetaField label="时间基线" value={selectedPairingMetric?.time_baseline_days} />
|
||||||
|
<MetaField label="空间基线" value={selectedPairingMetric?.spatial_baseline_meters} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(selectedPairingEdge || selectedPairingMetric) && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
|
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>网络边</div>
|
|
||||||
<div><strong>edge_rank:</strong>{selectedPairingEdge?.edge_rank ?? '-'}</div>
|
|
||||||
<div><strong>selection_reason:</strong>{selectedPairingEdge?.selection_reason || '-'}</div>
|
|
||||||
<div><strong>selection_score:</strong>{selectedPairingEdge?.selection_score ?? '-'}</div>
|
|
||||||
<div><strong>reference_edge:</strong>{selectedPairingEdge?.is_reference_edge ? '是' : '否'}</div>
|
|
||||||
<div><strong>metric_cache_ref_id:</strong>{selectedPairingEdge?.metric_cache_ref_id ?? '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>度量快照</div>
|
|
||||||
<div><strong>主从日期:</strong>{selectedPairingMetric?.master_imaging_date || '-'} / {selectedPairingMetric?.slave_imaging_date || '-'}</div>
|
|
||||||
<div><strong>主从卫星:</strong>{selectedPairingMetric?.master_satellite || '-'} / {selectedPairingMetric?.slave_satellite || '-'}</div>
|
|
||||||
<div><strong>主从模式:</strong>{selectedPairingMetric?.master_imaging_mode || '-'} / {selectedPairingMetric?.slave_imaging_mode || '-'}</div>
|
|
||||||
<div><strong>主从极化:</strong>{selectedPairingMetric?.master_polarization || '-'} / {selectedPairingMetric?.slave_polarization || '-'}</div>
|
|
||||||
<div><strong>时间基线:</strong>{selectedPairingMetric?.time_baseline_days ?? '-'}</div>
|
|
||||||
<div><strong>空间基线:</strong>{selectedPairingMetric?.spatial_baseline_meters ?? '-'}</div>
|
|
||||||
<div><strong>重叠率:</strong>{selectedPairingMetric?.scene_overlap_ratio ?? '-'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 12 }}>
|
<div className="dinsar-catalog-detail-grid">
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>资产列表 ({selectedAssets.length})</div>
|
<div className="dinsar-catalog-section-card">
|
||||||
{selectedAssets.length === 0 ? (
|
<div className="dinsar-catalog-section-title">资产列表 ({selectedAssets.length})</div>
|
||||||
<div style={{ color: '#94a3b8' }}>暂无资产记录。</div>
|
{selectedAssets.length === 0 ? (
|
||||||
) : (
|
<div className="dinsar-catalog-empty inline">暂无资产记录。</div>
|
||||||
selectedAssets.map(asset => (
|
) : (
|
||||||
<div
|
<div className="dinsar-catalog-asset-list">
|
||||||
key={asset.id}
|
{selectedAssets.map((asset) => (
|
||||||
style={{
|
<div key={asset.id} className={`dinsar-catalog-asset-item ${asset.exists_flag ? 'ok' : 'missing'}`}>
|
||||||
padding: '6px 8px',
|
<div className="dinsar-catalog-asset-top">
|
||||||
borderRadius: 6,
|
<strong>{asset.asset_role}</strong>
|
||||||
background: '#f8fafc',
|
<span>{asset.exists_flag ? '文件存在' : '文件缺失'}</span>
|
||||||
marginBottom: 6,
|
</div>
|
||||||
color: '#334155',
|
<div>{asset.asset_name}</div>
|
||||||
}}
|
<div className="break-all">{asset.absolute_path}</div>
|
||||||
>
|
|
||||||
<div><strong>{asset.asset_role}</strong> · {asset.asset_name}</div>
|
|
||||||
<div style={{ color: asset.exists_flag ? '#166534' : '#dc2626' }}>
|
|
||||||
{asset.exists_flag ? '文件存在' : '文件缺失'}
|
|
||||||
</div>
|
|
||||||
<div style={{ wordBreak: 'break-all', color: '#64748b' }}>{asset.absolute_path}</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 12 }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>问题列表 ({selectedIssues.length})</div>
|
|
||||||
{selectedIssues.length === 0 ? (
|
|
||||||
<div style={{ color: '#16a34a' }}>当前没有登记问题。</div>
|
|
||||||
) : (
|
|
||||||
selectedIssues.map(issue => (
|
|
||||||
<div
|
|
||||||
key={issue.id}
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
borderRadius: 6,
|
|
||||||
background: issue.severity === 'ERROR' ? '#fef2f2' : '#fff7ed',
|
|
||||||
color: issue.severity === 'ERROR' ? '#991b1b' : '#9a3412',
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div><strong>{issue.issue_code}</strong> · {issue.severity}</div>
|
|
||||||
<div>{issue.message}</div>
|
|
||||||
{issue.repair_action && (
|
|
||||||
<div style={{ color: '#64748b', marginTop: 2 }}>
|
|
||||||
建议修复动作:{issue.repair_action}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="dinsar-catalog-section-card">
|
||||||
|
<div className="dinsar-catalog-section-title">问题列表 ({selectedIssues.length})</div>
|
||||||
|
{selectedIssues.length === 0 ? (
|
||||||
|
<div className="dinsar-catalog-empty inline ok">当前没有登记问题。</div>
|
||||||
|
) : (
|
||||||
|
<div className="dinsar-catalog-issue-list">
|
||||||
|
{selectedIssues.map((issue) => (
|
||||||
|
<div key={issue.id} className={`dinsar-catalog-issue-item ${String(issue.severity || '').toUpperCase() === 'ERROR' ? 'error' : 'warn'}`}>
|
||||||
|
<div className="dinsar-catalog-issue-top">
|
||||||
|
<strong>{issue.issue_code}</strong>
|
||||||
|
<span>{issue.severity}</span>
|
||||||
|
</div>
|
||||||
|
<div>{issue.message}</div>
|
||||||
|
{issue.repair_action && (
|
||||||
|
<div className="dinsar-catalog-issue-action">
|
||||||
|
建议修复动作:{issue.repair_action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,581 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import apiClient from '../api/client';
|
|
||||||
import {
|
|
||||||
getDinsarCatalogStatus,
|
|
||||||
getDinsarProductDetail,
|
|
||||||
listDinsarProducts,
|
|
||||||
queueDinsarCatalogRebuild,
|
|
||||||
queueDinsarProductPublish,
|
|
||||||
} from '../api/dinsarProducts';
|
|
||||||
import {
|
|
||||||
DINSAR_ENGINE_ALL,
|
|
||||||
buildDinsarEngineOptions,
|
|
||||||
getDinsarEngineMeta,
|
|
||||||
} from '../utils/dinsarEngines';
|
|
||||||
|
|
||||||
const STATUS_TONE_MAP = {
|
|
||||||
READY: 'ready',
|
|
||||||
PARTIAL: 'warn',
|
|
||||||
QUARANTINED: 'error',
|
|
||||||
WARN: 'warn',
|
|
||||||
ERROR: 'error',
|
|
||||||
REBUILDING: 'info',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDateTime(value) {
|
|
||||||
if (!value) return '-';
|
|
||||||
try {
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDirectoryList(value) {
|
|
||||||
return [...new Set(
|
|
||||||
String(value || '')
|
|
||||||
.split(/[\r\n,;]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageTone(message) {
|
|
||||||
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ label, tone = 'neutral' }) {
|
|
||||||
return <span className={`dinsar-status-pill tone-${tone}`}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaField({ label, value, multiline = false }) {
|
|
||||||
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
|
|
||||||
return (
|
|
||||||
<div className="dinsar-catalog-meta-field">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong className={multiline ? 'break-all' : ''}>{displayValue}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DinsarCatalogPanel({
|
|
||||||
readOnly = false,
|
|
||||||
compact = false,
|
|
||||||
initialSourceDir = '',
|
|
||||||
onTaskQueued,
|
|
||||||
}) {
|
|
||||||
const [catalogStatus, setCatalogStatus] = useState(null);
|
|
||||||
const [products, setProducts] = useState([]);
|
|
||||||
const [selectedProductId, setSelectedProductId] = useState(null);
|
|
||||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
|
||||||
const [actionMessage, setActionMessage] = useState('');
|
|
||||||
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
|
|
||||||
const [publishRoot, setPublishRoot] = useState('');
|
|
||||||
const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL);
|
|
||||||
const [queryDraft, setQueryDraft] = useState('');
|
|
||||||
const [queryApplied, setQueryApplied] = useState('');
|
|
||||||
|
|
||||||
const listLimit = compact ? 8 : 24;
|
|
||||||
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialSourceDir) return;
|
|
||||||
setSourceDirectoriesText((current) => (current.trim() ? current : initialSourceDir));
|
|
||||||
}, [initialSourceDir]);
|
|
||||||
|
|
||||||
const sourceDirectories = useMemo(
|
|
||||||
() => parseDirectoryList(sourceDirectoriesText),
|
|
||||||
[sourceDirectoriesText]
|
|
||||||
);
|
|
||||||
|
|
||||||
const engineOptions = useMemo(
|
|
||||||
() => buildDinsarEngineOptions(products, { includeKnown: true }),
|
|
||||||
[products]
|
|
||||||
);
|
|
||||||
const selectedEngineMeta = useMemo(
|
|
||||||
() => (engineFilter === DINSAR_ENGINE_ALL ? null : getDinsarEngineMeta(engineFilter)),
|
|
||||||
[engineFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (engineFilter === DINSAR_ENGINE_ALL) return;
|
|
||||||
if (!engineOptions.some((option) => option.value === engineFilter)) {
|
|
||||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
|
||||||
}
|
|
||||||
}, [engineFilter, engineOptions]);
|
|
||||||
|
|
||||||
const loadCatalog = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [statusData, productData] = await Promise.all([
|
|
||||||
getDinsarCatalogStatus(),
|
|
||||||
listDinsarProducts({
|
|
||||||
limit: listLimit,
|
|
||||||
offset: 0,
|
|
||||||
engine_code: engineFilter === DINSAR_ENGINE_ALL ? undefined : engineFilter,
|
|
||||||
query: queryApplied || undefined,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
setCatalogStatus(statusData);
|
|
||||||
const nextItems = Array.isArray(productData?.items) ? productData.items : [];
|
|
||||||
setProducts(nextItems);
|
|
||||||
setSelectedProductId((current) => {
|
|
||||||
if (current && nextItems.some((item) => item.id === current)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return nextItems[0]?.id ?? null;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setActionMessage(`结果目录状态加载失败:${error?.response?.data?.detail || error.message}`);
|
|
||||||
setCatalogStatus(null);
|
|
||||||
setProducts([]);
|
|
||||||
setSelectedProductId(null);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [engineFilter, listLimit, queryApplied]);
|
|
||||||
|
|
||||||
const loadProductDetail = useCallback(async (productId) => {
|
|
||||||
if (!productId) {
|
|
||||||
setSelectedProduct(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedProduct(null);
|
|
||||||
setDetailLoading(true);
|
|
||||||
try {
|
|
||||||
const detail = await getDinsarProductDetail(productId);
|
|
||||||
setSelectedProduct(detail);
|
|
||||||
} catch (error) {
|
|
||||||
setSelectedProduct({
|
|
||||||
error: error?.response?.data?.detail || error.message || '结果详情加载失败',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCatalog();
|
|
||||||
}, [loadCatalog]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadProductDetail(selectedProductId);
|
|
||||||
}, [loadProductDetail, selectedProductId]);
|
|
||||||
|
|
||||||
const handleApplyFilters = useCallback(() => {
|
|
||||||
setQueryApplied(queryDraft.trim());
|
|
||||||
}, [queryDraft]);
|
|
||||||
|
|
||||||
const handleResetFilters = useCallback(() => {
|
|
||||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
|
||||||
setQueryDraft('');
|
|
||||||
setQueryApplied('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleQueuePublish = async () => {
|
|
||||||
if (readOnly || sourceDirectories.length === 0) return;
|
|
||||||
setActionLoading(true);
|
|
||||||
setActionMessage('');
|
|
||||||
try {
|
|
||||||
const result = await queueDinsarProductPublish({
|
|
||||||
source_directories: sourceDirectories,
|
|
||||||
publish_root: publishRoot.trim() || null,
|
|
||||||
rebuild_catalog: true,
|
|
||||||
});
|
|
||||||
setActionMessage(`结果包发布任务已入队:${result.task_id}`);
|
|
||||||
onTaskQueued?.(result.task_id);
|
|
||||||
await loadCatalog();
|
|
||||||
} catch (error) {
|
|
||||||
setActionMessage(`结果包发布失败:${error?.response?.data?.detail || error.message}`);
|
|
||||||
} finally {
|
|
||||||
setActionLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQueueRebuild = async () => {
|
|
||||||
if (readOnly) return;
|
|
||||||
setActionLoading(true);
|
|
||||||
setActionMessage('');
|
|
||||||
try {
|
|
||||||
const result = await queueDinsarCatalogRebuild({
|
|
||||||
publish_root: publishRoot.trim() || null,
|
|
||||||
full_rebuild: true,
|
|
||||||
});
|
|
||||||
setActionMessage(`结果目录重建任务已入队:${result.task_id}`);
|
|
||||||
onTaskQueued?.(result.task_id);
|
|
||||||
await loadCatalog();
|
|
||||||
} catch (error) {
|
|
||||||
setActionMessage(`结果目录重建失败:${error?.response?.data?.detail || error.message}`);
|
|
||||||
} finally {
|
|
||||||
setActionLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral';
|
|
||||||
const actionTone = getMessageTone(actionMessage);
|
|
||||||
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
|
|
||||||
const selectedAssets = Array.isArray(selectedProduct?.assets) ? selectedProduct.assets : [];
|
|
||||||
const selectedPairingTrace = selectedProduct?.pairing_trace || null;
|
|
||||||
const selectedPairingNetwork = selectedProduct?.pairing_network || null;
|
|
||||||
const selectedPairingRun = selectedPairingNetwork?.run || null;
|
|
||||||
const selectedPairingEdge = selectedPairingNetwork?.edge || null;
|
|
||||||
const selectedPairingMetric = selectedPairingNetwork?.metric || null;
|
|
||||||
const selectedProductEngine = getDinsarEngineMeta(selectedProduct?.engine_code);
|
|
||||||
const selectedStatusTone = STATUS_TONE_MAP[selectedProduct?.status] || 'neutral';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`dinsar-catalog-shell ${compact ? 'compact' : ''}`}>
|
|
||||||
<div className="dinsar-catalog-header">
|
|
||||||
<div className="dinsar-catalog-header-copy">
|
|
||||||
<strong>{compact ? '结果目录状态' : '标准结果包目录'}</strong>
|
|
||||||
<p>
|
|
||||||
{compact
|
|
||||||
? '查看结果包目录与数据库索引是否一致。'
|
|
||||||
: '统一结果目录按 engine + pair + run 管理,便于同一对影像保留多套生产结果并行对比。'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-header-actions">
|
|
||||||
{selectedEngineMeta && (
|
|
||||||
<span className={`dinsar-engine-badge tone-${selectedEngineMeta.tone}`}>
|
|
||||||
{selectedEngineMeta.shortLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button onClick={loadCatalog} disabled={loading || actionLoading}>
|
|
||||||
{loading ? '刷新中...' : '刷新'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-summary">
|
|
||||||
<div className="dinsar-catalog-stat-card">
|
|
||||||
<span>目录状态</span>
|
|
||||||
<strong>{catalogStatus?.status || '未知'}</strong>
|
|
||||||
<StatusPill label={catalogStatus?.status || 'UNKNOWN'} tone={catalogTone} />
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-stat-card">
|
|
||||||
<span>需要重建</span>
|
|
||||||
<strong>{catalogStatus?.needs_rebuild ? '是' : '否'}</strong>
|
|
||||||
<small>{catalogStatus?.needs_rebuild ? 'Manifest 与数据库存在漂移' : '目录登记正常'}</small>
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-stat-card">
|
|
||||||
<span>Manifest / 数据库</span>
|
|
||||||
<strong>{catalogStatus?.manifest_count ?? 0} / {catalogStatus?.db_count ?? 0}</strong>
|
|
||||||
<small>已登记结果包总量</small>
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-stat-card">
|
|
||||||
<span>问题数量</span>
|
|
||||||
<strong>{catalogStatus?.issue_count ?? 0}</strong>
|
|
||||||
<small>含缺失文件与健康异常</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-meta-strip">
|
|
||||||
<div><strong>结果包根目录:</strong>{catalogStatus?.storage_root || '-'}</div>
|
|
||||||
<div><strong>最近消息:</strong>{catalogStatus?.last_message || '-'}</div>
|
|
||||||
<div><strong>最近全量重建:</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actionMessage && (
|
|
||||||
<div className={`dinsar-catalog-message tone-${actionTone}`}>
|
|
||||||
{actionMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!compact && (
|
|
||||||
<div className="dinsar-catalog-manage">
|
|
||||||
<div className="dinsar-catalog-manage-copy">
|
|
||||||
<strong>手动发布与目录重建</strong>
|
|
||||||
<p>
|
|
||||||
这里用于把既有结果目录重新发布为标准结果包,并按最新规则重建目录索引。
|
|
||||||
如果同一对影像存在 ENVI 与 ISCE2 两套结果,它们会依赖 `engine_code` 与 `run_key` 分别登记,不会互相覆盖。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-manage-form">
|
|
||||||
<textarea
|
|
||||||
value={sourceDirectoriesText}
|
|
||||||
onChange={(event) => setSourceDirectoriesText(event.target.value)}
|
|
||||||
placeholder="输入一个或多个结果源目录,支持换行、逗号或分号分隔"
|
|
||||||
disabled={readOnly || actionLoading}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={publishRoot}
|
|
||||||
onChange={(event) => setPublishRoot(event.target.value)}
|
|
||||||
placeholder="可选:自定义标准结果包根目录,留空使用系统配置"
|
|
||||||
disabled={readOnly || actionLoading}
|
|
||||||
/>
|
|
||||||
<div className="dinsar-catalog-manage-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="primary"
|
|
||||||
onClick={handleQueuePublish}
|
|
||||||
disabled={readOnly || actionLoading || sourceDirectories.length === 0}
|
|
||||||
>
|
|
||||||
{actionLoading ? '处理中...' : '发布结果包并重建'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleQueueRebuild}
|
|
||||||
disabled={readOnly || actionLoading}
|
|
||||||
>
|
|
||||||
仅重建目录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`dinsar-catalog-workspace ${compact ? 'compact' : ''}`}>
|
|
||||||
<aside className="dinsar-catalog-list-card">
|
|
||||||
<div className="dinsar-catalog-card-head">
|
|
||||||
<div>
|
|
||||||
<strong>结果包列表</strong>
|
|
||||||
<span>
|
|
||||||
{loading ? '加载中...' : `当前展示 ${products.length} 条`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{queryApplied && <StatusPill label={`检索: ${queryApplied}`} tone="info" />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-filter-bar">
|
|
||||||
<label className="dinsar-catalog-filter-field">
|
|
||||||
<span>生产引擎</span>
|
|
||||||
<select value={engineFilter} onChange={(event) => setEngineFilter(event.target.value)}>
|
|
||||||
<option value={DINSAR_ENGINE_ALL}>全部引擎</option>
|
|
||||||
{engineOptions.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="dinsar-catalog-filter-field search">
|
|
||||||
<span>检索</span>
|
|
||||||
<input
|
|
||||||
value={queryDraft}
|
|
||||||
onChange={(event) => setQueryDraft(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleApplyFilters();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="搜索任务名 / pair / run / 引擎"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-filter-actions">
|
|
||||||
<button type="button" onClick={handleApplyFilters}>查询</button>
|
|
||||||
<button type="button" onClick={handleResetFilters}>重置</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{products.length === 0 ? (
|
|
||||||
<div className="dinsar-catalog-empty">
|
|
||||||
{loading ? '正在加载结果包...' : '当前筛选条件下没有结果包。'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="dinsar-catalog-list">
|
|
||||||
{products.map((item) => {
|
|
||||||
const tone = STATUS_TONE_MAP[item.status] || 'neutral';
|
|
||||||
const engineMeta = getDinsarEngineMeta(item.engine_code);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={`dinsar-catalog-list-item ${selectedProductId === item.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setSelectedProductId(item.id)}
|
|
||||||
>
|
|
||||||
<div className="dinsar-catalog-list-item-top">
|
|
||||||
<strong>{item.display_name || item.product_id}</strong>
|
|
||||||
<StatusPill label={item.status || 'UNKNOWN'} tone={tone} />
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-list-item-badges">
|
|
||||||
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
|
|
||||||
<span>{formatDateTime(item.published_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-list-item-meta">
|
|
||||||
{(item.task_alias || item.task_name || '-')}{item.run_key ? ` / ${item.run_key}` : ''}
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-list-item-meta">
|
|
||||||
{item.pair_key || '-'}
|
|
||||||
</div>
|
|
||||||
{(item.selection_strategy || item.network_run_id || item.network_edge_id != null) && (
|
|
||||||
<div className="dinsar-catalog-list-item-trace">
|
|
||||||
{(item.selection_strategy || 'trace')}
|
|
||||||
{item.network_edge_id != null ? ` / edge ${item.network_edge_id}` : ''}
|
|
||||||
{item.network_run_id ? ` / ${item.network_run_id}` : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="dinsar-catalog-detail-card">
|
|
||||||
<div className="dinsar-catalog-card-head">
|
|
||||||
<div>
|
|
||||||
<strong>结果包详情</strong>
|
|
||||||
<span>查看选中结果的发布信息、配对溯源与资产健康</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!selectedProductId ? (
|
|
||||||
<div className="dinsar-catalog-empty">请选择一个结果包查看详情。</div>
|
|
||||||
) : detailLoading || !selectedProduct ? (
|
|
||||||
<div className="dinsar-catalog-empty">正在加载详情...</div>
|
|
||||||
) : selectedProduct?.error ? (
|
|
||||||
<div className="dinsar-catalog-empty error">{selectedProduct.error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="dinsar-catalog-detail-body">
|
|
||||||
<div className="dinsar-catalog-hero">
|
|
||||||
<div className="dinsar-catalog-preview-frame">
|
|
||||||
<img
|
|
||||||
src={`${previewBaseUrl}/dinsar-products/${selectedProduct.id}/preview`}
|
|
||||||
alt={selectedProduct.display_name || selectedProduct.product_id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-hero-meta">
|
|
||||||
<div className="dinsar-catalog-hero-title-row">
|
|
||||||
<div>
|
|
||||||
<h4>{selectedProduct.display_name || selectedProduct.product_id}</h4>
|
|
||||||
<p>{selectedProduct.task_alias || selectedProduct.task_name || '未命名任务'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-hero-badges">
|
|
||||||
<span className={`dinsar-engine-badge tone-${selectedProductEngine.tone}`}>
|
|
||||||
{selectedProductEngine.shortLabel}
|
|
||||||
</span>
|
|
||||||
<StatusPill label={selectedProduct.status || 'UNKNOWN'} tone={selectedStatusTone} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-kv-grid">
|
|
||||||
<MetaField label="产品编号" value={selectedProduct.product_id} multiline />
|
|
||||||
<MetaField label="配对标识" value={selectedProduct.pair_key} multiline />
|
|
||||||
<MetaField label="场景配对 UID" value={selectedProduct.pair_uid} multiline />
|
|
||||||
<MetaField label="运行标识" value={selectedProduct.run_key} multiline />
|
|
||||||
<MetaField label="生产配置" value={selectedProduct.profile_code} />
|
|
||||||
<MetaField label="健康状态" value={selectedProduct.health_status} />
|
|
||||||
<MetaField label="主文件" value={selectedProduct.primary_asset_path} multiline />
|
|
||||||
<MetaField label="源文件" value={selectedProduct.source_primary_path} multiline />
|
|
||||||
<MetaField label="结果包目录" value={selectedProduct.publish_dir} multiline />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-detail-grid">
|
|
||||||
<div className="dinsar-catalog-section-card">
|
|
||||||
<div className="dinsar-catalog-section-title">时空概览</div>
|
|
||||||
<MetaField label="主影像日期" value={selectedProduct.profile?.master_imaging_date} />
|
|
||||||
<MetaField label="从影像日期" value={selectedProduct.profile?.slave_imaging_date} />
|
|
||||||
<MetaField label="时间基线" value={selectedProduct.profile?.time_baseline_days} />
|
|
||||||
<MetaField label="空间基线" value={selectedProduct.profile?.spatial_baseline_meters} />
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-section-card">
|
|
||||||
<div className="dinsar-catalog-section-title">空间范围</div>
|
|
||||||
<MetaField label="最小坐标" value={`${selectedProduct.min_lon ?? '-'}, ${selectedProduct.min_lat ?? '-'}`} />
|
|
||||||
<MetaField label="最大坐标" value={`${selectedProduct.max_lon ?? '-'}, ${selectedProduct.max_lat ?? '-'}`} />
|
|
||||||
<MetaField label="登记时间" value={formatDateTime(selectedProduct.registered_at)} />
|
|
||||||
<MetaField label="发布时间" value={formatDateTime(selectedProduct.published_at)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-section-card">
|
|
||||||
<div className="dinsar-catalog-section-title">配对追踪</div>
|
|
||||||
{!selectedPairingTrace?.network_run_id ? (
|
|
||||||
<div className="dinsar-catalog-empty inline">当前结果未携带完整的配对网络追踪信息。</div>
|
|
||||||
) : (
|
|
||||||
<div className="dinsar-catalog-detail-grid">
|
|
||||||
<div className="dinsar-catalog-section-card nested">
|
|
||||||
<MetaField label="network_run_id" value={selectedPairingTrace.network_run_id} multiline />
|
|
||||||
<MetaField label="network_edge_id" value={selectedPairingTrace.network_edge_id} />
|
|
||||||
<MetaField label="pair_uid" value={selectedPairingTrace.pair_uid} multiline />
|
|
||||||
<MetaField label="选择策略" value={selectedPairingTrace.selection_strategy} />
|
|
||||||
<MetaField label="策略版本" value={selectedPairingTrace.policy_version} />
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-section-card nested">
|
|
||||||
<MetaField label="网络记录" value={selectedPairingNetwork?.run_found ? '已找到' : '未找到'} />
|
|
||||||
<MetaField label="边记录" value={selectedPairingNetwork?.edge_found ? '已找到' : '未找到'} />
|
|
||||||
<MetaField label="运行状态" value={selectedPairingRun?.status} />
|
|
||||||
<MetaField label="候选边数" value={selectedPairingRun?.candidate_count} />
|
|
||||||
<MetaField label="入选边数" value={selectedPairingRun?.selected_edge_count} />
|
|
||||||
<MetaField label="告警数" value={selectedPairingRun?.warning_count} />
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-section-card nested">
|
|
||||||
<MetaField label="edge_rank" value={selectedPairingEdge?.edge_rank} />
|
|
||||||
<MetaField label="selection_reason" value={selectedPairingEdge?.selection_reason} multiline />
|
|
||||||
<MetaField label="selection_score" value={selectedPairingEdge?.selection_score} />
|
|
||||||
<MetaField label="reference_edge" value={selectedPairingEdge?.is_reference_edge ? '是' : '否'} />
|
|
||||||
<MetaField label="metric_cache_ref_id" value={selectedPairingEdge?.metric_cache_ref_id} />
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-catalog-section-card nested">
|
|
||||||
<MetaField label="主从日期" value={`${selectedPairingMetric?.master_imaging_date || '-'} / ${selectedPairingMetric?.slave_imaging_date || '-'}`} />
|
|
||||||
<MetaField label="主从卫星" value={`${selectedPairingMetric?.master_satellite || '-'} / ${selectedPairingMetric?.slave_satellite || '-'}`} />
|
|
||||||
<MetaField label="主从模式" value={`${selectedPairingMetric?.master_imaging_mode || '-'} / ${selectedPairingMetric?.slave_imaging_mode || '-'}`} />
|
|
||||||
<MetaField label="主从极化" value={`${selectedPairingMetric?.master_polarization || '-'} / ${selectedPairingMetric?.slave_polarization || '-'}`} />
|
|
||||||
<MetaField label="时间基线" value={selectedPairingMetric?.time_baseline_days} />
|
|
||||||
<MetaField label="空间基线" value={selectedPairingMetric?.spatial_baseline_meters} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-detail-grid">
|
|
||||||
<div className="dinsar-catalog-section-card">
|
|
||||||
<div className="dinsar-catalog-section-title">资产列表 ({selectedAssets.length})</div>
|
|
||||||
{selectedAssets.length === 0 ? (
|
|
||||||
<div className="dinsar-catalog-empty inline">暂无资产记录。</div>
|
|
||||||
) : (
|
|
||||||
<div className="dinsar-catalog-asset-list">
|
|
||||||
{selectedAssets.map((asset) => (
|
|
||||||
<div key={asset.id} className={`dinsar-catalog-asset-item ${asset.exists_flag ? 'ok' : 'missing'}`}>
|
|
||||||
<div className="dinsar-catalog-asset-top">
|
|
||||||
<strong>{asset.asset_role}</strong>
|
|
||||||
<span>{asset.exists_flag ? '文件存在' : '文件缺失'}</span>
|
|
||||||
</div>
|
|
||||||
<div>{asset.asset_name}</div>
|
|
||||||
<div className="break-all">{asset.absolute_path}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-catalog-section-card">
|
|
||||||
<div className="dinsar-catalog-section-title">问题列表 ({selectedIssues.length})</div>
|
|
||||||
{selectedIssues.length === 0 ? (
|
|
||||||
<div className="dinsar-catalog-empty inline ok">当前没有登记问题。</div>
|
|
||||||
) : (
|
|
||||||
<div className="dinsar-catalog-issue-list">
|
|
||||||
{selectedIssues.map((issue) => (
|
|
||||||
<div key={issue.id} className={`dinsar-catalog-issue-item ${String(issue.severity || '').toUpperCase() === 'ERROR' ? 'error' : 'warn'}`}>
|
|
||||||
<div className="dinsar-catalog-issue-top">
|
|
||||||
<strong>{issue.issue_code}</strong>
|
|
||||||
<span>{issue.severity}</span>
|
|
||||||
</div>
|
|
||||||
<div>{issue.message}</div>
|
|
||||||
{issue.repair_action && (
|
|
||||||
<div className="dinsar-catalog-issue-action">
|
|
||||||
建议修复动作:{issue.repair_action}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { exportDinsarResults } from '../api/dinsar';
|
import { exportDinsarResults } from '../api/dinsar';
|
||||||
|
import { getDinsarEngineMeta } from '../utils/dinsarEngines';
|
||||||
|
|
||||||
const EXAMPLE_TARGET_DIR = String.raw`例如: D:\Export\Results 或 \\server\share\results`;
|
const EXAMPLE_TARGET_DIR = String.raw`例如: D:\Export\Results 或 \\server\share\results`;
|
||||||
|
|
||||||
@@ -10,6 +11,9 @@ export default function ResultExportModal({ results = [], onClose }) {
|
|||||||
const [exportResult, setExportResult] = useState(null);
|
const [exportResult, setExportResult] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const selectedCount = selectedIds.size;
|
||||||
|
const sortedResults = useMemo(() => [...results], [results]);
|
||||||
|
|
||||||
const toggleSelect = (id) => {
|
const toggleSelect = (id) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -86,7 +90,7 @@ export default function ResultExportModal({ results = [], onClose }) {
|
|||||||
onChange={toggleAll}
|
onChange={toggleAll}
|
||||||
disabled={exporting || results.length === 0}
|
disabled={exporting || results.length === 0}
|
||||||
/>
|
/>
|
||||||
全选 ({selectedIds.size}/{results.length})
|
全选 ({selectedCount}/{results.length})
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,21 +99,27 @@ export default function ResultExportModal({ results = [], onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="export-result-list">
|
<ul className="export-result-list">
|
||||||
{results.map((result) => (
|
{sortedResults.map((result) => {
|
||||||
<li key={result.id} className="export-result-item">
|
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
||||||
<label>
|
return (
|
||||||
<input
|
<li key={result.id} className="export-result-item">
|
||||||
type="checkbox"
|
<label>
|
||||||
checked={selectedIds.has(result.id)}
|
<input
|
||||||
onChange={() => toggleSelect(result.id)}
|
type="checkbox"
|
||||||
disabled={exporting}
|
checked={selectedIds.has(result.id)}
|
||||||
/>
|
onChange={() => toggleSelect(result.id)}
|
||||||
<span className="export-result-name" title={result.file_path || result.name}>
|
disabled={exporting}
|
||||||
{result.name}
|
/>
|
||||||
</span>
|
<span className="export-result-name" title={result.file_path || result.name}>
|
||||||
</label>
|
{result.name}
|
||||||
</li>
|
</span>
|
||||||
))}
|
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>
|
||||||
|
{engineMeta.shortLabel}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { exportDinsarResults } from '../api/dinsar';
|
|
||||||
import { getDinsarEngineMeta } from '../utils/dinsarEngines';
|
|
||||||
|
|
||||||
const EXAMPLE_TARGET_DIR = String.raw`例如: D:\Export\Results 或 \\server\share\results`;
|
|
||||||
|
|
||||||
export default function ResultExportModal({ results = [], onClose }) {
|
|
||||||
const [targetDir, setTargetDir] = useState('');
|
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set(results.map((result) => result.id)));
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
const [exportResult, setExportResult] = useState(null);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const selectedCount = selectedIds.size;
|
|
||||||
const sortedResults = useMemo(() => [...results], [results]);
|
|
||||||
|
|
||||||
const toggleSelect = (id) => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
if (selectedIds.size === results.length) {
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedIds(new Set(results.map((result) => result.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
const dir = targetDir.trim();
|
|
||||||
if (!dir) {
|
|
||||||
setError('请输入目标路径。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedIds.size === 0) {
|
|
||||||
setError('请至少选择一个结果。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError('');
|
|
||||||
setExporting(true);
|
|
||||||
setExportResult(null);
|
|
||||||
try {
|
|
||||||
const response = await exportDinsarResults([...selectedIds], dir);
|
|
||||||
setExportResult(response);
|
|
||||||
} catch (eventualError) {
|
|
||||||
setError(eventualError.response?.data?.detail || eventualError.message || '提取失败。');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay visible" onClick={onClose}>
|
|
||||||
<div className="modal-content result-export-modal" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h3>提取 D-InSAR 结果</h3>
|
|
||||||
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="关闭">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body">
|
|
||||||
<div className="export-path-section">
|
|
||||||
<label>目标路径,支持本地盘符或 UNC 路径,例如 `D:\Export\Results`。</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={targetDir}
|
|
||||||
onChange={(event) => setTargetDir(event.target.value)}
|
|
||||||
placeholder={EXAMPLE_TARGET_DIR}
|
|
||||||
disabled={exporting}
|
|
||||||
className="export-path-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="export-select-section">
|
|
||||||
<div className="export-select-header">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedIds.size === results.length && results.length > 0}
|
|
||||||
onChange={toggleAll}
|
|
||||||
disabled={exporting || results.length === 0}
|
|
||||||
/>
|
|
||||||
全选 ({selectedCount}/{results.length})
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="export-select-hint">
|
|
||||||
导出时会优先按任务名创建子目录;如果同名结果已存在且内容不同,会自动追加后缀避免覆盖。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="export-result-list">
|
|
||||||
{sortedResults.map((result) => {
|
|
||||||
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
|
||||||
return (
|
|
||||||
<li key={result.id} className="export-result-item">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedIds.has(result.id)}
|
|
||||||
onChange={() => toggleSelect(result.id)}
|
|
||||||
disabled={exporting}
|
|
||||||
/>
|
|
||||||
<span className="export-result-name" title={result.file_path || result.name}>
|
|
||||||
{result.name}
|
|
||||||
</span>
|
|
||||||
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>
|
|
||||||
{engineMeta.shortLabel}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="export-error">{error}</div>}
|
|
||||||
|
|
||||||
{exportResult && (
|
|
||||||
<div className="export-summary">
|
|
||||||
<div className="export-summary-title">提取完成</div>
|
|
||||||
<div className="export-summary-stats">
|
|
||||||
<span className="stat-ok">复制: {exportResult.copied}</span>
|
|
||||||
<span className="stat-skip">跳过: {exportResult.skipped}</span>
|
|
||||||
{exportResult.failed > 0 && (
|
|
||||||
<span className="stat-fail">失败: {exportResult.failed}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="export-summary-dir">
|
|
||||||
目标目录: {exportResult.target_dir}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={exporting}>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={exporting || selectedIds.size === 0 || !targetDir.trim()}
|
|
||||||
className="btn-primary"
|
|
||||||
>
|
|
||||||
{exporting ? '提取中...' : `确定提取 ${selectedIds.size} 个结果`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
|
|||||||
const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel'));
|
const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel'));
|
||||||
const LazyAiAnalysisPanel = lazy(() => import('../../AiAnalysisPanel'));
|
const LazyAiAnalysisPanel = lazy(() => import('../../AiAnalysisPanel'));
|
||||||
const LazyPairingPanel = lazy(() => import('../../panels/PairPlanningPanel'));
|
const LazyPairingPanel = lazy(() => import('../../panels/PairPlanningPanel'));
|
||||||
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel.rewrite'));
|
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'));
|
||||||
const LazyBatchPanel = lazy(() => import('../../panels/BatchPanel'));
|
const LazyBatchPanel = lazy(() => import('../../panels/BatchPanel'));
|
||||||
const LazyPairsListPanel = lazy(() => import('../../panels/PairsListPanel'));
|
const LazyPairsListPanel = lazy(() => import('../../panels/PairsListPanel'));
|
||||||
const LazyPsResultsPanel = lazy(() => import('../../panels/PsResultsPanel'));
|
const LazyPsResultsPanel = lazy(() => import('../../panels/PsResultsPanel'));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { parseDatesFromName, formatYmd } from '../../utils/appUiHelpers';
|
import { parseDatesFromName, formatYmd } from '../../utils/appUiHelpers';
|
||||||
|
import { getDinsarEngineMeta } from '../../utils/dinsarEngines';
|
||||||
|
|
||||||
function truncateMiddle(value, maxLength = 26) {
|
function truncateMiddle(value, maxLength = 28) {
|
||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
if (!text || text.length <= maxLength) {
|
if (!text || text.length <= maxLength) {
|
||||||
return text || '-';
|
return text || '-';
|
||||||
@@ -21,49 +22,104 @@ function DinsarResultRow({
|
|||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
}) {
|
}) {
|
||||||
const dates = showDates ? parseDatesFromName(result.name, (value) => formatYmd(value, language)) : null;
|
const dates = showDates ? parseDatesFromName(result.name, (value) => formatYmd(value, language)) : null;
|
||||||
const hasTrace = !!(result.selection_strategy || result.network_run_id || result.network_edge_id || result.pair_uid);
|
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
||||||
|
const hasTrace = !!(
|
||||||
|
result.selection_strategy ||
|
||||||
|
result.network_run_id ||
|
||||||
|
result.network_edge_id ||
|
||||||
|
result.pair_uid ||
|
||||||
|
result.pair_key ||
|
||||||
|
result.run_key
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="data-item dinsar-item">
|
<li className="data-item dinsar-item">
|
||||||
<div className="dinsar-info">
|
<div className="dinsar-row-header">
|
||||||
<span className="data-item-name" title={result.name}>
|
<span className="data-item-name" title={result.name}>
|
||||||
{result.name}
|
{result.name}
|
||||||
</span>
|
</span>
|
||||||
{result.ai_score !== null && (
|
<div className="dinsar-row-badges">
|
||||||
<span
|
<span
|
||||||
className={`ai-score ${result.ai_score > 0.7 ? 'good' : (result.ai_score < 0.4 ? 'bad' : 'medium')}`}
|
className={`dinsar-engine-badge tone-${engineMeta.tone}`}
|
||||||
title={language === 'en' ? 'AI quality score' : 'AI 质量评分'}
|
title={`${language === 'en' ? 'Engine' : '生产引擎'}: ${engineMeta.label}`}
|
||||||
>
|
>
|
||||||
AI: {(result.ai_score * 100).toFixed(0)}
|
{engineMeta.shortLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{result.ai_score !== null && (
|
||||||
|
<span
|
||||||
|
className={`ai-score ${result.ai_score > 0.7 ? 'good' : (result.ai_score < 0.4 ? 'bad' : 'medium')}`}
|
||||||
|
title={language === 'en' ? 'AI quality score' : 'AI 质量评分'}
|
||||||
|
>
|
||||||
|
AI {Math.round(result.ai_score * 100)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasTrace && (
|
{hasTrace && (
|
||||||
<div className="dinsar-trace-info">
|
<div className="dinsar-trace-info">
|
||||||
<div className="dinsar-trace-line">
|
<div className="dinsar-trace-line">
|
||||||
<span className="dinsar-trace-pill" title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}>
|
<span
|
||||||
|
className="dinsar-trace-pill"
|
||||||
|
title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}
|
||||||
|
>
|
||||||
{result.selection_strategy || 'legacy'}
|
{result.selection_strategy || 'legacy'}
|
||||||
</span>
|
</span>
|
||||||
<span title={language === 'en' ? 'Network edge id' : '网络边编号'}>
|
{result.run_key && (
|
||||||
edge {result.network_edge_id ?? '-'}
|
<span
|
||||||
</span>
|
className="dinsar-trace-stat"
|
||||||
<span title={language === 'en' ? 'Network run id' : '网络运行编号'}>
|
title={`${language === 'en' ? 'Run key' : '运行标识'}: ${result.run_key}`}
|
||||||
{truncateMiddle(result.network_run_id, 24)}
|
>
|
||||||
</span>
|
<strong>{language === 'en' ? 'run' : '运行'}</strong>
|
||||||
|
<span>{truncateMiddle(result.run_key, 24)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{result.network_edge_id != null && (
|
||||||
|
<span
|
||||||
|
className="dinsar-trace-stat"
|
||||||
|
title={language === 'en' ? 'Network edge id' : '网络边编号'}
|
||||||
|
>
|
||||||
|
<strong>edge</strong>
|
||||||
|
<span>{result.network_edge_id}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{result.network_run_id && (
|
||||||
|
<span
|
||||||
|
className="dinsar-trace-stat"
|
||||||
|
title={`${language === 'en' ? 'Network run id' : '网络运行编号'}: ${result.network_run_id}`}
|
||||||
|
>
|
||||||
|
<strong>{language === 'en' ? 'network' : '网络'}</strong>
|
||||||
|
<span>{truncateMiddle(result.network_run_id, 22)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dinsar-trace-line" title={result.pair_uid || result.pair_key || '-'}>
|
<div className="dinsar-trace-line">
|
||||||
<span>{truncateMiddle(result.pair_uid || result.pair_key, 30)}</span>
|
<span
|
||||||
{result.policy_version && <span>{result.policy_version}</span>}
|
className="dinsar-trace-stat"
|
||||||
|
title={result.pair_uid || result.pair_key || '-'}
|
||||||
|
>
|
||||||
|
<strong>{language === 'en' ? 'pair' : '配对'}</strong>
|
||||||
|
<span>{truncateMiddle(result.pair_uid || result.pair_key, 36)}</span>
|
||||||
|
</span>
|
||||||
|
{result.policy_version && (
|
||||||
|
<span className="dinsar-trace-stat">
|
||||||
|
<strong>{language === 'en' ? 'policy' : '策略版本'}</strong>
|
||||||
|
<span>{result.policy_version}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dates && (
|
{dates && (
|
||||||
<div className="date-info">
|
<div className="date-info">
|
||||||
<span className="date-tag master" title={language === 'en' ? 'Master date' : '主影像日期'}>{dates.master}</span>
|
<span className="date-tag master" title={language === 'en' ? 'Master date' : '主影像日期'}>
|
||||||
<span className="date-arrow">→</span>
|
{dates.master}
|
||||||
<span className="date-tag slave" title={language === 'en' ? 'Slave date' : '辅影像日期'}>{dates.slave}</span>
|
</span>
|
||||||
|
<span className="date-arrow">-></span>
|
||||||
|
<span className="date-tag slave" title={language === 'en' ? 'Slave date' : '从影像日期'}>
|
||||||
|
{dates.slave}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -83,29 +139,34 @@ function DinsarResultRow({
|
|||||||
title={language === 'en' ? 'Mark as low quality' : '标记为低质量'}
|
title={language === 'en' ? 'Mark as low quality' : '标记为低质量'}
|
||||||
disabled={isReadOnlyUser}
|
disabled={isReadOnlyUser}
|
||||||
>
|
>
|
||||||
{language === 'en' ? 'Poor' : '欠佳'}
|
{language === 'en' ? 'Poor' : '较差'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="ai-analyze-btn"
|
<div className="dinsar-control-cluster">
|
||||||
onClick={(event) => {
|
<button
|
||||||
event.stopPropagation();
|
className="ai-analyze-btn"
|
||||||
onAnalyze(result.id);
|
onClick={(event) => {
|
||||||
}}
|
event.stopPropagation();
|
||||||
disabled={isLoading || isReadOnlyUser}
|
onAnalyze(result.id);
|
||||||
title={language === 'en' ? 'Use AI to analyze this result' : '使用 AI 自动分析此结果'}
|
}}
|
||||||
>
|
disabled={isLoading || isReadOnlyUser}
|
||||||
{language === 'en' ? 'AI Diagnose' : 'AI 诊断'}
|
title={language === 'en' ? 'Use AI to analyze this result' : '使用 AI 分析该结果'}
|
||||||
</button>
|
>
|
||||||
<input
|
{language === 'en' ? 'AI Diagnose' : 'AI 诊断'}
|
||||||
type="checkbox"
|
</button>
|
||||||
checked={!!result.isVisible}
|
<label className="dinsar-toggle-label" title={language === 'en' ? 'Show or hide on map' : '在地图上显示或隐藏'}>
|
||||||
onChange={(event) => {
|
<input
|
||||||
event.stopPropagation();
|
type="checkbox"
|
||||||
onToggleVisibility(result.id);
|
checked={!!result.isVisible}
|
||||||
}}
|
onChange={(event) => {
|
||||||
title={language === 'en' ? 'Show or hide on map' : '在地图上显示/隐藏'}
|
event.stopPropagation();
|
||||||
/>
|
onToggleVisibility(result.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{language === 'en' ? 'Map' : '地图'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import { parseDatesFromName, formatYmd } from '../../utils/appUiHelpers';
|
|
||||||
import { getDinsarEngineMeta } from '../../utils/dinsarEngines';
|
|
||||||
|
|
||||||
function truncateMiddle(value, maxLength = 28) {
|
|
||||||
const text = String(value || '').trim();
|
|
||||||
if (!text || text.length <= maxLength) {
|
|
||||||
return text || '-';
|
|
||||||
}
|
|
||||||
const sideLength = Math.max(6, Math.floor((maxLength - 3) / 2));
|
|
||||||
return `${text.slice(0, sideLength)}...${text.slice(-sideLength)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DinsarResultRow({
|
|
||||||
result,
|
|
||||||
language,
|
|
||||||
showDates,
|
|
||||||
isLoading,
|
|
||||||
isReadOnlyUser,
|
|
||||||
onLabel,
|
|
||||||
onAnalyze,
|
|
||||||
onToggleVisibility,
|
|
||||||
}) {
|
|
||||||
const dates = showDates ? parseDatesFromName(result.name, (value) => formatYmd(value, language)) : null;
|
|
||||||
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
|
||||||
const hasTrace = !!(
|
|
||||||
result.selection_strategy ||
|
|
||||||
result.network_run_id ||
|
|
||||||
result.network_edge_id ||
|
|
||||||
result.pair_uid ||
|
|
||||||
result.pair_key ||
|
|
||||||
result.run_key
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="data-item dinsar-item">
|
|
||||||
<div className="dinsar-row-header">
|
|
||||||
<span className="data-item-name" title={result.name}>
|
|
||||||
{result.name}
|
|
||||||
</span>
|
|
||||||
<div className="dinsar-row-badges">
|
|
||||||
<span
|
|
||||||
className={`dinsar-engine-badge tone-${engineMeta.tone}`}
|
|
||||||
title={`${language === 'en' ? 'Engine' : '生产引擎'}: ${engineMeta.label}`}
|
|
||||||
>
|
|
||||||
{engineMeta.shortLabel}
|
|
||||||
</span>
|
|
||||||
{result.ai_score !== null && (
|
|
||||||
<span
|
|
||||||
className={`ai-score ${result.ai_score > 0.7 ? 'good' : (result.ai_score < 0.4 ? 'bad' : 'medium')}`}
|
|
||||||
title={language === 'en' ? 'AI quality score' : 'AI 质量评分'}
|
|
||||||
>
|
|
||||||
AI {Math.round(result.ai_score * 100)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasTrace && (
|
|
||||||
<div className="dinsar-trace-info">
|
|
||||||
<div className="dinsar-trace-line">
|
|
||||||
<span
|
|
||||||
className="dinsar-trace-pill"
|
|
||||||
title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}
|
|
||||||
>
|
|
||||||
{result.selection_strategy || 'legacy'}
|
|
||||||
</span>
|
|
||||||
{result.run_key && (
|
|
||||||
<span
|
|
||||||
className="dinsar-trace-stat"
|
|
||||||
title={`${language === 'en' ? 'Run key' : '运行标识'}: ${result.run_key}`}
|
|
||||||
>
|
|
||||||
<strong>{language === 'en' ? 'run' : '运行'}</strong>
|
|
||||||
<span>{truncateMiddle(result.run_key, 24)}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{result.network_edge_id != null && (
|
|
||||||
<span
|
|
||||||
className="dinsar-trace-stat"
|
|
||||||
title={language === 'en' ? 'Network edge id' : '网络边编号'}
|
|
||||||
>
|
|
||||||
<strong>edge</strong>
|
|
||||||
<span>{result.network_edge_id}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{result.network_run_id && (
|
|
||||||
<span
|
|
||||||
className="dinsar-trace-stat"
|
|
||||||
title={`${language === 'en' ? 'Network run id' : '网络运行编号'}: ${result.network_run_id}`}
|
|
||||||
>
|
|
||||||
<strong>{language === 'en' ? 'network' : '网络'}</strong>
|
|
||||||
<span>{truncateMiddle(result.network_run_id, 22)}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="dinsar-trace-line">
|
|
||||||
<span
|
|
||||||
className="dinsar-trace-stat"
|
|
||||||
title={result.pair_uid || result.pair_key || '-'}
|
|
||||||
>
|
|
||||||
<strong>{language === 'en' ? 'pair' : '配对'}</strong>
|
|
||||||
<span>{truncateMiddle(result.pair_uid || result.pair_key, 36)}</span>
|
|
||||||
</span>
|
|
||||||
{result.policy_version && (
|
|
||||||
<span className="dinsar-trace-stat">
|
|
||||||
<strong>{language === 'en' ? 'policy' : '策略版本'}</strong>
|
|
||||||
<span>{result.policy_version}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dates && (
|
|
||||||
<div className="date-info">
|
|
||||||
<span className="date-tag master" title={language === 'en' ? 'Master date' : '主影像日期'}>
|
|
||||||
{dates.master}
|
|
||||||
</span>
|
|
||||||
<span className="date-arrow">-></span>
|
|
||||||
<span className="date-tag slave" title={language === 'en' ? 'Slave date' : '从影像日期'}>
|
|
||||||
{dates.slave}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="data-item-controls">
|
|
||||||
<div className="label-buttons">
|
|
||||||
<button
|
|
||||||
className={`label-btn good ${result.user_label === 1 ? 'active' : ''}`}
|
|
||||||
onClick={() => onLabel(result.id, result.user_label === 1 ? null : 1)}
|
|
||||||
title={language === 'en' ? 'Mark as high quality' : '标记为高质量'}
|
|
||||||
disabled={isReadOnlyUser}
|
|
||||||
>
|
|
||||||
{language === 'en' ? 'Good' : '良好'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`label-btn bad ${result.user_label === 0 ? 'active' : ''}`}
|
|
||||||
onClick={() => onLabel(result.id, result.user_label === 0 ? null : 0)}
|
|
||||||
title={language === 'en' ? 'Mark as low quality' : '标记为低质量'}
|
|
||||||
disabled={isReadOnlyUser}
|
|
||||||
>
|
|
||||||
{language === 'en' ? 'Poor' : '较差'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-control-cluster">
|
|
||||||
<button
|
|
||||||
className="ai-analyze-btn"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onAnalyze(result.id);
|
|
||||||
}}
|
|
||||||
disabled={isLoading || isReadOnlyUser}
|
|
||||||
title={language === 'en' ? 'Use AI to analyze this result' : '使用 AI 分析该结果'}
|
|
||||||
>
|
|
||||||
{language === 'en' ? 'AI Diagnose' : 'AI 诊断'}
|
|
||||||
</button>
|
|
||||||
<label className="dinsar-toggle-label" title={language === 'en' ? 'Show or hide on map' : '在地图上显示或隐藏'}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!result.isVisible}
|
|
||||||
onChange={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onToggleVisibility(result.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{language === 'en' ? 'Map' : '地图'}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(DinsarResultRow);
|
|
||||||
@@ -158,7 +158,7 @@ export default function DinsarResultPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="list-toolbar column-layout">
|
<div className="list-toolbar column-layout dinsar-results-toolbar">
|
||||||
{focusedHazardPoint && (
|
{focusedHazardPoint && (
|
||||||
<div className="filter-banner">
|
<div className="filter-banner">
|
||||||
<span>
|
<span>
|
||||||
@@ -168,7 +168,7 @@ export default function DinsarResultPanel({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
正在查看覆盖点 <strong>{focusedHazardPoint.hazard_name}</strong> 的结果
|
当前仅显示覆盖隐患点 <strong>{focusedHazardPoint.hazard_name}</strong> 的结果
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -178,144 +178,252 @@ export default function DinsarResultPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="toolbar-row">
|
<div className="dinsar-toolbar-grid">
|
||||||
<button onClick={() => onSetAllVisibility(true)}>
|
<section className="dinsar-toolbar-panel">
|
||||||
{language === 'en' ? 'Show All' : '显示全部'}
|
<span className="dinsar-toolbar-kicker">
|
||||||
</button>
|
{language === 'en' ? 'Current page' : '当前页'}
|
||||||
<button onClick={() => onSetAllVisibility(false)}>
|
</span>
|
||||||
{language === 'en' ? 'Hide All' : '隐藏全部'}
|
<strong className="dinsar-toolbar-value">
|
||||||
</button>
|
{filteredResults.length} / {dinsarResults.length}
|
||||||
<button onClick={() => setShowDates(!showDates)}>
|
</strong>
|
||||||
{showDates
|
<p className="dinsar-toolbar-note">
|
||||||
? (language === 'en' ? 'Hide Dates' : '隐藏日期')
|
{language === 'en'
|
||||||
: (language === 'en' ? 'Show Dates' : '显示日期')}
|
? 'Results after local filtering on the current page'
|
||||||
</button>
|
: '当前页本地筛选后的结果数量'}
|
||||||
<button
|
</p>
|
||||||
onClick={() => setShowExportModal(true)}
|
<div className="dinsar-toolbar-chip-row">
|
||||||
disabled={isLoading || dinsarResults.length === 0}
|
<span className="dinsar-toolbar-chip">
|
||||||
title={language === 'en' ? 'Export selected results to a directory' : '将结果文件提取到指定目录'}
|
{language === 'en' ? 'AI score' : 'AI 分数'}
|
||||||
>
|
{' >= '}
|
||||||
{language === 'en' ? 'Export...' : '导出...'}
|
{scorePercent}
|
||||||
</button>
|
</span>
|
||||||
|
<span className="dinsar-toolbar-chip">
|
||||||
|
{language === 'en' ? 'Dates' : '日期'}
|
||||||
|
{showDates
|
||||||
|
? (language === 'en' ? ': visible' : ':已展开')
|
||||||
|
: (language === 'en' ? ': hidden' : ':已收起')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dinsar-toolbar-panel">
|
||||||
|
<span className="dinsar-toolbar-kicker">
|
||||||
|
{language === 'en' ? 'Engine focus' : '当前引擎'}
|
||||||
|
</span>
|
||||||
|
<strong className="dinsar-toolbar-value">
|
||||||
|
{filteredEngineMeta
|
||||||
|
? filteredEngineMeta.shortLabel
|
||||||
|
: (language === 'en' ? 'All' : '全部')}
|
||||||
|
</strong>
|
||||||
|
<p className="dinsar-toolbar-note">
|
||||||
|
{filteredEngineMeta
|
||||||
|
? filteredEngineMeta.label
|
||||||
|
: (language === 'en'
|
||||||
|
? 'Compare outputs from all registered engines'
|
||||||
|
: '同时查看所有登记引擎的结果')}
|
||||||
|
</p>
|
||||||
|
<div className="dinsar-toolbar-chip-row">
|
||||||
|
<span className="dinsar-toolbar-chip">
|
||||||
|
{language === 'en' ? 'Strategy' : '策略'}:
|
||||||
|
{' '}
|
||||||
|
{strategyFilter === DINSAR_STRATEGY_ALL
|
||||||
|
? (language === 'en' ? 'All' : '全部')
|
||||||
|
: strategyFilter}
|
||||||
|
</span>
|
||||||
|
<span className="dinsar-toolbar-chip">
|
||||||
|
{language === 'en' ? 'Trace search' : '检索词'}:
|
||||||
|
{' '}
|
||||||
|
{traceSearch.trim() || (language === 'en' ? 'None' : '未设置')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dinsar-toolbar-panel">
|
||||||
|
<span className="dinsar-toolbar-kicker">
|
||||||
|
{language === 'en' ? 'Page control' : '分页控制'}
|
||||||
|
</span>
|
||||||
|
<strong className="dinsar-toolbar-value">{pageSummaryText}</strong>
|
||||||
|
<p className="dinsar-toolbar-note">
|
||||||
|
{language === 'en'
|
||||||
|
? 'Use page size and jump controls below for large catalogs'
|
||||||
|
: '大规模结果集请结合页大小和跳页控制使用'}
|
||||||
|
</p>
|
||||||
|
<div className="dinsar-toolbar-actions">
|
||||||
|
<button onClick={() => onSetAllVisibility(true)}>
|
||||||
|
{language === 'en' ? 'Show All' : '全部显示'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onSetAllVisibility(false)}>
|
||||||
|
{language === 'en' ? 'Hide All' : '全部隐藏'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowDates(!showDates)}>
|
||||||
|
{showDates
|
||||||
|
? (language === 'en' ? 'Hide Dates' : '收起日期')
|
||||||
|
: (language === 'en' ? 'Show Dates' : '显示日期')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
disabled={isLoading || filteredResults.length === 0}
|
||||||
|
title={language === 'en'
|
||||||
|
? 'Export visible results in the current filter scope'
|
||||||
|
: '按当前筛选范围导出结果文件'}
|
||||||
|
>
|
||||||
|
{language === 'en' ? 'Export...' : '提取结果...'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-row filter-row">
|
<div className="dinsar-filter-layout">
|
||||||
<label title={language === 'en' ? 'Filter low-quality results below this score' : '过滤低于当前分数的结果'}>
|
<label className="dinsar-filter-field">
|
||||||
{language === 'en' ? 'AI Score Filter' : 'AI 评分过滤'}: {(scoreFilter * 100).toFixed(0)}
|
<span>{language === 'en' ? 'AI score floor' : 'AI 分数下限'}</span>
|
||||||
|
<div className="dinsar-score-filter">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={scoreFilter}
|
||||||
|
onChange={onScoreFilterChange}
|
||||||
|
/>
|
||||||
|
<strong>{scorePercent}</strong>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value={scoreFilter}
|
|
||||||
onChange={onScoreFilterChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-row filter-row">
|
<label className="dinsar-filter-field">
|
||||||
<label>{language === 'en' ? 'Pairing strategy' : '配对策略'}:</label>
|
<span>{language === 'en' ? 'Pairing strategy' : '配对策略'}</span>
|
||||||
<select
|
|
||||||
value={strategyFilter}
|
|
||||||
onChange={(event) => setStrategyFilter(event.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<option value={DINSAR_STRATEGY_ALL}>
|
|
||||||
{language === 'en' ? 'All strategies' : '全部策略'}
|
|
||||||
</option>
|
|
||||||
{strategyOptions
|
|
||||||
.filter((value) => value !== DINSAR_STRATEGY_ALL)
|
|
||||||
.map((value) => (
|
|
||||||
<option key={value} value={value}>{value}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={traceSearch}
|
|
||||||
onChange={(event) => setTraceSearch(event.target.value)}
|
|
||||||
placeholder={language === 'en' ? 'Search pair/run trace' : '搜索 pair/run trace'}
|
|
||||||
disabled={isLoading}
|
|
||||||
style={{ minWidth: '180px', flex: 1 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-row">
|
|
||||||
<span style={{ fontSize: '12px', color: '#4a5568' }}>
|
|
||||||
{language === 'en'
|
|
||||||
? `Filtered ${filteredResults.length} / ${dinsarResults.length} results`
|
|
||||||
: `当前筛出 ${filteredResults.length} / ${dinsarResults.length} 条结果`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-row">
|
|
||||||
<button type="button" onClick={() => onPageChange(-1)} disabled={isLoading || dinsarPagination.offset <= 0}>
|
|
||||||
{language === 'en' ? 'Previous' : '上一页'}
|
|
||||||
</button>
|
|
||||||
<span style={{ fontSize: '12px', color: '#4a5568' }}>
|
|
||||||
{language === 'en'
|
|
||||||
? `Page ${dinsarCurrentPage}/${dinsarTotalPages} (Total ${dinsarPagination.total} items)`
|
|
||||||
: `第 ${dinsarCurrentPage}/${dinsarTotalPages} 页(共 ${dinsarPagination.total} 条)`}
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={() => onPageChange(1)} disabled={isLoading || !dinsarPagination.hasMore}>
|
|
||||||
{language === 'en' ? 'Next' : '下一页'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-row">
|
|
||||||
<label style={{ fontSize: '12px', color: '#4a5568' }}>
|
|
||||||
{language === 'en' ? 'Per page' : '每页'}
|
|
||||||
<select
|
<select
|
||||||
value={dinsarPagination.limit}
|
value={strategyFilter}
|
||||||
onChange={onPageSizeChange}
|
onChange={(event) => setStrategyFilter(event.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{ marginLeft: '6px', marginRight: '6px' }}
|
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
<option value={DINSAR_STRATEGY_ALL}>
|
||||||
<option key={size} value={size}>{size}</option>
|
{language === 'en' ? 'All strategies' : '全部策略'}
|
||||||
|
</option>
|
||||||
|
{strategyOptions
|
||||||
|
.filter((value) => value !== DINSAR_STRATEGY_ALL)
|
||||||
|
.map((value) => (
|
||||||
|
<option key={value} value={value}>{value}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="dinsar-filter-field">
|
||||||
|
<span>{language === 'en' ? 'Production engine' : '生产引擎'}</span>
|
||||||
|
<select
|
||||||
|
value={engineFilter}
|
||||||
|
onChange={(event) => setEngineFilter(event.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{engineFilterOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{language === 'en' ? 'items' : '条'}
|
|
||||||
</label>
|
</label>
|
||||||
<label style={{ fontSize: '12px', color: '#4a5568' }}>
|
|
||||||
{language === 'en' ? 'Go to' : '跳到'}
|
<label className="dinsar-filter-field dinsar-filter-field-wide">
|
||||||
|
<span>{language === 'en' ? 'Trace search' : 'Trace 检索'}</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min={1}
|
value={traceSearch}
|
||||||
max={dinsarTotalPages}
|
onChange={(event) => setTraceSearch(event.target.value)}
|
||||||
value={dinsarPageInput}
|
placeholder={language === 'en'
|
||||||
onChange={(event) => {
|
? 'Search pair / run / policy / engine'
|
||||||
setDinsarPageInput(event.target.value);
|
: '搜索 pair / run / policy / engine'}
|
||||||
setDinsarPageInputTouched(false);
|
|
||||||
}}
|
|
||||||
onBlur={() => setDinsarPageInputTouched(true)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
onGoToPage();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{
|
|
||||||
width: '70px',
|
|
||||||
marginLeft: '6px',
|
|
||||||
marginRight: '6px',
|
|
||||||
borderColor: showDinsarPageInputError ? '#e53e3e' : undefined,
|
|
||||||
boxShadow: showDinsarPageInputError ? '0 0 0 1px rgba(229,62,62,0.25)' : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{language === 'en' ? 'page' : '页'}
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" onClick={onGoToPage} disabled={isLoading}>
|
|
||||||
{language === 'en' ? 'Jump' : '跳转'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-row">
|
<div className="dinsar-engine-filter-row">
|
||||||
<span style={{ fontSize: '12px', color: showDinsarPageInputError ? '#e53e3e' : '#718096' }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={engineFilter === DINSAR_ENGINE_ALL ? 'active' : ''}
|
||||||
|
onClick={() => setEngineFilter(DINSAR_ENGINE_ALL)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<span>{language === 'en' ? 'All engines' : '全部引擎'}</span>
|
||||||
|
<strong>{dinsarResults.length}</strong>
|
||||||
|
</button>
|
||||||
|
{engineCounts.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.code}
|
||||||
|
type="button"
|
||||||
|
className={engineFilter === option.code ? 'active' : ''}
|
||||||
|
onClick={() => setEngineFilter(option.code)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<span>{option.shortLabel}</span>
|
||||||
|
<strong>{option.count}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dinsar-toolbar-footer">
|
||||||
|
<div className="dinsar-toolbar-footer-main">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(-1)}
|
||||||
|
disabled={isLoading || dinsarPagination.offset <= 0}
|
||||||
|
>
|
||||||
|
{language === 'en' ? 'Previous' : '上一页'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={isLoading || !dinsarPagination.hasMore}
|
||||||
|
>
|
||||||
|
{language === 'en' ? 'Next' : '下一页'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="dinsar-pagination-field">
|
||||||
|
<span>{language === 'en' ? 'Per page' : '每页条数'}</span>
|
||||||
|
<select
|
||||||
|
value={dinsarPagination.limit}
|
||||||
|
onChange={onPageSizeChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="dinsar-pagination-field dinsar-pagination-field-jump">
|
||||||
|
<span>{language === 'en' ? 'Jump to page' : '跳转页码'}</span>
|
||||||
|
<div className="dinsar-page-jump-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={dinsarTotalPages}
|
||||||
|
value={dinsarPageInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDinsarPageInput(event.target.value);
|
||||||
|
setDinsarPageInputTouched(false);
|
||||||
|
}}
|
||||||
|
onBlur={() => setDinsarPageInputTouched(true)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
onGoToPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={showDinsarPageInputError ? 'has-error' : ''}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={onGoToPage} disabled={isLoading}>
|
||||||
|
{language === 'en' ? 'Jump' : '跳转'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`dinsar-toolbar-hint ${showDinsarPageInputError ? 'error' : ''}`}>
|
||||||
{showDinsarPageInputError
|
{showDinsarPageInputError
|
||||||
? dinsarPageInputValidationError
|
? dinsarPageInputValidationError
|
||||||
: getPageHintText(dinsarTotalPages, language)}
|
: getPageHintText(dinsarTotalPages, language)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,7 +452,7 @@ export default function DinsarResultPanel({
|
|||||||
|
|
||||||
{showExportModal && (
|
{showExportModal && (
|
||||||
<ResultExportModal
|
<ResultExportModal
|
||||||
results={dinsarResults}
|
results={filteredResults}
|
||||||
onClose={() => setShowExportModal(false)}
|
onClose={() => setShowExportModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { useDinsarStore, useHazardStore, useUiStore, useAuthStore } from '../store';
|
|
||||||
import { useI18n } from '../i18n/I18nContext';
|
|
||||||
import VirtualizedList from '../components/common/VirtualizedList';
|
|
||||||
import DinsarResultRow from '../components/panels/DinsarResultRow.rewrite';
|
|
||||||
import ResultExportModal from '../components/ResultExportModal.rewrite';
|
|
||||||
import { PAGE_SIZE_OPTIONS } from '../config/appConstants';
|
|
||||||
import { getPageHintText } from '../utils/appUiHelpers';
|
|
||||||
import {
|
|
||||||
DINSAR_ENGINE_ALL,
|
|
||||||
buildDinsarEngineOptions,
|
|
||||||
getDinsarEngineMeta,
|
|
||||||
} from '../utils/dinsarEngines';
|
|
||||||
import {
|
|
||||||
DINSAR_STRATEGY_ALL,
|
|
||||||
buildDinsarStrategyOptions,
|
|
||||||
filterDinsarResults,
|
|
||||||
} from '../utils/dinsarResultFilters';
|
|
||||||
|
|
||||||
const DINSAR_ROW_HEIGHT = {
|
|
||||||
compact: 136,
|
|
||||||
expanded: 166,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DinsarResultPanel({
|
|
||||||
dinsarCurrentPage,
|
|
||||||
dinsarTotalPages,
|
|
||||||
showDinsarPageInputError,
|
|
||||||
dinsarPageInputValidationError,
|
|
||||||
onSetAllVisibility,
|
|
||||||
onScoreFilterChange,
|
|
||||||
onPageChange,
|
|
||||||
onPageSizeChange,
|
|
||||||
onGoToPage,
|
|
||||||
onToggleVisibility,
|
|
||||||
onLabel,
|
|
||||||
onAnalyze,
|
|
||||||
}) {
|
|
||||||
const { language } = useI18n();
|
|
||||||
const {
|
|
||||||
dinsarResults,
|
|
||||||
dinsarPagination,
|
|
||||||
scoreFilter,
|
|
||||||
engineFilter,
|
|
||||||
traceSearch,
|
|
||||||
strategyFilter,
|
|
||||||
dinsarPageInput,
|
|
||||||
setEngineFilter,
|
|
||||||
setTraceSearch,
|
|
||||||
setStrategyFilter,
|
|
||||||
setDinsarPageInput,
|
|
||||||
setDinsarPageInputTouched,
|
|
||||||
} = useDinsarStore(useShallow((state) => ({
|
|
||||||
dinsarResults: state.dinsarResults,
|
|
||||||
dinsarPagination: state.dinsarPagination,
|
|
||||||
scoreFilter: state.scoreFilter,
|
|
||||||
engineFilter: state.engineFilter,
|
|
||||||
traceSearch: state.traceSearch,
|
|
||||||
strategyFilter: state.strategyFilter,
|
|
||||||
dinsarPageInput: state.dinsarPageInput,
|
|
||||||
setEngineFilter: state.setEngineFilter,
|
|
||||||
setTraceSearch: state.setTraceSearch,
|
|
||||||
setStrategyFilter: state.setStrategyFilter,
|
|
||||||
setDinsarPageInput: state.setDinsarPageInput,
|
|
||||||
setDinsarPageInputTouched: state.setDinsarPageInputTouched,
|
|
||||||
})));
|
|
||||||
const { focusedHazardPoint, setFocusedHazardPoint } = useHazardStore(useShallow((state) => ({
|
|
||||||
focusedHazardPoint: state.focusedHazardPoint,
|
|
||||||
setFocusedHazardPoint: state.setFocusedHazardPoint,
|
|
||||||
})));
|
|
||||||
const { isLoading, showDates, setShowDates } = useUiStore(useShallow((state) => ({
|
|
||||||
isLoading: state.isLoading,
|
|
||||||
showDates: state.showDates,
|
|
||||||
setShowDates: state.setShowDates,
|
|
||||||
})));
|
|
||||||
const { currentUser } = useAuthStore();
|
|
||||||
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
|
|
||||||
const [showExportModal, setShowExportModal] = useState(false);
|
|
||||||
|
|
||||||
const strategyOptions = useMemo(
|
|
||||||
() => buildDinsarStrategyOptions(dinsarResults),
|
|
||||||
[dinsarResults]
|
|
||||||
);
|
|
||||||
const engineOptions = useMemo(
|
|
||||||
() => buildDinsarEngineOptions(dinsarResults),
|
|
||||||
[dinsarResults]
|
|
||||||
);
|
|
||||||
const engineFilterOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
value: DINSAR_ENGINE_ALL,
|
|
||||||
label: language === 'en' ? 'All engines' : '全部引擎',
|
|
||||||
},
|
|
||||||
...engineOptions.map((option) => ({
|
|
||||||
value: option.value,
|
|
||||||
label: option.label,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
[engineOptions, language]
|
|
||||||
);
|
|
||||||
const filteredEngineMeta = useMemo(
|
|
||||||
() => (engineFilter === DINSAR_ENGINE_ALL ? null : getDinsarEngineMeta(engineFilter)),
|
|
||||||
[engineFilter]
|
|
||||||
);
|
|
||||||
const engineCounts = useMemo(() => {
|
|
||||||
const counts = new Map();
|
|
||||||
dinsarResults.forEach((result) => {
|
|
||||||
const meta = getDinsarEngineMeta(result?.engine_code);
|
|
||||||
counts.set(meta.code, (counts.get(meta.code) || 0) + 1);
|
|
||||||
});
|
|
||||||
return engineOptions.map((option) => ({
|
|
||||||
...option,
|
|
||||||
count: counts.get(option.code) || 0,
|
|
||||||
}));
|
|
||||||
}, [dinsarResults, engineOptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (strategyFilter === DINSAR_STRATEGY_ALL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!strategyOptions.includes(strategyFilter)) {
|
|
||||||
setStrategyFilter(DINSAR_STRATEGY_ALL);
|
|
||||||
}
|
|
||||||
}, [setStrategyFilter, strategyFilter, strategyOptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (engineFilter === DINSAR_ENGINE_ALL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!engineOptions.some((option) => option.value === engineFilter)) {
|
|
||||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
|
||||||
}
|
|
||||||
}, [engineFilter, engineOptions, setEngineFilter]);
|
|
||||||
|
|
||||||
const filteredResults = useMemo(
|
|
||||||
() => filterDinsarResults(dinsarResults, {
|
|
||||||
scoreFilter,
|
|
||||||
engineFilter,
|
|
||||||
strategyFilter,
|
|
||||||
traceSearch,
|
|
||||||
focusedHazardPoint,
|
|
||||||
}),
|
|
||||||
[dinsarResults, engineFilter, focusedHazardPoint, scoreFilter, strategyFilter, traceSearch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const scorePercent = Math.round(Number(scoreFilter || 0) * 100);
|
|
||||||
const virtualRowHeight = showDates ? DINSAR_ROW_HEIGHT.expanded : DINSAR_ROW_HEIGHT.compact;
|
|
||||||
const pageSummaryText = language === 'en'
|
|
||||||
? `Page ${dinsarCurrentPage}/${dinsarTotalPages} · ${dinsarPagination.total} total`
|
|
||||||
: `第 ${dinsarCurrentPage}/${dinsarTotalPages} 页 · 共 ${dinsarPagination.total} 条`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="panel-content">
|
|
||||||
{dinsarPagination.total === 0 ? (
|
|
||||||
<p className="empty-state">
|
|
||||||
{language === 'en' ? 'No D-InSAR results found.' : '未找到 D-InSAR 结果。'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="list-toolbar column-layout dinsar-results-toolbar">
|
|
||||||
{focusedHazardPoint && (
|
|
||||||
<div className="filter-banner">
|
|
||||||
<span>
|
|
||||||
{language === 'en' ? (
|
|
||||||
<>
|
|
||||||
Viewing results covering <strong>{focusedHazardPoint.hazard_name}</strong>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
当前仅显示覆盖隐患点 <strong>{focusedHazardPoint.hazard_name}</strong> 的结果
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<button className="clear-filter-btn" onClick={() => setFocusedHazardPoint(null)}>
|
|
||||||
{language === 'en' ? 'Clear Filter' : '清除筛选'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="dinsar-toolbar-grid">
|
|
||||||
<section className="dinsar-toolbar-panel">
|
|
||||||
<span className="dinsar-toolbar-kicker">
|
|
||||||
{language === 'en' ? 'Current page' : '当前页'}
|
|
||||||
</span>
|
|
||||||
<strong className="dinsar-toolbar-value">
|
|
||||||
{filteredResults.length} / {dinsarResults.length}
|
|
||||||
</strong>
|
|
||||||
<p className="dinsar-toolbar-note">
|
|
||||||
{language === 'en'
|
|
||||||
? 'Results after local filtering on the current page'
|
|
||||||
: '当前页本地筛选后的结果数量'}
|
|
||||||
</p>
|
|
||||||
<div className="dinsar-toolbar-chip-row">
|
|
||||||
<span className="dinsar-toolbar-chip">
|
|
||||||
{language === 'en' ? 'AI score' : 'AI 分数'}
|
|
||||||
{' >= '}
|
|
||||||
{scorePercent}
|
|
||||||
</span>
|
|
||||||
<span className="dinsar-toolbar-chip">
|
|
||||||
{language === 'en' ? 'Dates' : '日期'}
|
|
||||||
{showDates
|
|
||||||
? (language === 'en' ? ': visible' : ':已展开')
|
|
||||||
: (language === 'en' ? ': hidden' : ':已收起')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="dinsar-toolbar-panel">
|
|
||||||
<span className="dinsar-toolbar-kicker">
|
|
||||||
{language === 'en' ? 'Engine focus' : '当前引擎'}
|
|
||||||
</span>
|
|
||||||
<strong className="dinsar-toolbar-value">
|
|
||||||
{filteredEngineMeta
|
|
||||||
? filteredEngineMeta.shortLabel
|
|
||||||
: (language === 'en' ? 'All' : '全部')}
|
|
||||||
</strong>
|
|
||||||
<p className="dinsar-toolbar-note">
|
|
||||||
{filteredEngineMeta
|
|
||||||
? filteredEngineMeta.label
|
|
||||||
: (language === 'en'
|
|
||||||
? 'Compare outputs from all registered engines'
|
|
||||||
: '同时查看所有登记引擎的结果')}
|
|
||||||
</p>
|
|
||||||
<div className="dinsar-toolbar-chip-row">
|
|
||||||
<span className="dinsar-toolbar-chip">
|
|
||||||
{language === 'en' ? 'Strategy' : '策略'}:
|
|
||||||
{' '}
|
|
||||||
{strategyFilter === DINSAR_STRATEGY_ALL
|
|
||||||
? (language === 'en' ? 'All' : '全部')
|
|
||||||
: strategyFilter}
|
|
||||||
</span>
|
|
||||||
<span className="dinsar-toolbar-chip">
|
|
||||||
{language === 'en' ? 'Trace search' : '检索词'}:
|
|
||||||
{' '}
|
|
||||||
{traceSearch.trim() || (language === 'en' ? 'None' : '未设置')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="dinsar-toolbar-panel">
|
|
||||||
<span className="dinsar-toolbar-kicker">
|
|
||||||
{language === 'en' ? 'Page control' : '分页控制'}
|
|
||||||
</span>
|
|
||||||
<strong className="dinsar-toolbar-value">{pageSummaryText}</strong>
|
|
||||||
<p className="dinsar-toolbar-note">
|
|
||||||
{language === 'en'
|
|
||||||
? 'Use page size and jump controls below for large catalogs'
|
|
||||||
: '大规模结果集请结合页大小和跳页控制使用'}
|
|
||||||
</p>
|
|
||||||
<div className="dinsar-toolbar-actions">
|
|
||||||
<button onClick={() => onSetAllVisibility(true)}>
|
|
||||||
{language === 'en' ? 'Show All' : '全部显示'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => onSetAllVisibility(false)}>
|
|
||||||
{language === 'en' ? 'Hide All' : '全部隐藏'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowDates(!showDates)}>
|
|
||||||
{showDates
|
|
||||||
? (language === 'en' ? 'Hide Dates' : '收起日期')
|
|
||||||
: (language === 'en' ? 'Show Dates' : '显示日期')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowExportModal(true)}
|
|
||||||
disabled={isLoading || filteredResults.length === 0}
|
|
||||||
title={language === 'en'
|
|
||||||
? 'Export visible results in the current filter scope'
|
|
||||||
: '按当前筛选范围导出结果文件'}
|
|
||||||
>
|
|
||||||
{language === 'en' ? 'Export...' : '提取结果...'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-filter-layout">
|
|
||||||
<label className="dinsar-filter-field">
|
|
||||||
<span>{language === 'en' ? 'AI score floor' : 'AI 分数下限'}</span>
|
|
||||||
<div className="dinsar-score-filter">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value={scoreFilter}
|
|
||||||
onChange={onScoreFilterChange}
|
|
||||||
/>
|
|
||||||
<strong>{scorePercent}</strong>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="dinsar-filter-field">
|
|
||||||
<span>{language === 'en' ? 'Pairing strategy' : '配对策略'}</span>
|
|
||||||
<select
|
|
||||||
value={strategyFilter}
|
|
||||||
onChange={(event) => setStrategyFilter(event.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<option value={DINSAR_STRATEGY_ALL}>
|
|
||||||
{language === 'en' ? 'All strategies' : '全部策略'}
|
|
||||||
</option>
|
|
||||||
{strategyOptions
|
|
||||||
.filter((value) => value !== DINSAR_STRATEGY_ALL)
|
|
||||||
.map((value) => (
|
|
||||||
<option key={value} value={value}>{value}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="dinsar-filter-field">
|
|
||||||
<span>{language === 'en' ? 'Production engine' : '生产引擎'}</span>
|
|
||||||
<select
|
|
||||||
value={engineFilter}
|
|
||||||
onChange={(event) => setEngineFilter(event.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{engineFilterOptions.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="dinsar-filter-field dinsar-filter-field-wide">
|
|
||||||
<span>{language === 'en' ? 'Trace search' : 'Trace 检索'}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={traceSearch}
|
|
||||||
onChange={(event) => setTraceSearch(event.target.value)}
|
|
||||||
placeholder={language === 'en'
|
|
||||||
? 'Search pair / run / policy / engine'
|
|
||||||
: '搜索 pair / run / policy / engine'}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-engine-filter-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={engineFilter === DINSAR_ENGINE_ALL ? 'active' : ''}
|
|
||||||
onClick={() => setEngineFilter(DINSAR_ENGINE_ALL)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<span>{language === 'en' ? 'All engines' : '全部引擎'}</span>
|
|
||||||
<strong>{dinsarResults.length}</strong>
|
|
||||||
</button>
|
|
||||||
{engineCounts.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.code}
|
|
||||||
type="button"
|
|
||||||
className={engineFilter === option.code ? 'active' : ''}
|
|
||||||
onClick={() => setEngineFilter(option.code)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<span>{option.shortLabel}</span>
|
|
||||||
<strong>{option.count}</strong>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dinsar-toolbar-footer">
|
|
||||||
<div className="dinsar-toolbar-footer-main">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPageChange(-1)}
|
|
||||||
disabled={isLoading || dinsarPagination.offset <= 0}
|
|
||||||
>
|
|
||||||
{language === 'en' ? 'Previous' : '上一页'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPageChange(1)}
|
|
||||||
disabled={isLoading || !dinsarPagination.hasMore}
|
|
||||||
>
|
|
||||||
{language === 'en' ? 'Next' : '下一页'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<label className="dinsar-pagination-field">
|
|
||||||
<span>{language === 'en' ? 'Per page' : '每页条数'}</span>
|
|
||||||
<select
|
|
||||||
value={dinsarPagination.limit}
|
|
||||||
onChange={onPageSizeChange}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
|
||||||
<option key={size} value={size}>{size}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="dinsar-pagination-field dinsar-pagination-field-jump">
|
|
||||||
<span>{language === 'en' ? 'Jump to page' : '跳转页码'}</span>
|
|
||||||
<div className="dinsar-page-jump-input">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={dinsarTotalPages}
|
|
||||||
value={dinsarPageInput}
|
|
||||||
onChange={(event) => {
|
|
||||||
setDinsarPageInput(event.target.value);
|
|
||||||
setDinsarPageInputTouched(false);
|
|
||||||
}}
|
|
||||||
onBlur={() => setDinsarPageInputTouched(true)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
onGoToPage();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={showDinsarPageInputError ? 'has-error' : ''}
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={onGoToPage} disabled={isLoading}>
|
|
||||||
{language === 'en' ? 'Jump' : '跳转'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`dinsar-toolbar-hint ${showDinsarPageInputError ? 'error' : ''}`}>
|
|
||||||
{showDinsarPageInputError
|
|
||||||
? dinsarPageInputValidationError
|
|
||||||
: getPageHintText(dinsarTotalPages, language)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="panel-scroll-shell">
|
|
||||||
<VirtualizedList
|
|
||||||
items={filteredResults}
|
|
||||||
itemHeight={virtualRowHeight}
|
|
||||||
getKey={(result) => result.id}
|
|
||||||
renderItem={(result, index, key) => (
|
|
||||||
<DinsarResultRow
|
|
||||||
key={key || `${result.id}-${index}`}
|
|
||||||
result={result}
|
|
||||||
language={language}
|
|
||||||
showDates={showDates}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isReadOnlyUser={isReadOnlyUser}
|
|
||||||
onLabel={onLabel}
|
|
||||||
onAnalyze={onAnalyze}
|
|
||||||
onToggleVisibility={onToggleVisibility}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showExportModal && (
|
|
||||||
<ResultExportModal
|
|
||||||
results={filteredResults}
|
|
||||||
onClose={() => setShowExportModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user