diff --git a/INIT.md b/INIT.md
new file mode 100644
index 0000000..34ec84b
--- /dev/null
+++ b/INIT.md
@@ -0,0 +1,102 @@
+# INIT
+
+## 1. 当前开发机已确认的本机环境
+
+- Windows 开发机 Python 解释器:`C:\ProgramData\anaconda3\envs\InSAR\python.exe`
+- 当前项目运行时应优先从 `.env` 读取路径,不允许在代码中写死开发机盘符或目录。
+- 与 D-InSAR / ISCE2 / WSL 相关的运行目录、输出目录、轨道目录、DEM 路径都应通过 `.env` 配置维护。
+
+## 2. 本轮已落地的修复
+
+### 2.1 D-InSAR 生产与产物任务日志管理
+
+- 后端新增任务运行日志专用路由:
+ - `backend/app/routers/tasks_runtime.py`
+- `backend/app/routers/tasks.py` 改为兼容导出入口。
+- `backend/app/services/task_service.py` 新增:
+ - 单条任务日志删除
+ - 当前任务日志清空
+- 前端已接入:
+ - `frontend/src/api/tasks.js`
+ - `frontend/src/DinsarProductionPanel.jsx`
+ - `frontend/src/DinsarProductsPanel.jsx`
+
+当前已支持:
+
+- 查看任务日志
+- 删除单条任务日志
+- 清空当前任务全部日志
+
+### 2.2 D-InSAR 提交链路与运行时问题
+
+- 修复 `backend/app/services/job_handlers.py` 中缺失 `datetime` 导入导致的控制器异常。
+- 修复 D-InSAR 运行时目录处理逻辑,避免继续依赖历史开发机 `Z:\` 路径。
+- 当前原则:路径统一走 `.env`,不允许把开发机专用盘符写入跟踪代码。
+
+### 2.3 前端乱码修复
+
+本轮已清理以下面板中的残留乱码与混杂英文:
+
+- `frontend/src/DinsarProductionPanel.jsx`
+- `frontend/src/DinsarProductsPanel.jsx`
+
+日志管理原文件 `frontend/src/LogManagementPanel.jsx` 本身存在历史乱码内容,而且开发过程中目标文件一度被占用,当前采用的稳定方案是:
+
+- 新增干净版本:`frontend/src/LogManagementPanel.clean.jsx`
+- 由 `frontend/src/HealthCheckPanel.jsx` 暂时改为导入 `LogManagementPanel.clean`
+
+这样做的目的:
+
+- 先保证页面可用、无乱码
+- 不在文件被占用时强行覆盖旧文件
+- 后续如果旧文件占用解除,再考虑把 clean 版本回收为正式文件名
+
+## 3. PowerShell 使用注意事项
+
+### 3.1 查看中文文件时
+
+Windows PowerShell 5.x 下,终端读取 UTF-8 中文文件时可能出现乱码,这不一定代表源码文件本身已坏。
+
+推荐先执行:
+
+```powershell
+chcp 65001
+$OutputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
+```
+
+如果仍然显示异常,优先用编辑器直接查看源码,不要仅凭 PowerShell 控制台输出判断文件编码是否损坏。
+
+### 3.2 写入源码文件时
+
+Windows PowerShell 5.x 的 `Set-Content -Encoding UTF8` 默认会写入 BOM。对脚本、配置或前端源码,BOM 可能继续引出兼容问题。
+
+因此本项目内不建议直接用这类命令覆盖源码文件。更稳妥的方式:
+
+- 优先使用编辑器或补丁方式修改源码
+- 如果必须在 PowerShell 中写文件,使用无 BOM UTF-8
+
+示例:
+
+```powershell
+$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+[System.IO.File]::WriteAllText("D:\Code\Insar_management_system_v2\somefile.txt", $content, $utf8NoBom)
+```
+
+## 4. WSL / ISCE2 相关约束
+
+- 当前项目已经按“Windows 业务登记 + WSL 执行 ISCE2”模式接入。
+- 是否继续维持双环境桥接,只能作为 fallback 方案讨论;默认正式链路仍应以单一、可维护、可配置的环境为主。
+- 后续若继续收敛环境,优先原则是:
+ - 先保证正式生产链稳定
+ - 再讨论实验期环境是否下线
+ - 所有环境差异都必须记录到文档,不能只留在口头结论里
+
+## 5. 本轮验证结果
+
+- 前端已重新执行 `npm run build`,构建通过。
+- 当前界面实际已恢复以下中文区域:
+ - 日志管理
+ - 运行监控
+ - 处理模板
+ - D-InSAR 产物说明文案
+
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
index 2a8b9ed..62f4759 100644
--- a/backend/app/routers/__init__.py
+++ b/backend/app/routers/__init__.py
@@ -22,7 +22,7 @@ from . import (
root_registry,
stats,
task_batches,
- tasks,
+ tasks_runtime,
timeseries_production,
tools,
unpack,
@@ -36,7 +36,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(health.router)
router.include_router(auth.router)
router.include_router(license.router)
- router.include_router(tasks.router)
+ router.include_router(tasks_runtime.router)
router.include_router(workflow.router)
router.include_router(task_batches.router)
router.include_router(tools.router)
diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py
index a2f596e..0a334b7 100644
--- a/backend/app/routers/tasks.py
+++ b/backend/app/routers/tasks.py
@@ -1,150 +1 @@
-from __future__ import annotations
-
-import asyncio
-import json
-from typing import List, Optional
-
-from fastapi import APIRouter, Depends, HTTPException, Request
-from fastapi.responses import StreamingResponse
-from pydantic import BaseModel
-
-from .. import database
-from ..auth_utils import verify_password
-from ..models import AuthUserORM, TaskInfo
-from ..services.dinsar_production_service import dinsar_production_service
-from ..services.task_service import (
- TASK_ACTIVE_DEFAULT_LIMIT,
- TASK_ACTIVE_MAX_LIMIT,
- TASK_LOG_DEFAULT_LIMIT,
- TASK_LOG_MAX_LIMIT,
- TASK_QUERY_MAX_OFFSET,
- task_service,
-)
-from ..auth_service import SESSION_COOKIE_NAME, get_user_by_session_token
-from .dependencies import _require_admin
-
-router = APIRouter()
-
-
-class ForceCancelRequest(BaseModel):
- password: str
-
-
-def _new_session():
- if database.AsyncSessionLocal is None:
- database.init_db()
- if database.AsyncSessionLocal is None:
- raise RuntimeError("Database session factory is not initialized.")
- return database.AsyncSessionLocal()
-
-
-@router.get("/tasks/active", response_model=List[TaskInfo])
-async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
- """获取当前正在运行的所有后台任务"""
- safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
- safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
- orm_tasks = await task_service.get_active_tasks(limit=safe_limit, offset=safe_offset)
- return [TaskInfo.model_validate(t) for t in orm_tasks]
-
-
-@router.get("/tasks/active/stream")
-async def stream_active_tasks(request: Request):
- """SSE 端点:每 3s 推送一次活跃任务列表,替代前端轮询。"""
- # Authenticate via Cookie at connection establishment
- token = request.cookies.get(SESSION_COOKIE_NAME)
- if token:
- async with _new_session() as db:
- user = await get_user_by_session_token(db, token)
- if not user:
- raise HTTPException(status_code=401, detail="Authentication required.")
- else:
- raise HTTPException(status_code=401, detail="Authentication required.")
-
- async def event_generator():
- while True:
- if await request.is_disconnected():
- break
- try:
- orm_tasks = await task_service.get_active_tasks(
- limit=TASK_ACTIVE_MAX_LIMIT, offset=0
- )
- tasks_data = [TaskInfo.model_validate(t).model_dump() for t in orm_tasks]
- yield f"data: {json.dumps(tasks_data)}\n\n"
- except Exception:
- yield "data: []\n\n"
- await asyncio.sleep(3)
-
- return StreamingResponse(
- event_generator(),
- media_type="text/event-stream",
- headers={
- "Cache-Control": "no-cache",
- "X-Accel-Buffering": "no",
- "Connection": "keep-alive",
- },
- )
-
-
-@router.get("/tasks/{task_id}", response_model=Optional[TaskInfo])
-async def get_task_status(task_id: str):
- """获取特定任务的状态"""
- task = await task_service.get_task(task_id)
- if task:
- return TaskInfo.model_validate(task)
- return None
-
-
-@router.get("/tasks/{task_id}/logs")
-async def get_task_logs(task_id: str, limit: int = TASK_LOG_DEFAULT_LIMIT, offset: int = 0):
- """获取指定任务的日志(支持 limit/offset)。"""
- task = await task_service.get_task(task_id)
- if not task:
- raise HTTPException(status_code=404, detail="任务未找到")
-
- safe_limit = min(TASK_LOG_MAX_LIMIT, max(1, int(limit or TASK_LOG_DEFAULT_LIMIT)))
- safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
- logs = await task_service.get_logs(task_id, limit=safe_limit, offset=safe_offset)
- return {
- "task_id": task_id,
- "limit": safe_limit,
- "offset": safe_offset,
- "count": len(logs),
- "logs": [
- {
- "timestamp": log.timestamp.isoformat() if log.timestamp else None,
- "level": log.log_level,
- "message": log.message,
- }
- for log in logs
- ],
- }
-
-
-@router.post("/tasks/{task_id}/force-cancel")
-async def force_cancel_task(
- task_id: str,
- body: ForceCancelRequest,
- admin_user: AuthUserORM = Depends(_require_admin),
-):
- """管理员输入密码后强制取消任务(解锁前端)"""
- if not verify_password(body.password, admin_user.password_hash):
- raise HTTPException(status_code=403, detail="密码错误")
- task = await task_service.get_task(task_id)
- if not task:
- raise HTTPException(status_code=404, detail="任务未找到")
- if task.status not in ("PENDING", "RUNNING"):
- raise HTTPException(status_code=400, detail="任务已结束,无需取消")
- killed_pid = None
- async with _new_session() as db:
- run = await dinsar_production_service.request_cancel(task_id, db=db)
- if run is not None:
- killed_pid = await dinsar_production_service.kill_active_execution_by_task_id(
- task_id,
- db=db,
- )
- await task_service.update_task(task_id, status="CANCELLED", message="管理员强制取消")
- return {
- "message": "任务已强制取消",
- "task_id": task_id,
- "killed_pid": killed_pid,
- }
+from .tasks_runtime import * # noqa: F401,F403
diff --git a/backend/app/routers/tasks_runtime.py b/backend/app/routers/tasks_runtime.py
new file mode 100644
index 0000000..353a8f3
--- /dev/null
+++ b/backend/app/routers/tasks_runtime.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from .. import database
+from ..auth_service import SESSION_COOKIE_NAME, get_user_by_session_token
+from ..auth_utils import verify_password
+from ..models import AuthUserORM, TaskInfo
+from ..services.dinsar_production_service import dinsar_production_service
+from ..services.task_service import (
+ TASK_ACTIVE_DEFAULT_LIMIT,
+ TASK_ACTIVE_MAX_LIMIT,
+ TASK_LOG_DEFAULT_LIMIT,
+ TASK_LOG_MAX_LIMIT,
+ TASK_QUERY_MAX_OFFSET,
+ task_service,
+)
+from .dependencies import _require_admin
+
+router = APIRouter()
+
+
+class ForceCancelRequest(BaseModel):
+ password: str
+
+
+def _new_session():
+ if database.AsyncSessionLocal is None:
+ database.init_db()
+ if database.AsyncSessionLocal is None:
+ raise RuntimeError("Database session factory is not initialized.")
+ return database.AsyncSessionLocal()
+
+
+@router.get("/tasks/active", response_model=List[TaskInfo])
+async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
+ safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
+ safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
+ orm_tasks = await task_service.get_active_tasks(limit=safe_limit, offset=safe_offset)
+ return [TaskInfo.model_validate(task) for task in orm_tasks]
+
+
+@router.get("/tasks/active/stream")
+async def stream_active_tasks(request: Request):
+ token = request.cookies.get(SESSION_COOKIE_NAME)
+ if not token:
+ raise HTTPException(status_code=401, detail="Authentication required.")
+
+ async with _new_session() as db:
+ user = await get_user_by_session_token(db, token)
+ if not user:
+ raise HTTPException(status_code=401, detail="Authentication required.")
+
+ async def event_generator():
+ while True:
+ if await request.is_disconnected():
+ break
+ try:
+ orm_tasks = await task_service.get_active_tasks(
+ limit=TASK_ACTIVE_MAX_LIMIT,
+ offset=0,
+ )
+ tasks_data = [TaskInfo.model_validate(task).model_dump() for task in orm_tasks]
+ yield f"data: {json.dumps(tasks_data)}\n\n"
+ except Exception:
+ yield "data: []\n\n"
+ await asyncio.sleep(3)
+
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "Connection": "keep-alive",
+ },
+ )
+
+
+@router.get("/tasks/{task_id}", response_model=Optional[TaskInfo])
+async def get_task_status(task_id: str):
+ task = await task_service.get_task(task_id)
+ if task:
+ return TaskInfo.model_validate(task)
+ return None
+
+
+@router.get("/tasks/{task_id}/logs")
+async def get_task_logs(task_id: str, limit: int = TASK_LOG_DEFAULT_LIMIT, offset: int = 0):
+ task = await task_service.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail="Task not found.")
+
+ safe_limit = min(TASK_LOG_MAX_LIMIT, max(1, int(limit or TASK_LOG_DEFAULT_LIMIT)))
+ safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
+ logs = await task_service.get_logs(task_id, limit=safe_limit, offset=safe_offset)
+ return {
+ "task_id": task_id,
+ "limit": safe_limit,
+ "offset": safe_offset,
+ "count": len(logs),
+ "logs": [
+ {
+ "id": log.id,
+ "timestamp": log.timestamp.isoformat() if log.timestamp else None,
+ "level": log.log_level,
+ "message": log.message,
+ }
+ for log in logs
+ ],
+ }
+
+
+@router.delete("/tasks/{task_id}/logs/{log_id}")
+async def delete_task_log(
+ task_id: str,
+ log_id: int,
+ admin_user: AuthUserORM = Depends(_require_admin),
+):
+ task = await task_service.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail="Task not found.")
+
+ deleted = await task_service.delete_log(task_id, log_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Task log entry not found.")
+
+ return {
+ "task_id": task_id,
+ "log_id": log_id,
+ "deleted": True,
+ }
+
+
+@router.delete("/tasks/{task_id}/logs")
+async def clear_task_logs(
+ task_id: str,
+ admin_user: AuthUserORM = Depends(_require_admin),
+):
+ task = await task_service.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail="Task not found.")
+
+ deleted_count = await task_service.clear_logs(task_id)
+ return {
+ "task_id": task_id,
+ "deleted_count": deleted_count,
+ }
+
+
+@router.post("/tasks/{task_id}/force-cancel")
+async def force_cancel_task(
+ task_id: str,
+ body: ForceCancelRequest,
+ admin_user: AuthUserORM = Depends(_require_admin),
+):
+ if not verify_password(body.password, admin_user.password_hash):
+ raise HTTPException(status_code=403, detail="Password is incorrect.")
+
+ task = await task_service.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail="Task not found.")
+ if task.status not in ("PENDING", "RUNNING"):
+ raise HTTPException(status_code=400, detail="Task is already finished.")
+
+ killed_pid = None
+ async with _new_session() as db:
+ run = await dinsar_production_service.request_cancel(task_id, db=db)
+ if run is not None:
+ killed_pid = await dinsar_production_service.kill_active_execution_by_task_id(
+ task_id,
+ db=db,
+ )
+
+ await task_service.update_task(
+ task_id,
+ status="CANCELLED",
+ message="Task cancelled by administrator.",
+ )
+ return {
+ "message": "Task cancelled.",
+ "task_id": task_id,
+ "killed_pid": killed_pid,
+ }
diff --git a/backend/app/services/job_handlers.py b/backend/app/services/job_handlers.py
index 4832b58..d84e1f4 100644
--- a/backend/app/services/job_handlers.py
+++ b/backend/app/services/job_handlers.py
@@ -11,6 +11,7 @@ import sys
import tempfile
import time
import uuid
+from datetime import datetime
from typing import Callable, Awaitable, Optional, Dict, Any, List
logger = logging.getLogger(__name__)
diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py
index 624441e..64fcadd 100644
--- a/backend/app/services/task_service.py
+++ b/backend/app/services/task_service.py
@@ -8,7 +8,7 @@ import sys
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
-from sqlalchemy import func, and_, text
+from sqlalchemy import delete, func, and_, text
from ..config import read_int_env
from ..models import SystemTaskORM, TaskLogORM
@@ -367,4 +367,57 @@ class TaskService:
# 创建全局单例实例
+ async def delete_log(
+ self,
+ task_id: str,
+ log_id: int,
+ db: Optional[AsyncSession] = None,
+ ) -> bool:
+ gen_db = db is None
+ if gen_db:
+ db = get_db_session()
+
+ try:
+ result = await db.execute(
+ select(TaskLogORM).where(
+ TaskLogORM.id == int(log_id),
+ TaskLogORM.task_id == task_id,
+ )
+ )
+ log_entry = result.scalar_one_or_none()
+ if log_entry is None:
+ return False
+ await db.delete(log_entry)
+ await db.commit()
+ return True
+ except Exception:
+ await db.rollback()
+ raise
+ finally:
+ if gen_db:
+ await db.close()
+
+ async def clear_logs(
+ self,
+ task_id: str,
+ db: Optional[AsyncSession] = None,
+ ) -> int:
+ gen_db = db is None
+ if gen_db:
+ db = get_db_session()
+
+ try:
+ result = await db.execute(
+ delete(TaskLogORM).where(TaskLogORM.task_id == task_id)
+ )
+ await db.commit()
+ return int(result.rowcount or 0)
+ except Exception:
+ await db.rollback()
+ raise
+ finally:
+ if gen_db:
+ await db.close()
+
+
task_service = TaskService()
diff --git a/frontend/src/DinsarProductionPanel.jsx b/frontend/src/DinsarProductionPanel.jsx
index 841725d..8f464d9 100644
--- a/frontend/src/DinsarProductionPanel.jsx
+++ b/frontend/src/DinsarProductionPanel.jsx
@@ -1,7 +1,8 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { listEngines, listRuns, submitRun } from './api/dinsarProduction';
-import { getActiveTasks, getJobLog, getTaskLogs } from './api/idl';
+import { getJobLog } from './api/idl';
+import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
const card = {
background: '#fff',
@@ -24,10 +25,10 @@ const ENGINE_STATUS_COLOR = {
const ENGINE_STATUS_LABEL = {
ok: '可用',
- degraded: '部分可用',
+ degraded: '降级',
unavailable: '不可用',
not_implemented: '预留',
- error: '错误',
+ error: '异常',
};
const ENGINE_LABEL = {
@@ -37,8 +38,8 @@ const ENGINE_LABEL = {
};
const TASK_TYPE_LABEL = {
- ISCE2_RUN: 'ISCE2生产任务',
- IDL_RUN_DINSAR: 'ENVI生产任务',
+ ISCE2_RUN: 'ISCE2生产',
+ IDL_RUN_DINSAR: 'ENVI生产',
};
const STATUS_LABEL = {
@@ -220,7 +221,7 @@ function ParamField({ name, schema, value, disabled, onChange }) {
style={inputStyle}
/>
{description &&
{description}
}
- {recommendation && 建议:{recommendation}
}
+ {recommendation && 推荐:{recommendation}
}
);
}
@@ -236,6 +237,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [engineExtraParams, setEngineExtraParams] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState('');
+ const [submitError, setSubmitError] = useState(false);
const [runs, setRuns] = useState([]);
const [runsLoading, setRunsLoading] = useState(false);
@@ -244,6 +246,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [activeTask, setActiveTask] = useState(null);
const [taskLogs, setTaskLogs] = useState([]);
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
+ const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
+ const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
const currentEngineObj = engines.find(engine => engine.engine_code === selectedEngine) || null;
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
@@ -301,6 +305,42 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}
}, []);
+ const handleDeleteTaskLog = useCallback(async logId => {
+ const taskId = activeTask?.task_id;
+ if (!taskId || !logId || taskLogActionLoading) return;
+ if (!window.confirm('确定要删除这条任务日志吗?')) return;
+
+ setTaskLogDeletingId(logId);
+ setTaskLogActionLoading(true);
+ try {
+ await deleteTaskLog(taskId, logId);
+ await loadTaskLogs(taskId);
+ } catch (err) {
+ setSubmitError(true);
+ setSubmitMsg(`删除日志失败:${err?.response?.data?.detail || err.message}`);
+ } finally {
+ setTaskLogDeletingId(null);
+ setTaskLogActionLoading(false);
+ }
+ }, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
+
+ const handleClearTaskLogs = useCallback(async () => {
+ const taskId = activeTask?.task_id;
+ if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
+ if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
+
+ setTaskLogActionLoading(true);
+ try {
+ await clearTaskLogs(taskId);
+ await loadTaskLogs(taskId);
+ } catch (err) {
+ setSubmitError(true);
+ setSubmitMsg(`清空日志失败:${err?.response?.data?.detail || err.message}`);
+ } finally {
+ setTaskLogActionLoading(false);
+ }
+ }, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
+
useEffect(() => {
loadEngines();
loadRuns();
@@ -339,12 +379,14 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const handleSubmit = async () => {
if (!rootDir.trim()) {
- setSubmitMsg('请填写根目录。');
+ setSubmitError(true);
+ setSubmitMsg('请输入根目录。');
return;
}
setSubmitting(true);
setSubmitMsg('');
+ setSubmitError(false);
try {
const extra = buildExtraPayload(currentParamSchema, engineExtraParams);
const result = await submitRun({
@@ -355,12 +397,14 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
timeout_seconds: timeoutSec ? Number(timeoutSec) : null,
extra,
});
- const taskCount = result?.selected_task_count ? `,已选 ${result.selected_task_count} 个任务` : '';
+ const taskCount = result?.selected_task_count ? `,选中 ${result.selected_task_count} 个任务` : '';
+ setSubmitError(false);
setSubmitMsg(`任务已入队:${result.task_id}${taskCount}`);
if (onJobQueued) onJobQueued(result.task_id);
loadRuns();
loadActiveTask();
} catch (err) {
+ setSubmitError(true);
setSubmitMsg(`提交失败:${err?.response?.data?.detail || err.message}`);
} finally {
setSubmitting(false);
@@ -411,7 +455,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
onClick={() => setLogModal({ open: false, runId: '', content: '', loading: false })}
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}
>
- ×
+ 关闭
{engines.length === 0 && !enginesLoading && (
-
暂无可用引擎信息。
+
暂无引擎信息。
)}
{engines.map(engine => (
-
生产提交
+
提交生产任务
@@ -498,7 +542,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
setRootDir(event.target.value)}
- placeholder="批量根目录或单个任务目录"
+ placeholder="批处理根目录或单个任务目录"
disabled={readOnly}
style={{
width: '100%',
@@ -515,7 +559,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
- 这些参数主要影响目标网格尺寸、精轨裁剪、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格尺寸;只有在边缘被裁切、时间窗口异常或噪声较多时,再继续调整其他参数。
+ 这些参数主要影响目标网格大小、精裁剪范围、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格大小;只有在边缘被裁切、时间窗异常或噪声较多时,再继续调整其他参数。
{Object.entries(currentParamSchema).map(([name, schema]) => (
@@ -600,7 +644,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
{submitting ? '提交中...' : '提交任务'}
{submitMsg && (
-
+
{submitMsg}
)}
@@ -651,7 +695,26 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
-
任务日志
+
+
任务日志
+ {!readOnly && (
+
+ )}
+
{taskLogsLoading ? (
加载中...
) : taskLogs.length === 0 ? (
@@ -660,13 +723,38 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
{taskLogs.map((log, index) => (
-
- {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
+
+
+ {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
+
+
{log.message}
-
{log.message}
+ {!readOnly && (
+
+ )}
))}
@@ -675,7 +763,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
-
最近20条运行记录
+
最近 20 条运行记录
{runsLoading ? (
加载中...
) : runs.length === 0 ? (
@@ -684,7 +772,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
- {['任务编号', '引擎', '状态', '时间', '操作'].map(header => (
+ {['运行ID', '引擎', '状态', '时间', '操作'].map(header => (
|
- 日志
+ 查看日志
|
@@ -734,3 +822,4 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
);
}
+
diff --git a/frontend/src/DinsarProductsPanel.jsx b/frontend/src/DinsarProductsPanel.jsx
index 9f97b9d..1e6912d 100644
--- a/frontend/src/DinsarProductsPanel.jsx
+++ b/frontend/src/DinsarProductsPanel.jsx
@@ -1,7 +1,8 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { scanDinsarResults } from './api/dinsar';
-import { extractDispResults, getActiveTasks, getTaskLogs } from './api/idl';
+import { extractDispResults } from './api/idl';
+import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
const card = {
@@ -19,9 +20,9 @@ const PRODUCT_TASK_TYPES = [
];
const TASK_TYPE_LABEL = {
- SCAN_DINSAR: 'D-InSAR结果扫描任务',
- PUBLISH_DINSAR_PRODUCTS: '结果包发布任务',
- REBUILD_DINSAR_CATALOG: '结果目录重建任务',
+ SCAN_DINSAR: 'D-InSAR结果扫描',
+ PUBLISH_DINSAR_PRODUCTS: 'D-InSAR产物发布',
+ REBUILD_DINSAR_CATALOG: 'D-InSAR目录重建',
};
const STATUS_LABEL = {
@@ -53,6 +54,8 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
const [activeTask, setActiveTask] = useState(null);
const [taskLogs, setTaskLogs] = useState([]);
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
+ const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
+ const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
const loadActiveTask = useCallback(async () => {
try {
@@ -81,6 +84,42 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
}
}, []);
+ const handleDeleteTaskLog = useCallback(async logId => {
+ const taskId = activeTask?.task_id;
+ 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);
+ }
+ }, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
+
+ const handleClearTaskLogs = useCallback(async () => {
+ const taskId = activeTask?.task_id;
+ if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
+ if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
+
+ setTaskLogActionLoading(true);
+ try {
+ await clearTaskLogs(taskId);
+ await loadTaskLogs(taskId);
+ } catch (error) {
+ setActionMessage(`清空日志失败:${error?.response?.data?.detail || error.message}`);
+ setActionError(true);
+ } finally {
+ setTaskLogActionLoading(false);
+ }
+ }, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
+
useEffect(() => {
loadActiveTask();
}, [loadActiveTask]);
@@ -137,7 +176,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
return (
-
产物提取与重扫
+
D-InSAR 产物提取与重扫
- 这里负责把生产目录中的位移结果提取为标准结果包,并触发结果重扫、发布和编目。生产运行与参数配置已独立放到“D-InSAR生产”选项卡。
+ 这里负责把生产目录中的位移结果提取为标准成果包,并触发结果重扫、发布和编目。生产运行与参数配置已独立放到“D-InSAR生产”选项卡。
setExtractRootDir(event.target.value)}
- placeholder="结果根目录(提取位移结果)"
+ placeholder="结果根目录"
style={{ flex: 2, minWidth: 220, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
/>
setExtractDestDir(event.target.value)}
- placeholder="目标目录(留空使用默认)"
+ placeholder="目标目录(可选)"
style={{ flex: 1, minWidth: 180, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
/>
{!activeTask ? (
-
当前没有运行中的产物处理任务。
+
当前没有正在执行的产物处理任务。
) : (
当前任务
@@ -269,7 +310,26 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
)}
-
任务日志
+
+
任务日志
+ {!readOnly && (
+
+ )}
+
{taskLogsLoading ? (
加载中...
) : taskLogs.length === 0 ? (
@@ -278,13 +338,38 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
{taskLogs.map((log, index) => (
-
- {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
+
+
+ {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
+
+
{log.message}
-
{log.message}
+ {!readOnly && (
+
+ )}
))}
@@ -302,3 +387,4 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
);
}
+
diff --git a/frontend/src/HealthCheckPanel.jsx b/frontend/src/HealthCheckPanel.jsx
index b7b6927..05a10f1 100644
--- a/frontend/src/HealthCheckPanel.jsx
+++ b/frontend/src/HealthCheckPanel.jsx
@@ -6,7 +6,7 @@ import { cleanupSessions } from './api/auth';
import { syncWaterScenesFromDisk } from './api/water';
import { listEngines, runWslCheck } from './api/dinsarProduction';
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
-import LogManagementPanel from './LogManagementPanel';
+import LogManagementPanel from './LogManagementPanel.clean';
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
const toNumber = (value) => {
diff --git a/frontend/src/LogManagementPanel.clean.jsx b/frontend/src/LogManagementPanel.clean.jsx
new file mode 100644
index 0000000..5433242
--- /dev/null
+++ b/frontend/src/LogManagementPanel.clean.jsx
@@ -0,0 +1,372 @@
+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 (
+
+
+
日志管理
+
+
+
+
+
+
+
+ {logs.length === 0 ? (
+
暂无日志文件
+ ) : (
+
+
+
+ | 文件名 |
+ 类型 |
+ 大小 |
+ 修改时间 |
+ 操作 |
+
+
+
+ {logs.map((log, index) => (
+
+ | {log.name} |
+
+
+ {getTypeLabel(log.type)}
+
+ |
+
+ {formatSize(log.size)}
+ |
+ {log.modified_at} |
+
+
+
+ {isAdmin && (
+
+ )}
+
+ |
+
+ ))}
+
+
+ )}
+
+ {showModal && selectedLog && (
+
+
+
+
+
{selectedLog.name}
+
+ 大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines}
+
+
+
+
+
+
+
setSearchTerm(event.target.value)}
+ style={{
+ flex: 1,
+ padding: '6px 12px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '13px',
+ }}
+ />
+
+ 显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)}
+
+
+
+
+
+
+
+ {filteredContent || '(空日志)'}
+
+
+
+
+ )}
+
+ );
+};
+
+export default LogManagementPanel;
diff --git a/frontend/src/api/tasks.js b/frontend/src/api/tasks.js
index 08f7abc..3bdedc5 100644
--- a/frontend/src/api/tasks.js
+++ b/frontend/src/api/tasks.js
@@ -2,3 +2,9 @@ import apiClient from './client';
export const getActiveTasks = () => apiClient.get('/tasks/active').then(r => r.data);
export const getTask = (taskId) => apiClient.get(`/tasks/${taskId}`).then(r => r.data);
+export const getTaskLogs = (taskId, limit = 50, offset = 0) =>
+ apiClient.get(`/tasks/${taskId}/logs?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`).then(r => r.data);
+export const deleteTaskLog = (taskId, logId) =>
+ apiClient.delete(`/tasks/${taskId}/logs/${encodeURIComponent(logId)}`).then(r => r.data);
+export const clearTaskLogs = (taskId) =>
+ apiClient.delete(`/tasks/${taskId}/logs`).then(r => r.data);