Fix D-InSAR runtime logs and UI encoding

This commit is contained in:
2026-04-16 22:37:09 +08:00
parent 0a8f3331e3
commit a666ed0e5c
11 changed files with 949 additions and 199 deletions
+102
View File
@@ -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 产物说明文案
+2 -2
View File
@@ -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)
+1 -150
View File
@@ -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
+190
View File
@@ -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,
}
+1
View File
@@ -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__)
+54 -1
View File
@@ -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()
+112 -23
View File
@@ -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 && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{description}</div>}
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>建议{recommendation}</div>}
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>推荐{recommendation}</div>}
</div>
);
}
@@ -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 }}
>
×
关闭
</button>
</div>
<pre
@@ -451,7 +495,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{engines.length === 0 && !enginesLoading && (
<span style={{ fontSize: 12, color: '#94a3b8' }}>暂无可用引擎信息</span>
<span style={{ fontSize: 12, color: '#94a3b8' }}>暂无引擎信息</span>
)}
{engines.map(engine => (
<EngineStatusCard
@@ -465,7 +509,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
<div style={card}>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>生产提交</strong>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>提交生产任务</strong>
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 180 }}>
@@ -498,7 +542,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<input
value={rootDir}
onChange={event => setRootDir(event.target.value)}
placeholder="批根目录或单个任务目录"
placeholder="批处理根目录或单个任务目录"
disabled={readOnly}
style={{
width: '100%',
@@ -515,7 +559,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
<div style={{ minWidth: 120 }}>
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
处理任务数0=全部
任务数0 表示全部
</label>
<input
type="number"
@@ -528,7 +572,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
<div style={{ minWidth: 140 }}>
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
超时秒数留空默认
超时时间可选
</label>
<input
type="number"
@@ -565,7 +609,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
borderRadius: 6,
}}
>
这些参数主要影响目标网格尺寸裁剪地理编码范围和位移结果掩膜建议先使用默认值通常优先只调整目标网格尺寸只有在边缘被裁切时间窗异常或噪声较多时再继续调整其他参数
这些参数主要影响目标网格大小精裁剪范围地理编码范围和位移结果掩膜建议先使用默认值通常优先只调整目标网格大小只有在边缘被裁切时间窗异常或噪声较多时再继续调整其他参数
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{Object.entries(currentParamSchema).map(([name, schema]) => (
@@ -600,7 +644,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
{submitting ? '提交中...' : '提交任务'}
</button>
{submitMsg && (
<span style={{ fontSize: 12, color: submitMsg.includes('失败') ? '#ef4444' : '#16a34a' }}>
<span style={{ fontSize: 12, color: submitError ? '#ef4444' : '#16a34a' }}>
{submitMsg}
</span>
)}
@@ -651,7 +695,26 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e', marginBottom: 6 }}>任务日志</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
{!readOnly && (
<button
onClick={handleClearTaskLogs}
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 ? '清空中...' : '清空日志'}
</button>
)}
</div>
{taskLogsLoading ? (
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
) : taskLogs.length === 0 ? (
@@ -660,14 +723,39 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{taskLogs.map((log, index) => (
<div
key={`${log.timestamp || 'log'}-${index}`}
style={{ fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}
key={log.id || `${log.timestamp || 'log'}-${index}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
alignItems: 'flex-start',
}}
>
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
<div style={{ color: '#64748b' }}>
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</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>
)}
@@ -675,7 +763,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
)}
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>最近20条运行记录</div>
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>最近 20 条运行记录</div>
{runsLoading ? (
<div style={{ fontSize: 12, color: '#94a3b8' }}>加载中...</div>
) : runs.length === 0 ? (
@@ -684,7 +772,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['任务编号', '引擎', '状态', '时间', '操作'].map(header => (
{['运行ID', '引擎', '状态', '时间', '操作'].map(header => (
<th
key={header}
style={{ padding: '4px 8px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', color: '#64748b' }}
@@ -722,7 +810,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
background: '#f8fafc',
}}
>
日志
查看日志
</button>
</td>
</tr>
@@ -734,3 +822,4 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
);
}
+102 -16
View File
@@ -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 (
<div style={{ padding: '16px', maxWidth: 960 }}>
<div style={card}>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>产物提取与重扫</strong>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>D-InSAR 产物提取与重扫</strong>
<div
style={{
@@ -151,20 +190,20 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
borderRadius: 6,
}}
>
这里负责把生产目录中的位移结果提取为标准果包并触发结果重扫发布和编目生产运行与参数配置已独立放到D-InSAR生产选项卡
这里负责把生产目录中的位移结果提取为标准果包并触发结果重扫发布和编目生产运行与参数配置已独立放到D-InSAR生产选项卡
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
<input
value={extractRootDir}
onChange={event => setExtractRootDir(event.target.value)}
placeholder="结果根目录(提取位移结果)"
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="目标目录(留空使用默认"
placeholder="目标目录(可选"
style={{ flex: 1, minWidth: 180, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
/>
<button
@@ -209,15 +248,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<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>
<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}
成果目录已同步发布 {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}
标准果包目录同步失败{extractResult.catalog?.message}
</span>
)}
</div>
@@ -245,7 +286,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
</div>
{!activeTask ? (
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有运行中的产物处理任务</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有正在执行的产物处理任务</div>
) : (
<div style={{ padding: '8px 10px', background: '#fefce8', borderRadius: 6, border: '1px solid #fde68a' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#92400e', marginBottom: 4 }}>当前任务</div>
@@ -269,7 +310,26 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
)}
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e', marginBottom: 6 }}>任务日志</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
{!readOnly && (
<button
onClick={handleClearTaskLogs}
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 ? '清空中...' : '清空日志'}
</button>
)}
</div>
{taskLogsLoading ? (
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
) : taskLogs.length === 0 ? (
@@ -278,14 +338,39 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{taskLogs.map((log, index) => (
<div
key={`${log.timestamp || 'log'}-${index}`}
style={{ fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}
key={log.id || `${log.timestamp || 'log'}-${index}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
alignItems: 'flex-start',
}}
>
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
<div style={{ color: '#64748b' }}>
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</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>
)}
@@ -302,3 +387,4 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
</div>
);
}
+1 -1
View File
@@ -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) => {
+372
View File
@@ -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 (
<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;
+6
View File
@@ -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);