feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow
本次同步 09-12 至 09-28 的代码变更,主要内容: 新增功能 - 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService 对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口 - 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、 ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器 - 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator - 新增 tools/minio-file-server MinIO 文件服务 功能增强 - 数据清洗:cleaning_task_service/scheduler/routes 重构, data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整 - 数据标注:annotation sync.py、project.py 接口与schema 更新 - 数据采集、算子市场、首页与菜单路由相关页面调整 - DataJuicer 算子接入(useOperatorOperations、dj_ops_schema) 结构调整 - deepflow/ 更名为 deep-flow/(419 个文件重命名) - 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本 未纳入本次提交 - scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录) - *.backup*、__pycache__ 产物 - zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物) - DataX/(内嵌独立仓库,远程亦未收录其源码)
This commit is contained in:
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "edeepdata",
|
||||
"name": "eDeepData",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "edeepdata",
|
||||
"name": "eDeepData",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "edeepdata",
|
||||
"name": "eDeepData",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DeepData 登录中…</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%);
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.12);
|
||||
padding: 40px 48px;
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px; height: 32px;
|
||||
margin: 0 auto 16px;
|
||||
border: 3px solid #dbeafe;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
h1 { font-size: 18px; color: #1f2937; margin-bottom: 8px; }
|
||||
p { font-size: 14px; color: #6b7280; }
|
||||
.error { color: #dc2626; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div id="spinner" class="spinner"></div>
|
||||
<h1 id="title">DeepData 登录处理中…</h1>
|
||||
<p id="desc">正在完成单点登录,请稍候。</p>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var token = params.get("sso_token");
|
||||
var logout = params.get("sso_logout");
|
||||
var error = params.get("sso_error");
|
||||
var errDesc = params.get("sso_error_description") || "";
|
||||
|
||||
var title = document.getElementById("title");
|
||||
var desc = document.getElementById("desc");
|
||||
var spinner = document.getElementById("spinner");
|
||||
|
||||
function fail(msg) {
|
||||
spinner.style.display = "none";
|
||||
title.textContent = "登录失败";
|
||||
title.className = "error";
|
||||
desc.innerHTML = (msg ? msg + "<br/>" : "") +
|
||||
'<a href="/#/">返回首页</a>';
|
||||
}
|
||||
|
||||
if (error) {
|
||||
fail(decodeURIComponent(errDesc || "单点登录未完成,请重试"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (logout) {
|
||||
// 登出回调:清理本地会话
|
||||
try { localStorage.removeItem("session"); } catch (e) {}
|
||||
window.location.replace("/#/");
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// SSO 登录成功:持久化会话(与账号密码登录的 localStorage.session 结构一致)
|
||||
localStorage.setItem("session", JSON.stringify({
|
||||
token: token,
|
||||
authMode: "SSO"
|
||||
}));
|
||||
} catch (e) {
|
||||
fail("保存登录状态失败,请检查浏览器是否允许本地存储");
|
||||
return;
|
||||
}
|
||||
window.location.replace("/#/");
|
||||
return;
|
||||
}
|
||||
|
||||
// 缺少任何参数:视为异常
|
||||
fail("未收到有效的登录结果");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface AudioPreviewProps {
|
||||
blob?: Blob;
|
||||
blobUrl?: string; // 🔴 新增
|
||||
}
|
||||
|
||||
export const AudioPreview: React.FC<AudioPreviewProps> = ({ blob, blobUrl }) => {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (blobUrl) {
|
||||
setUrl(blobUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
const type = blob.type || 'audio/mpeg';
|
||||
const blobObj = new Blob([blob], { type });
|
||||
const u = URL.createObjectURL(blobObj);
|
||||
setUrl(u);
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}
|
||||
setUrl(null);
|
||||
}, [blob, blobUrl]);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
正在加载音频...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full bg-gray-50 p-10">
|
||||
<div className="mb-4 text-6xl">🎵</div>
|
||||
<audio controls src={url} className="w-full max-w-md" style={{ outline: 'none' }}>
|
||||
您的浏览器不支持 HTML5 音频标签。
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Spin } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export interface ExcelPreviewProps {
|
||||
blob?: Blob;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
interface DataType {
|
||||
key: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ✅ 注意这里的 export 关键字,必须与 index.tsx 中的 { ExcelPreview } 匹配
|
||||
export const ExcelPreview: React.FC<ExcelPreviewProps> = ({ blob, fileName }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [headers, setHeaders] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<DataType[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blob) {
|
||||
setLoading(false);
|
||||
setError('未提供文件数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json<DataType>(worksheet, { defval: '' });
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
setHeaders([]);
|
||||
setRows([]);
|
||||
} else {
|
||||
const cols = Object.keys(jsonData[0]);
|
||||
setHeaders(cols);
|
||||
const formattedRows = jsonData.map((row, idx) => ({ key: idx, ...row }));
|
||||
setRows(formattedRows);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to parse Excel file:', err);
|
||||
setError(err.message || 'Excel 解析失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError('文件读取失败');
|
||||
setLoading(false);
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
}, [blob]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Spin size="large" tip="正在解析 Excel 文件..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-red-500">
|
||||
<div className="text-center">
|
||||
<p className="text-lg mb-2">⚠️ Excel 解析失败</p>
|
||||
<p className="text-sm text-gray-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (headers.length === 0) {
|
||||
return <div className="flex items-center justify-center h-full text-gray-400">表格为空</div>;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DataType> = headers.map(header => ({
|
||||
title: header,
|
||||
dataIndex: header,
|
||||
key: header,
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (text: any) => String(text ?? '-')
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{fileName && (
|
||||
<div className="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<span className="text-sm text-gray-600">
|
||||
📄 {fileName} ({rows.length} 行 × {headers.length} 列)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={{ pageSize: 50 }}
|
||||
size="small"
|
||||
bordered
|
||||
scroll={{ x: 'max-content', y: 'calc(100vh - 200px)' }}
|
||||
rowKey="key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,31 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface ImagePreviewProps {
|
||||
blobUrl?: string;
|
||||
blob?: Blob;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export const ImagePreview: React.FC<ImagePreviewProps> = ({
|
||||
blobUrl,
|
||||
blob,
|
||||
alt = 'Preview'
|
||||
}) => {
|
||||
if (!blobUrl) {
|
||||
const [internalUrl, setInternalUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (blobUrl) {
|
||||
setInternalUrl(blobUrl);
|
||||
} else if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setInternalUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setInternalUrl(null);
|
||||
}
|
||||
}, [blob, blobUrl]);
|
||||
|
||||
if (!internalUrl) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
No image available
|
||||
@@ -18,11 +34,11 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-gray-50">
|
||||
<div className="flex items-center justify-center h-full bg-gray-50 p-4">
|
||||
<img
|
||||
src={blobUrl}
|
||||
src={internalUrl}
|
||||
alt={alt}
|
||||
className="max-w-full max-h-[600px] object-contain"
|
||||
className="max-w-full max-h-[600px] object-contain shadow-sm rounded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw'; // 新增:用于解析 Markdown 中的 HTML 标签
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
@@ -26,7 +27,13 @@ export const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
|
||||
<div className="prose prose-sm max-w-none markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]} // ✅ 开启 HTML 解析支持
|
||||
urlTransform={(url) => url} // ✅ 放行 data:image 等所有协议
|
||||
components={{
|
||||
// ✅ 自定义图片渲染器,解决 Base64 图片显示问题并限制最大宽度
|
||||
img: ({ node, ...props }) => (
|
||||
<img {...props} style={{ maxWidth: '100%', height: 'auto' }} />
|
||||
),
|
||||
// 代码块高亮
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
@@ -77,7 +84,7 @@ export const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 表格
|
||||
// 表格 (保持原有样式,rehypeRaw 会让 HTML table 进入此组件)
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-4">
|
||||
<table className="min-w-full divide-y divide-gray-200 border">
|
||||
|
||||
@@ -1,106 +1,59 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
|
||||
// 强制指定 Worker 路径,解决动态导入失败问题
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
interface PdfPreviewProps {
|
||||
export interface PdfPreviewProps {
|
||||
fileName?: string;
|
||||
blob?: Blob;
|
||||
fileUrl?: string;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
export const PdfPreview: React.FC<PdfPreviewProps> = ({ fileName, blob, fileUrl, scale = 1.0 }) => {
|
||||
const [numPages, setNumPages] = useState<number | null>(null);
|
||||
// ✅ 方案:使用原生 iframe 渲染,解决 Edge 崩溃问题
|
||||
export const PdfPreview: React.FC<PdfPreviewProps> = ({ blob, fileUrl }) => {
|
||||
const [docUrl, setDocUrl] = useState<string | null>(null);
|
||||
|
||||
// 🔄 将 Blob 转换为 URL
|
||||
useEffect(() => {
|
||||
if (fileUrl) {
|
||||
setDocUrl(fileUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
// 🛠️ 核心修复:强制指定 MIME 类型为 application/pdf
|
||||
// 如果不指定类型,浏览器会将其视为未知文件并直接触发下载
|
||||
const pdfBlob = new Blob([blob], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(pdfBlob);
|
||||
setDocUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}
|
||||
|
||||
setDocUrl(null);
|
||||
}, [blob, fileUrl]);
|
||||
|
||||
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
|
||||
setNumPages(numPages);
|
||||
};
|
||||
|
||||
// 防御性检查:无数据时
|
||||
if (!docUrl) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#999', background: '#525659' }}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
height: '100%', color: '#999', background: '#f5f5f5'
|
||||
}}>
|
||||
未提供 PDF 文件或数据正在初始化...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 🎨 统一的加载指示器(仅显示一次)
|
||||
const LoadingUI = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '400px',
|
||||
color: '#fff'
|
||||
}}>
|
||||
{/* 纯 CSS 旋转动画,不依赖任何外部库 */}
|
||||
<div style={{
|
||||
width: '40px', height: '40px',
|
||||
border: '4px solid rgba(255,255,255,0.3)',
|
||||
borderTop: '4px solid #fff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: '15px'
|
||||
}}></div>
|
||||
<span>正在解析 PDF 文档...</span>
|
||||
{/* 注入关键帧动画 */}
|
||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
backgroundColor: '#525659',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '20px'
|
||||
}}
|
||||
>
|
||||
<Document
|
||||
file={docUrl}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={(error) => console.error('PDF 加载失败:', error)}
|
||||
// ✅ 核心修复:只保留这一个 loading 属性,彻底消除重复提示
|
||||
loading={<LoadingUI />}
|
||||
>
|
||||
{numPages && Array.from(new Array(numPages), (el, index) => (
|
||||
<div key={`page_${index + 1}`} style={{ marginBottom: '20px', boxShadow: '0 4px 9px rgba(0,0,0,0.5)' }}>
|
||||
<Page
|
||||
pageNumber={index + 1}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Document>
|
||||
<div style={{ height: '100%', width: '100%', background: '#525659' }}>
|
||||
<iframe
|
||||
src={docUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: 'block'
|
||||
}}
|
||||
title="PDF Preview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface VideoPreviewProps {
|
||||
blob?: Blob;
|
||||
blobUrl?: string; // 🔴 新增:支持直接传入 URL
|
||||
}
|
||||
|
||||
export const VideoPreview: React.FC<VideoPreviewProps> = ({ blob, blobUrl }) => {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. 如果有直接的 URL,直接使用
|
||||
if (blobUrl) {
|
||||
setUrl(blobUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 如果是 Blob 对象,转换为 URL
|
||||
if (blob) {
|
||||
// 尝试从 blob.type 获取类型,如果没有则根据常见视频格式兜底
|
||||
const type = blob.type || 'video/mp4';
|
||||
const blobObj = new Blob([blob], { type });
|
||||
const u = URL.createObjectURL(blobObj);
|
||||
setUrl(u);
|
||||
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}
|
||||
|
||||
setUrl(null);
|
||||
}, [blob, blobUrl]);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
正在加载视频数据...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-black">
|
||||
<video
|
||||
controls
|
||||
src={url}
|
||||
className="max-w-full max-h-[600px]"
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
您的浏览器不支持 HTML5 视频标签。
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ImagePreview } from './ImagePreview';
|
||||
import { VideoPreview } from './VideoPreview';
|
||||
import { AudioPreview } from './AudioPreview';
|
||||
import { MarkdownPreview } from './MarkdownPreview';
|
||||
import { DocxPreview } from './DocxPreview';
|
||||
import { PdfPreview } from './PdfPreview';
|
||||
@@ -7,6 +9,7 @@ import { JsonPreview } from './JsonPreview';
|
||||
import { CsvPreview } from './CsvPreview';
|
||||
import { CodePreview } from './CodePreview';
|
||||
import { TextPreview } from './TextPreview';
|
||||
import { ExcelPreview } from './ExcelPreview';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
fileName?: string;
|
||||
@@ -32,7 +35,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-red-500">
|
||||
<div className="text-center">
|
||||
<p className="text-lg mb-2">⚠️ Preview Failed</p>
|
||||
<p className="text-lg mb-2">⚠️ 预览失败</p>
|
||||
<p className="text-sm text-gray-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,15 +48,27 @@ export const FilePreview: React.FC<FilePreviewProps> = ({
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
<span>Loading preview...</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 根据文件扩展名选择预览器
|
||||
// 🔴 视频支持
|
||||
if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) {
|
||||
// 🔴 修改:同时传入 blob 和 blobUrl
|
||||
return <VideoPreview blob={blob} blobUrl={blobUrl} />;
|
||||
}
|
||||
|
||||
// 🔴 音频支持
|
||||
if (['mp3', 'wav', 'ogg', 'aac', 'flac'].includes(ext)) {
|
||||
// 🔴 修改:同时传入 blob 和 blobUrl
|
||||
return <AudioPreview blob={blob} blobUrl={blobUrl} />;
|
||||
}
|
||||
|
||||
// 🔴 图片支持 (png, jpg, jpeg, gif, webp, bmp, svg)
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
|
||||
return <ImagePreview blobUrl={blobUrl} />;
|
||||
return <ImagePreview blobUrl={blobUrl} blob={blob} />;
|
||||
}
|
||||
|
||||
if (ext === 'md' || ext === 'markdown') {
|
||||
@@ -65,10 +80,13 @@ export const FilePreview: React.FC<FilePreviewProps> = ({
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
// 🎯 注意:这里传递的是 blob,PdfPreview 内部已处理转换
|
||||
return <PdfPreview fileName={fileName} blob={blob} />;
|
||||
}
|
||||
|
||||
if (ext === 'xlsx' || ext === 'xls') {
|
||||
return <ExcelPreview fileName={fileName} blob={blob} />;
|
||||
}
|
||||
|
||||
if (ext === 'json') {
|
||||
return <JsonPreview fileName={fileName} content={content} />;
|
||||
}
|
||||
@@ -85,5 +103,4 @@ export const FilePreview: React.FC<FilePreviewProps> = ({
|
||||
return <TextPreview content={content} />;
|
||||
};
|
||||
|
||||
// 🔑 核心修复:必须保留默认导出,否则 Overview.tsx 会报 Build Failed
|
||||
export default FilePreview;
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get } from "@/utils/request";
|
||||
import type { RoleKey } from "@/pages/Layout/Menu";
|
||||
|
||||
/**
|
||||
* 当前登录用户角色解析(与后端 RoleResolver 的主角色逻辑对齐)。
|
||||
*
|
||||
* 数据源:GET /api/user/me -> body.data = { authenticated, role?, groups? }
|
||||
* 1) role 为有效角色(admin/dev/annotator/viewer)-> [role]
|
||||
* 2) 否则从 groups 中提取 "deepdata-<角色>" 前缀组,多组时取权重最高者(admin4>dev3>annotator2>viewer1)
|
||||
* 3) 两者都为空 -> ["viewer"](最小可见性,前端不再 403),并输出 console.warn
|
||||
*
|
||||
* 登录/登出都会触发整页刷新(Header/AuthGuard 中 window.location.reload()),
|
||||
* 因此模块级缓存是安全的;仍提供 resetCurrentRolesCache() 供特殊场景手动失效。
|
||||
*/
|
||||
export interface CurrentRoles {
|
||||
roles: RoleKey[];
|
||||
loaded: boolean;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
const VALID_ROLES: RoleKey[] = ["admin", "dev", "annotator", "viewer"];
|
||||
const ROLE_WEIGHT: Record<RoleKey, number> = {
|
||||
admin: 4,
|
||||
dev: 3,
|
||||
annotator: 2,
|
||||
viewer: 1,
|
||||
};
|
||||
const GROUP_PREFIX = "deepdata-";
|
||||
const MINIMAL_ROLES: RoleKey[] = ["viewer"];
|
||||
|
||||
let cached: CurrentRoles | null = null;
|
||||
let inflight: Promise<CurrentRoles> | null = null;
|
||||
|
||||
function normalize(raw: unknown): CurrentRoles {
|
||||
const data = (raw ?? {}) as {
|
||||
authenticated?: boolean;
|
||||
role?: string | null;
|
||||
groups?: (string | null)[] | null;
|
||||
};
|
||||
const authenticated = data.authenticated !== false;
|
||||
|
||||
// 1) 后端已解析好的主角色
|
||||
if (data.role && (VALID_ROLES as string[]).includes(data.role)) {
|
||||
return { roles: [data.role as RoleKey], loaded: true, authenticated };
|
||||
}
|
||||
|
||||
// 2) groups 前缀推导("deepdata-admin" -> admin),多组取权重最高者
|
||||
const derived = (data.groups ?? [])
|
||||
.filter((g): g is string => !!g && g.startsWith(GROUP_PREFIX))
|
||||
.map((g) => g.slice(GROUP_PREFIX.length) as RoleKey)
|
||||
.filter((g) => (VALID_ROLES as string[]).includes(g))
|
||||
.sort((a, b) => ROLE_WEIGHT[b] - ROLE_WEIGHT[a]);
|
||||
if (derived.length > 0) {
|
||||
return { roles: [derived[0]], loaded: true, authenticated };
|
||||
}
|
||||
|
||||
// 3) 兜底:最小可见性,不打 403
|
||||
console.warn(
|
||||
"[useCurrentRoles] 未识别到有效角色,按 viewer 最小可见性处理:",
|
||||
{ role: data.role, groups: data.groups }
|
||||
);
|
||||
return { roles: MINIMAL_ROLES, loaded: true, authenticated };
|
||||
}
|
||||
|
||||
export function fetchCurrentRoles(force = false): Promise<CurrentRoles> {
|
||||
if (cached && !force) return Promise.resolve(cached);
|
||||
if (inflight) return inflight;
|
||||
inflight = get("/api/user/me")
|
||||
.then((res: unknown) => {
|
||||
// request.ts 的 get 返回原始 {code,message,data},data 才是 UserResponse
|
||||
const payload = (res as { data?: unknown })?.data;
|
||||
const result = normalize(payload);
|
||||
cached = result;
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function resetCurrentRolesCache() {
|
||||
cached = null;
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
export function useCurrentRoles(): CurrentRoles {
|
||||
const [state, setState] = useState<CurrentRoles>({
|
||||
roles: [],
|
||||
loaded: false,
|
||||
authenticated: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchCurrentRoles().then((result) => {
|
||||
if (alive) setState(result);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"logout": "Logout",
|
||||
"gotoLogin": "Go to Login"
|
||||
"gotoLogin": "Go to Login",
|
||||
"changePassword": "Change Password"
|
||||
},
|
||||
"authMode": {
|
||||
"sso": "SSO Login",
|
||||
@@ -22,16 +23,16 @@
|
||||
"signupFailed": "Registration failed, please try again later",
|
||||
"logoutSuccess": "Logged out successfully",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"useSSO": "Please use DeepData single sign-on to register"
|
||||
"useSSO": "Please use ModelEngine single sign-on to register",
|
||||
"changePasswordSuccess": "Password changed successfully",
|
||||
"changePasswordFailed": "Failed to change password. Please check if your old password is correct."
|
||||
},
|
||||
"loginDialog": {
|
||||
"title": "Login",
|
||||
"description": "Enter your username and password to log in to the system",
|
||||
"submitButton": "Login",
|
||||
"noAccount": "Don't have an account?",
|
||||
"registerNow": "Register now",
|
||||
"casdoorLogin": "Casdoor SSO Login",
|
||||
"or": "or"
|
||||
"registerNow": "Register now"
|
||||
},
|
||||
"signupDialog": {
|
||||
"title": "Register",
|
||||
@@ -44,13 +45,19 @@
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password"
|
||||
"confirmPassword": "Confirm Password",
|
||||
"oldPassword": "Old Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmNewPassword": "Confirm New Password"
|
||||
},
|
||||
"placeholders": {
|
||||
"username": "Enter username",
|
||||
"email": "Enter email",
|
||||
"password": "Enter password",
|
||||
"confirmPassword": "Enter password again"
|
||||
"confirmPassword": "Enter password again",
|
||||
"oldPassword": "Old password",
|
||||
"newPassword": "New password",
|
||||
"confirmNewPassword": "Confirm new password"
|
||||
},
|
||||
"validations": {
|
||||
"usernameRequired": "Please enter username",
|
||||
@@ -60,6 +67,11 @@
|
||||
"passwordTooShort": "Password must be at least 6 characters",
|
||||
"confirmPasswordRequired": "Please confirm password",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
},
|
||||
"changePasswordDialog": {
|
||||
"title": "Change Password",
|
||||
"description": "Please enter your old password to verify your identity and set a new password.",
|
||||
"submitButton": "Confirm"
|
||||
}
|
||||
},
|
||||
"dataCollection": {
|
||||
@@ -643,7 +655,8 @@
|
||||
"completed": "Completed",
|
||||
"draft": "Draft",
|
||||
"pending": "Ready",
|
||||
"paused": "Paused"
|
||||
"paused": "Paused",
|
||||
"submitted": "Submitted"
|
||||
}
|
||||
},
|
||||
"loading": "Loading...",
|
||||
@@ -1406,7 +1419,8 @@
|
||||
"reset": "Reset",
|
||||
"config": "Parameter Configuration",
|
||||
"selectOperatorConfig": "Please select an operator for parameter configuration",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"reRun": "Run again"
|
||||
},
|
||||
"steps": {
|
||||
"basicInfo": "Basic Info",
|
||||
@@ -1540,7 +1554,10 @@
|
||||
"noLogs": "No logs available for this task",
|
||||
"streaming": "Streaming...",
|
||||
"download": "Download Log",
|
||||
"downloadFailed": "Failed to download log file"
|
||||
"downloadFailed": "Failed to download log file",
|
||||
"copySuccess": "Copy log file uccessfully",
|
||||
"copyFailed": "Failed to copy log file",
|
||||
"copyLogs": "Copy log"
|
||||
},
|
||||
"operatorTable": {
|
||||
"serialNumber": "Serial Number",
|
||||
@@ -1613,7 +1630,7 @@
|
||||
"stopped": "Stopped"
|
||||
},
|
||||
"logTable": {
|
||||
"nthRun": "Run #{{n}}"
|
||||
"nthRun": "Run #{{num}}"
|
||||
},
|
||||
"paramConfig": {
|
||||
"enterValue": "Enter value"
|
||||
|
||||
+2862
-2845
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,8 @@ function getAuthToken(): string | null {
|
||||
* 在任何渲染之前检查系统参数 sys.home.page.url,若已配置则立即跳转,确保无闪烁。
|
||||
* 使用原始 fetch 但携带 JWT token,避免已登录用户仍收到 401。
|
||||
*/
|
||||
// xuhuanqing
|
||||
/**
|
||||
async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirected: boolean; authNeeded: boolean }> {
|
||||
if (window.location.pathname !== '/') {
|
||||
return { redirected: false, authNeeded: false };
|
||||
@@ -116,11 +118,65 @@ async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirecte
|
||||
|
||||
return { redirected: false, authNeeded: false };
|
||||
}
|
||||
**/
|
||||
async function checkHomePageRedirect(requireLogin: boolean, authenticated: boolean): Promise<{ redirected: boolean; authNeeded: boolean }> {
|
||||
if (window.location.pathname !== '/') {
|
||||
return { redirected: false, authNeeded: false };
|
||||
}
|
||||
|
||||
// 🟢 修改判断条件:仅当系统要求登录 且 用户当前未登录时,才需要认证
|
||||
if (requireLogin && !authenticated) {
|
||||
const cachedUrl = getCachedHomePageUrl();
|
||||
if (cachedUrl) {
|
||||
window.location.replace(cachedUrl);
|
||||
return { redirected: true, authNeeded: false };
|
||||
}
|
||||
// 需要登录且没有缓存URL,显示登录框
|
||||
return { redirected: false, authNeeded: true };
|
||||
}
|
||||
|
||||
// 不需要登录时,检查自定义首页
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sys-param/sys.home.page.url', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const url = result?.data?.paramValue?.trim();
|
||||
if (url) {
|
||||
setCachedHomePageUrl(url);
|
||||
window.location.replace(url);
|
||||
return { redirected: true, authNeeded: false };
|
||||
}
|
||||
// 参数存在但值为空 → 管理员已清除,清掉缓存
|
||||
setCachedHomePageUrl(null);
|
||||
}
|
||||
} catch {
|
||||
// 网络错误等,尝试从缓存读取
|
||||
const cachedUrl = getCachedHomePageUrl();
|
||||
if (cachedUrl) {
|
||||
window.location.replace(cachedUrl);
|
||||
return { redirected: true, authNeeded: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { redirected: false, authNeeded: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要登录(从后端获取配置)
|
||||
* 这个检查应该在应用启动时总是执行,无论当前路径是什么
|
||||
*/
|
||||
// xuhuanqing
|
||||
/**
|
||||
async function checkRequireLoginMode(): Promise<boolean> {
|
||||
try {
|
||||
const userResponse = await fetch('/api/user/me', {
|
||||
@@ -148,17 +204,59 @@ async function checkRequireLoginMode(): Promise<boolean> {
|
||||
setRequireLoginMode(false);
|
||||
return false;
|
||||
}
|
||||
**/
|
||||
async function checkRequireLoginMode(): Promise<{ requireLogin: boolean; authenticated: boolean }> {
|
||||
try {
|
||||
// 🟢 修改 1:读取本地 Token 并加入请求头
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const userResponse = await fetch('/api/user/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userResult = await userResponse.json();
|
||||
const requireLogin = userResult?.data?.requireLogin ?? false;
|
||||
const authenticated = userResult?.data?.authenticated ?? false; // 🟢 修改 2:获取真实认证状态
|
||||
|
||||
setRequireLoginMode(requireLogin);
|
||||
return { requireLogin, authenticated };
|
||||
} else if (userResponse.status === 401) {
|
||||
setRequireLoginMode(true);
|
||||
return { requireLogin: true, authenticated: false };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[bootstrap] Failed to check login requirement:', e);
|
||||
}
|
||||
|
||||
setRequireLoginMode(false);
|
||||
return { requireLogin: false, authenticated: false };
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const container = document.getElementById("root");
|
||||
if (!container) return;
|
||||
|
||||
/**
|
||||
// xuhuanqing
|
||||
// 首先检查是否需要登录(无论当前路径是什么)
|
||||
const requireLogin = await checkRequireLoginMode();
|
||||
|
||||
// 然后检查自定义首页重定向(只在根路径时)
|
||||
const { redirected, authNeeded } = await checkHomePageRedirect(requireLogin);
|
||||
**/
|
||||
// 首先检查是否需要登录(无论当前路径是什么)
|
||||
const { requireLogin, authenticated } = await checkRequireLoginMode(); // 🟢 接收解构状态
|
||||
|
||||
// 然后检查自定义首页重定向(只在根路径时),并传入 authenticated
|
||||
const { redirected, authNeeded } = await checkHomePageRedirect(requireLogin, authenticated); // 🟢 传入状态
|
||||
|
||||
if (redirected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import CreateAutoAnnotationDialog from "./components/CreateAutoAnnotationDialog"
|
||||
import EditAutoAnnotationDatasetDialog from "./components/EditAutoAnnotationDatasetDialog";
|
||||
import ImportFromLabelStudioDialog from "./components/ImportFromLabelStudioDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { appendTokenToUrl } from "@/utils/request";
|
||||
|
||||
const STATUS_COLORS: Record<AutoAnnotationStatus, string> = {
|
||||
pending: "default",
|
||||
@@ -188,8 +189,11 @@ export default function AutoAnnotation() {
|
||||
return;
|
||||
}
|
||||
|
||||
// const target = `${labelStudioBase}/projects/${projId}/data`;
|
||||
// window.open(target, "_blank");
|
||||
const target = `${labelStudioBase}/projects/${projId}/data`;
|
||||
window.open(target, "_blank");
|
||||
const secureTarget = appendTokenToUrl(target);
|
||||
window.open(secureTarget, "_blank");
|
||||
};
|
||||
|
||||
const handleViewResult = (task: AutoAnnotationTask) => {
|
||||
|
||||
@@ -139,6 +139,7 @@ const handleAnnotate = (task: AnnotationTask) => {
|
||||
const base =
|
||||
(window as any).__APP_CONFIG__?.labelStudioBase ??
|
||||
(window as any).labelStudioBase ??
|
||||
labelStudioBase ??
|
||||
'';
|
||||
|
||||
// 1️⃣ 裸 fetch:只种 cookie
|
||||
|
||||
@@ -30,7 +30,6 @@ const ParamConfig: React.FC<ParamConfigProps> = ({
|
||||
if (!param) return null;
|
||||
let defaultVal: any = operator.overrides?.[paramKey] ?? param.defaultVal;
|
||||
if (param.type === "range") {
|
||||
|
||||
defaultVal = Array.isArray(defaultVal)
|
||||
? defaultVal
|
||||
: [
|
||||
@@ -55,7 +54,9 @@ const ParamConfig: React.FC<ParamConfigProps> = ({
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
placeholder={t("dataCleansing.paramConfig.enterValue", { name: param.name })}
|
||||
placeholder={t("dataCleansing.paramConfig.enterValue", {
|
||||
name: param.name,
|
||||
})}
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -103,7 +104,12 @@ const ParamConfig: React.FC<ParamConfigProps> = ({
|
||||
</Form.Item>
|
||||
);
|
||||
case "checkbox": {
|
||||
const group = Array.isArray(value) ? value: value.split(",").map(item => item.trim()).filter(Boolean);
|
||||
const group = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
@@ -170,7 +176,7 @@ const ParamConfig: React.FC<ParamConfigProps> = ({
|
||||
}
|
||||
range
|
||||
min={min}
|
||||
max={max }
|
||||
max={max}
|
||||
step={step}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -204,7 +210,9 @@ const ParamConfig: React.FC<ParamConfigProps> = ({
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(val) => updateValue(val)}
|
||||
placeholder={t("dataCleansing.paramConfig.enterValue", { name: param.name })}
|
||||
placeholder={t("dataCleansing.paramConfig.enterValue", {
|
||||
name: param.name,
|
||||
})}
|
||||
className="w-full"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
@@ -224,11 +232,31 @@ const ParamConfig: React.FC<ParamConfigProps> = ({
|
||||
value={value}
|
||||
checkedChildren={param.checkedLabel}
|
||||
unCheckedChildren={param.unCheckedLabel}
|
||||
defaultChecked={String(param.defaultVal).toLowerCase() === 'true'}
|
||||
defaultChecked={String(param.defaultVal).toLowerCase() === "true"}
|
||||
onChange={(checked) => updateValue(checked)}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
placeholder={t("dataCleansing.paramConfig.enterValue", {
|
||||
name: param.name,
|
||||
})}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
className="w-full font-mono text-sm"
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
case "multiple":
|
||||
return (
|
||||
<div className="pl-4 border-l border-gray-300">
|
||||
|
||||
@@ -1,213 +1,228 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
import { CleansingTemplate } from "../../cleansing.model";
|
||||
import {queryCleaningTemplateByIdUsingGet, queryCleaningTemplatesUsingGet} from "../../cleansing.api";
|
||||
import {
|
||||
queryCategoryTreeUsingGet,
|
||||
queryOperatorsUsingPost,
|
||||
} from "@/pages/OperatorMarket/operator.api";
|
||||
import {useParams} from "react-router";
|
||||
import i18n from "@/i18n";
|
||||
import { App } from "antd";
|
||||
|
||||
function checkTypeCompatible(
|
||||
prevOutputs: string | undefined,
|
||||
nextInputs: string | undefined
|
||||
): boolean {
|
||||
if (!prevOutputs || !nextInputs) return true;
|
||||
const outputs = prevOutputs.toLowerCase().trim();
|
||||
const inputs = nextInputs.toLowerCase().trim();
|
||||
if (outputs === "multimodal" || inputs === "multimodal") return true;
|
||||
return outputs === inputs;
|
||||
}
|
||||
|
||||
export function useOperatorOperations() {
|
||||
const { id = "" } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const [operators, setOperators] = useState<OperatorI[]>([]);
|
||||
const [selectedOperators, setSelectedOperators] = useState<OperatorI[]>([]);
|
||||
const [configOperator, setConfigOperator] = useState<OperatorI | null>(null);
|
||||
|
||||
const [templates, setTemplates] = useState<CleansingTemplate[]>([]);
|
||||
const [currentTemplate, setCurrentTemplate] =
|
||||
useState<CleansingTemplate | null>(null);
|
||||
|
||||
// 将后端返回的算子数据映射为前端需要的格式
|
||||
const mapOperator = (op: OperatorI) => {
|
||||
const configs =
|
||||
op.settings
|
||||
? JSON.parse(op.settings)
|
||||
: {};
|
||||
const defaultParams: Record<string, string> = {};
|
||||
Object.keys(configs).forEach((key) => {
|
||||
const { value } = configs[key];
|
||||
defaultParams[key] = value;
|
||||
});
|
||||
return {
|
||||
...op,
|
||||
defaultParams,
|
||||
configs,
|
||||
};
|
||||
};
|
||||
|
||||
const [categoryOptions, setCategoryOptions] = useState([]);
|
||||
|
||||
const initOperators = async () => {
|
||||
const [categoryRes, operatorRes] = await Promise.all([
|
||||
queryCategoryTreeUsingGet(),
|
||||
queryOperatorsUsingPost({ page: 0, size: 1000 }),
|
||||
]);
|
||||
|
||||
const operators = operatorRes.data.content.map(mapOperator);
|
||||
setOperators(operators || []);
|
||||
|
||||
const options = categoryRes.data.content.reduce((acc: any[], item: any) => {
|
||||
const cats = item.categories.map((cat) => ({
|
||||
...cat,
|
||||
type: item.name,
|
||||
label: cat.name,
|
||||
value: cat.id,
|
||||
icon: cat.icon,
|
||||
operators: operators.filter((op) => op[item.name] === cat.name),
|
||||
}));
|
||||
acc.push(...cats);
|
||||
return acc;
|
||||
}, [] as { id: string; name: string; icon: React.ReactNode }[]);
|
||||
|
||||
setCategoryOptions(options);
|
||||
};
|
||||
|
||||
const initTemplates = async () => {
|
||||
if (id) {
|
||||
const { data } = await queryCleaningTemplateByIdUsingGet(id);
|
||||
const template = {
|
||||
...data,
|
||||
label: data.name,
|
||||
value: data.id,
|
||||
}
|
||||
setTemplates([template])
|
||||
setCurrentTemplate(template)
|
||||
} else {
|
||||
const { data } = await queryCleaningTemplatesUsingGet();
|
||||
const newTemplates =
|
||||
data.content?.map?.((item) => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) || [];
|
||||
setTemplates(newTemplates);
|
||||
setCurrentTemplate(newTemplates?.[0])
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOperators(currentTemplate?.instance?.map(mapOperator) || []);
|
||||
}, [currentTemplate]);
|
||||
|
||||
useEffect(() => {
|
||||
initTemplates();
|
||||
initOperators();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChange = () => {
|
||||
initOperators();
|
||||
};
|
||||
i18n.on('languageChanged', handleLanguageChange);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChange);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
const toggleOperator = (operator: OperatorI) => {
|
||||
const exist = selectedOperators.find((op) => op.id === operator.id);
|
||||
if (exist) {
|
||||
setSelectedOperators(
|
||||
selectedOperators.filter((op) => op.id !== operator.id)
|
||||
);
|
||||
} else {
|
||||
// Validate type compatibility with the last selected operator
|
||||
if (selectedOperators.length > 0) {
|
||||
const lastOp = selectedOperators[selectedOperators.length - 1];
|
||||
if (!checkTypeCompatible(lastOp.outputs, operator.inputs)) {
|
||||
message.warning(
|
||||
i18n.t("dataCleansing.create.typeMismatch", {
|
||||
from: lastOp.outputs,
|
||||
to: operator.inputs,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedOperators([...selectedOperators, { ...operator }]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除算子
|
||||
const removeOperator = (id: string) => {
|
||||
setSelectedOperators(selectedOperators.filter((op) => op.id !== id));
|
||||
if (configOperator?.id === id) setConfigOperator(null);
|
||||
};
|
||||
|
||||
// 配置算子参数变化
|
||||
const handleConfigChange = (
|
||||
operatorId: string,
|
||||
paramKey: string,
|
||||
value: any
|
||||
) => {
|
||||
setSelectedOperators((prev) =>
|
||||
prev.map((op) =>
|
||||
op.id === operatorId
|
||||
? {
|
||||
...op,
|
||||
overrides: {
|
||||
...(op?.overrides || op?.defaultParams),
|
||||
[paramKey]: value,
|
||||
},
|
||||
configs: {
|
||||
...(op?.configs),
|
||||
[paramKey]: {
|
||||
...(op?.configs[paramKey]),
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
: op
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 2) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
templates,
|
||||
currentTemplate,
|
||||
configOperator,
|
||||
categoryOptions,
|
||||
setConfigOperator,
|
||||
setCurrentTemplate,
|
||||
setCurrentStep,
|
||||
operators,
|
||||
setOperators,
|
||||
selectedOperators,
|
||||
setSelectedOperators,
|
||||
handleConfigChange,
|
||||
toggleOperator,
|
||||
removeOperator,
|
||||
handleNext,
|
||||
handlePrev,
|
||||
};
|
||||
}
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
import { CleansingTemplate } from "../../cleansing.model";
|
||||
import {queryCleaningTemplateByIdUsingGet, queryCleaningTemplatesUsingGet} from "../../cleansing.api";
|
||||
import {
|
||||
queryCategoryTreeUsingGet,
|
||||
queryOperatorsUsingPost,
|
||||
} from "@/pages/OperatorMarket/operator.api";
|
||||
import {useParams} from "react-router";
|
||||
import i18n from "@/i18n";
|
||||
import { App } from "antd";
|
||||
|
||||
function checkTypeCompatible(
|
||||
prevOutputs: string | undefined,
|
||||
nextInputs: string | undefined
|
||||
): boolean {
|
||||
if (!prevOutputs || !nextInputs) return true;
|
||||
const outputs = prevOutputs.toLowerCase().trim();
|
||||
const inputs = nextInputs.toLowerCase().trim();
|
||||
if (outputs === "multimodal" || inputs === "multimodal") return true;
|
||||
return outputs === inputs;
|
||||
}
|
||||
|
||||
export function useOperatorOperations() {
|
||||
const { id = "" } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const [operators, setOperators] = useState<OperatorI[]>([]);
|
||||
const [selectedOperators, setSelectedOperators] = useState<OperatorI[]>([]);
|
||||
const [configOperator, setConfigOperator] = useState<OperatorI | null>(null);
|
||||
|
||||
const [templates, setTemplates] = useState<CleansingTemplate[]>([]);
|
||||
const [currentTemplate, setCurrentTemplate] =
|
||||
useState<CleansingTemplate | null>(null);
|
||||
|
||||
// 将后端返回的算子数据映射为前端需要的格式
|
||||
const mapOperator = (op: OperatorI) => {
|
||||
let configs: any = {};
|
||||
if (op.settings) {
|
||||
try {
|
||||
configs = JSON.parse(op.settings);
|
||||
} catch (e) {
|
||||
console.error(`🔥 [算子解析失败] ID: ${op.id}, Name: ${op.name}`);
|
||||
console.error(`🔥 [算子解析失败] 错误详情:`, e);
|
||||
console.error(`🔥 [算子解析失败] Settings 内容:`, op.settings);
|
||||
// 容错:返回空配置,防止阻断整个页面加载
|
||||
configs = {};
|
||||
}
|
||||
}
|
||||
|
||||
const defaultParams: Record<string, string> = {};
|
||||
// 增加类型检查,防止 configs 解析异常
|
||||
if (configs && typeof configs === 'object' && !Array.isArray(configs)) {
|
||||
Object.keys(configs).forEach((key) => {
|
||||
const item = configs[key];
|
||||
if (item && typeof item === 'object' && 'value' in item) {
|
||||
defaultParams[key] = item.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
...op,
|
||||
defaultParams,
|
||||
configs,
|
||||
};
|
||||
};
|
||||
|
||||
const [categoryOptions, setCategoryOptions] = useState([]);
|
||||
|
||||
const initOperators = async () => {
|
||||
const [categoryRes, operatorRes] = await Promise.all([
|
||||
queryCategoryTreeUsingGet(),
|
||||
queryOperatorsUsingPost({ page: 0, size: 1000 }),
|
||||
]);
|
||||
|
||||
const operators = operatorRes.data.content.map(mapOperator);
|
||||
setOperators(operators || []);
|
||||
|
||||
const options = categoryRes.data.content.reduce((acc: any[], item: any) => {
|
||||
const cats = item.categories.map((cat) => ({
|
||||
...cat,
|
||||
type: item.name,
|
||||
label: cat.name,
|
||||
value: cat.id,
|
||||
icon: cat.icon,
|
||||
operators: operators.filter((op) => op[item.name] === cat.name),
|
||||
}));
|
||||
acc.push(...cats);
|
||||
return acc;
|
||||
}, [] as { id: string; name: string; icon: React.ReactNode }[]);
|
||||
|
||||
setCategoryOptions(options);
|
||||
};
|
||||
|
||||
const initTemplates = async () => {
|
||||
if (id) {
|
||||
const { data } = await queryCleaningTemplateByIdUsingGet(id);
|
||||
const template = {
|
||||
...data,
|
||||
label: data.name,
|
||||
value: data.id,
|
||||
}
|
||||
setTemplates([template])
|
||||
setCurrentTemplate(template)
|
||||
} else {
|
||||
const { data } = await queryCleaningTemplatesUsingGet();
|
||||
const newTemplates =
|
||||
data.content?.map?.((item) => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) || [];
|
||||
setTemplates(newTemplates);
|
||||
setCurrentTemplate(newTemplates?.[0])
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOperators(currentTemplate?.instance?.map(mapOperator) || []);
|
||||
}, [currentTemplate]);
|
||||
|
||||
useEffect(() => {
|
||||
initTemplates();
|
||||
initOperators();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChange = () => {
|
||||
initOperators();
|
||||
};
|
||||
i18n.on('languageChanged', handleLanguageChange);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChange);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
const toggleOperator = (operator: OperatorI) => {
|
||||
const exist = selectedOperators.find((op) => op.id === operator.id);
|
||||
if (exist) {
|
||||
setSelectedOperators(
|
||||
selectedOperators.filter((op) => op.id !== operator.id)
|
||||
);
|
||||
} else {
|
||||
// Validate type compatibility with the last selected operator
|
||||
if (selectedOperators.length > 0) {
|
||||
const lastOp = selectedOperators[selectedOperators.length - 1];
|
||||
if (!checkTypeCompatible(lastOp.outputs, operator.inputs)) {
|
||||
message.warning(
|
||||
i18n.t("dataCleansing.create.typeMismatch", {
|
||||
from: lastOp.outputs,
|
||||
to: operator.inputs,
|
||||
})
|
||||
);
|
||||
// xuhuanqing 仅仅警告,并不拒绝加入
|
||||
// return;
|
||||
}
|
||||
}
|
||||
setSelectedOperators([...selectedOperators, { ...operator }]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除算子
|
||||
const removeOperator = (id: string) => {
|
||||
setSelectedOperators(selectedOperators.filter((op) => op.id !== id));
|
||||
if (configOperator?.id === id) setConfigOperator(null);
|
||||
};
|
||||
|
||||
// 配置算子参数变化
|
||||
const handleConfigChange = (
|
||||
operatorId: string,
|
||||
paramKey: string,
|
||||
value: any
|
||||
) => {
|
||||
setSelectedOperators((prev) =>
|
||||
prev.map((op) =>
|
||||
op.id === operatorId
|
||||
? {
|
||||
...op,
|
||||
overrides: {
|
||||
...(op?.overrides || op?.defaultParams),
|
||||
[paramKey]: value,
|
||||
},
|
||||
configs: {
|
||||
...(op?.configs),
|
||||
[paramKey]: {
|
||||
...(op?.configs[paramKey]),
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
: op
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 2) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
templates,
|
||||
currentTemplate,
|
||||
configOperator,
|
||||
categoryOptions,
|
||||
setConfigOperator,
|
||||
setCurrentTemplate,
|
||||
setCurrentStep,
|
||||
operators,
|
||||
setOperators,
|
||||
selectedOperators,
|
||||
setSelectedOperators,
|
||||
handleConfigChange,
|
||||
toggleOperator,
|
||||
removeOperator,
|
||||
handleNext,
|
||||
handlePrev,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,223 +1,246 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {Breadcrumb, App, Tabs} from "antd";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Activity, LayoutList,
|
||||
} from "lucide-react";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
deleteCleaningTaskByIdUsingDelete,
|
||||
executeCleaningTaskUsingPost,
|
||||
queryCleaningTaskByIdUsingGet, queryCleaningTaskLogByIdUsingGet, queryCleaningTaskResultByIdUsingGet,
|
||||
stopCleaningTaskUsingPost,
|
||||
} from "../cleansing.api";
|
||||
import {getTaskStatusMap, mapTask} from "../cleansing.const";
|
||||
import {CleansingResult, TaskStatus} from "@/pages/DataCleansing/cleansing.model";
|
||||
import BasicInfo from "./components/BasicInfo";
|
||||
import OperatorTable from "./components/OperatorTable";
|
||||
import FileTable from "./components/FileTable";
|
||||
import LogsTable from "./components/LogsTable";
|
||||
import {formatExecutionDuration} from "@/utils/unit.ts";
|
||||
import {ReloadOutlined} from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// 任务详情页面组件
|
||||
export default function CleansingTaskDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id = "" } = useParams(); // 获取动态路由参数
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchTaskDetail = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskByIdUsingGet(id);
|
||||
setTask(mapTask(data, t));
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.taskDetailFailed"));
|
||||
navigate("/data/cleansing");
|
||||
}
|
||||
};
|
||||
|
||||
const pauseTask = async () => {
|
||||
await stopCleaningTaskUsingPost(id);
|
||||
message.success(t("dataCleansing.task.messages.taskPaused"));
|
||||
fetchTaskDetail();
|
||||
};
|
||||
|
||||
const startTask = async () => {
|
||||
await executeCleaningTaskUsingPost(id);
|
||||
message.success(t("dataCleansing.task.messages.taskStarted"));
|
||||
fetchTaskDetail();
|
||||
};
|
||||
|
||||
const deleteTask = async () => {
|
||||
await deleteCleaningTaskByIdUsingDelete(id);
|
||||
message.success(t("dataCleansing.task.messages.taskDeleted"));
|
||||
navigate("/data/cleansing");
|
||||
};
|
||||
|
||||
const [result, setResult] = useState<CleansingResult[]>();
|
||||
|
||||
const fetchTaskResult = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskResultByIdUsingGet(id);
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.fetchTaskResultFailed"));
|
||||
navigate("/data/cleansing/task-detail/" + id);
|
||||
}
|
||||
};
|
||||
|
||||
const [taskLog, setTaskLog] = useState();
|
||||
|
||||
const fetchTaskLog = async (retryCount: number) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskLogByIdUsingGet(id, retryCount);
|
||||
setTaskLog(data);
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.fetchTaskLogFailed"));
|
||||
navigate("/data/cleansing/task-detail/" + id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
fetchTaskDetail();
|
||||
{activeTab === "files" && await fetchTaskResult()}
|
||||
{activeTab === "logs" && await fetchTaskLog(task.retryCount)}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskDetail();
|
||||
}, [id, t]);
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
const headerData = {
|
||||
...task,
|
||||
icon: <LayoutList className="w-8 h-8" />,
|
||||
status: getTaskStatusMap(t)[task?.status],
|
||||
createdAt: task?.createdAt,
|
||||
lastUpdated: task?.updatedAt,
|
||||
};
|
||||
|
||||
const statistics = [
|
||||
{
|
||||
icon: <Clock className="w-4 h-4 text-blue-500" />,
|
||||
label: t("dataCleansing.detail.statistics.totalDuration"),
|
||||
value: formatExecutionDuration(task?.startedAt, task?.finishedAt) || "--",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="w-4 h-4 text-green-500" />,
|
||||
label: t("dataCleansing.detail.statistics.successFiles"),
|
||||
value: task?.progress?.succeedFileNum || 0,
|
||||
},
|
||||
{
|
||||
icon: <AlertCircle className="w-4 h-4 text-red-500" />,
|
||||
label: t("dataCleansing.detail.statistics.failedFiles"),
|
||||
value: task?.progress?.failedFileNum || 0,
|
||||
},
|
||||
{
|
||||
icon: <Activity className="w-4 h-4 text-purple-500" />,
|
||||
label: t("dataCleansing.detail.statistics.successRate"),
|
||||
value: task?.progress?.process ? task?.progress?.process + "%" : "--",
|
||||
},
|
||||
];
|
||||
|
||||
const operations = [
|
||||
...(task?.status?.value === TaskStatus.RUNNING
|
||||
? [
|
||||
{
|
||||
key: "pause",
|
||||
label: t("dataCleansing.actions.pauseTask"),
|
||||
icon: <Pause className="w-4 h-4" />,
|
||||
onClick: pauseTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...([TaskStatus.PENDING, TaskStatus.STOPPED, TaskStatus.FAILED, TaskStatus.PARTIAL_SUCCESS].includes(task?.status?.value)
|
||||
? [
|
||||
{
|
||||
key: "start",
|
||||
label: t("dataCleansing.actions.start"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onClick: startTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "refresh",
|
||||
label: t("dataCleansing.actions.refreshTask"),
|
||||
icon: <ReloadOutlined className="w-4 h-4" />,
|
||||
onClick: handleRefresh,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("dataCleansing.actions.deleteTask"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
onClick: deleteTask,
|
||||
},
|
||||
];
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
key: "basic",
|
||||
label: t("dataCleansing.detail.tabs.basicInfo"),
|
||||
},
|
||||
{
|
||||
key: "operators",
|
||||
label: t("dataCleansing.detail.tabs.operators"),
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
label: t("dataCleansing.detail.tabs.files"),
|
||||
},
|
||||
{
|
||||
key: "logs",
|
||||
label: t("dataCleansing.detail.tabs.logs"),
|
||||
},
|
||||
];
|
||||
|
||||
const breadItems = [
|
||||
{
|
||||
title: <Link to="/data/cleansing">{t("dataCleansing.detail.breadcrumb.dataProcessing")}</Link>,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.breadcrumb.taskDetail"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb items={breadItems} />
|
||||
<div className="mb-4 mt-4">
|
||||
<DetailHeader
|
||||
data={headerData}
|
||||
statistics={statistics}
|
||||
operations={operations}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-overflow-auto p-6 pt-2 bg-white rounded-md shadow">
|
||||
<Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
|
||||
<div className="h-full flex-1 overflow-auto">
|
||||
{activeTab === "basic" && (
|
||||
<BasicInfo task={task} />
|
||||
)}
|
||||
{activeTab === "operators" && <OperatorTable task={task} />}
|
||||
{activeTab === "files" && <FileTable result={result} fetchTaskResult={fetchTaskResult} />}
|
||||
{activeTab === "logs" && <LogsTable taskLog={taskLog} fetchTaskLog={fetchTaskLog} retryCount={task.retryCount} taskName={task.name} taskStatus={task?.status?.value} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// 文件路径: D:\xu_projects\for_mage\llm\repos\DeepData\frontend\src\pages\DataCleansing\Detail\TaskDetail.tsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Breadcrumb, App, Tabs, Modal } from "antd";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Activity,
|
||||
LayoutList,
|
||||
} from "lucide-react";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
deleteCleaningTaskByIdUsingDelete,
|
||||
executeCleaningTaskUsingPost,
|
||||
queryCleaningTaskByIdUsingGet,
|
||||
queryCleaningTaskResultByIdUsingGet,
|
||||
stopCleaningTaskUsingPost,
|
||||
} from "../cleansing.api";
|
||||
import { getTaskStatusMap, mapTask } from "../cleansing.const";
|
||||
import {
|
||||
CleansingResult,
|
||||
TaskStatus,
|
||||
} from "@/pages/DataCleansing/cleansing.model";
|
||||
import BasicInfo from "./components/BasicInfo";
|
||||
import OperatorTable from "./components/OperatorTable";
|
||||
import FileTable from "./components/FileTable";
|
||||
import LogsTable from "./components/LogsTable";
|
||||
import { formatExecutionDuration } from "@/utils/unit.ts";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function CleansingTaskDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id = "" } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchTaskDetail = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskByIdUsingGet(id);
|
||||
setTask(mapTask(data, t));
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.taskDetailFailed"));
|
||||
navigate("/data/cleansing");
|
||||
}
|
||||
};
|
||||
|
||||
const pauseTask = async () => {
|
||||
await stopCleaningTaskUsingPost(id);
|
||||
message.success(t("dataCleansing.task.messages.taskPaused"));
|
||||
fetchTaskDetail();
|
||||
};
|
||||
|
||||
const startTask = async () => {
|
||||
const executeAndRefresh = async () => {
|
||||
try {
|
||||
await executeCleaningTaskUsingPost(id);
|
||||
message.success(t("dataCleansing.task.messages.taskStarted"));
|
||||
// 刷新任务详情以更新 retryCount 和 status
|
||||
await fetchTaskDetail();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (task?.status?.value === TaskStatus.COMPLETED) {
|
||||
Modal.confirm({
|
||||
title: t("dataCleansing.messages.confirmRerun.title", "确认重新执行"),
|
||||
content: t(
|
||||
"dataCleansing.messages.confirmRerun.content",
|
||||
"该任务已完成。重新执行将递增重试计数并保留成功记录,是否继续?"
|
||||
),
|
||||
onOk: executeAndRefresh,
|
||||
});
|
||||
} else {
|
||||
executeAndRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async () => {
|
||||
await deleteCleaningTaskByIdUsingDelete(id);
|
||||
message.success(t("dataCleansing.task.messages.taskDeleted"));
|
||||
navigate("/data/cleansing");
|
||||
};
|
||||
|
||||
const [result, setResult] = useState<CleansingResult[]>();
|
||||
const fetchTaskResult = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskResultByIdUsingGet(id);
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.fetchTaskResultFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
// 移除不再需要的 taskLog 状态和 fetchTaskLog 函数
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await fetchTaskDetail();
|
||||
if (activeTab === "files") await fetchTaskResult();
|
||||
// LogsTable 会自动根据 retryCount 刷新,无需手动调用
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskDetail();
|
||||
}, [id, t]);
|
||||
|
||||
const [task, setTask] = useState<any>(null);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
const headerData = {
|
||||
...task,
|
||||
icon: <LayoutList className="w-8 h-8" />,
|
||||
status: getTaskStatusMap(t)[task?.status],
|
||||
createdAt: task?.createdAt,
|
||||
lastUpdated: task?.updatedAt,
|
||||
};
|
||||
|
||||
const statistics = [
|
||||
{
|
||||
icon: <Clock className="w-4 h-4 text-blue-500" />,
|
||||
label: t("dataCleansing.detail.statistics.totalDuration"),
|
||||
value: formatExecutionDuration(task?.startedAt, task?.finishedAt) || "--",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="w-4 h-4 text-green-500" />,
|
||||
label: t("dataCleansing.detail.statistics.successFiles"),
|
||||
value: task?.progress?.succeedFileNum || 0,
|
||||
},
|
||||
{
|
||||
icon: <AlertCircle className="w-4 h-4 text-red-500" />,
|
||||
label: t("dataCleansing.detail.statistics.failedFiles"),
|
||||
value: task?.progress?.failedFileNum || 0,
|
||||
},
|
||||
{
|
||||
icon: <Activity className="w-4 h-4 text-purple-500" />,
|
||||
label: t("dataCleansing.detail.statistics.successRate"),
|
||||
value: task?.progress?.process ? task?.progress?.process + "%" : "--",
|
||||
},
|
||||
];
|
||||
|
||||
const operations = [
|
||||
...(task?.status?.value === TaskStatus.RUNNING
|
||||
? [
|
||||
{
|
||||
key: "pause",
|
||||
label: t("dataCleansing.actions.pauseTask"),
|
||||
icon: <Pause className="w-4 h-4" />,
|
||||
onClick: pauseTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...([
|
||||
TaskStatus.PENDING,
|
||||
TaskStatus.STOPPED,
|
||||
TaskStatus.FAILED,
|
||||
TaskStatus.PARTIAL_SUCCESS,
|
||||
TaskStatus.COMPLETED,
|
||||
].includes(task?.status?.value)
|
||||
? [
|
||||
{
|
||||
key: "start",
|
||||
label:
|
||||
task?.status?.value === TaskStatus.COMPLETED
|
||||
? t("dataCleansing.actions.reRun")
|
||||
: t("dataCleansing.actions.start"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onClick: startTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "refresh",
|
||||
label: t("dataCleansing.actions.refreshTask"),
|
||||
icon: <ReloadOutlined className="w-4 h-4" />,
|
||||
onClick: handleRefresh,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("dataCleansing.actions.deleteTask"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
onClick: deleteTask,
|
||||
},
|
||||
];
|
||||
|
||||
const tabList = [
|
||||
{ key: "basic", label: t("dataCleansing.detail.tabs.basicInfo") },
|
||||
{ key: "operators", label: t("dataCleansing.detail.tabs.operators") },
|
||||
{ key: "files", label: t("dataCleansing.detail.tabs.files") },
|
||||
{ key: "logs", label: t("dataCleansing.detail.tabs.logs") },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: (
|
||||
<Link to="/data/cleansing">
|
||||
{t("dataCleansing.detail.breadcrumb.dataProcessing")}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: t("dataCleansing.detail.breadcrumb.taskDetail") },
|
||||
]}
|
||||
/>
|
||||
<div className="mb-4 mt-4">
|
||||
<DetailHeader
|
||||
data={headerData}
|
||||
statistics={statistics}
|
||||
operations={operations}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-overflow-auto p-6 pt-2 bg-white rounded-md shadow">
|
||||
<Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
|
||||
<div className="h-full flex-1 overflow-auto">
|
||||
{activeTab === "basic" && <BasicInfo task={task} />}
|
||||
{activeTab === "operators" && <OperatorTable task={task} />}
|
||||
{activeTab === "files" && (
|
||||
<FileTable
|
||||
task={task}
|
||||
result={result}
|
||||
fetchTaskResult={fetchTaskResult}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "logs" && (
|
||||
// 🔴 核心修改:移除 key,移除不必要的 props,让 LogsTable 自治
|
||||
<LogsTable
|
||||
retryCount={task?.retryCount ?? 0}
|
||||
taskName={task?.name || ""}
|
||||
taskStatus={task?.status?.value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
import {Button, Modal, Table, Badge, Input, Popover} from "antd";
|
||||
import { Download } from "lucide-react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useParams} from "react-router";
|
||||
import {TaskStatus} from "@/pages/DataCleansing/cleansing.model.ts";
|
||||
import {getTaskStatusMap} from "@/pages/DataCleansing/cleansing.const.tsx";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Table,
|
||||
Badge,
|
||||
Input,
|
||||
Popover,
|
||||
Spin,
|
||||
message,
|
||||
} from "antd";
|
||||
import { Download, Eye, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { TaskStatus } from "@/pages/DataCleansing/cleansing.model.ts";
|
||||
import { getTaskStatusMap } from "@/pages/DataCleansing/cleansing.const.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// 引入公共文件预览组件
|
||||
import FilePreview from "@/components/file-preview";
|
||||
import { appendTokenToUrl, getAuthToken } from "@/utils/request";
|
||||
|
||||
// ================= 辅助组件 =================
|
||||
|
||||
// 渲染 JSON 值的辅助组件
|
||||
function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) {
|
||||
if (value === null) {
|
||||
return <span className="text-gray-500">null</span>;
|
||||
}
|
||||
if (value === undefined) {
|
||||
if (value === null) return <span className="text-gray-500">null</span>;
|
||||
if (value === undefined)
|
||||
return <span className="text-gray-500">undefined</span>;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
if (typeof value === "boolean")
|
||||
return <span className="text-purple-600">{value.toString()}</span>;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (typeof value === "number")
|
||||
return <span className="text-blue-600">{value}</span>;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
// 处理换行符,保留空白格式
|
||||
const lines = value.split('\n');
|
||||
if (typeof value === "string") {
|
||||
const lines = value.split("\n");
|
||||
if (lines.length > 1) {
|
||||
return (
|
||||
<span className="text-green-700">
|
||||
@@ -38,9 +45,7 @@ function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) {
|
||||
return <span className="text-green-700">{value}</span>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-gray-500">[]</span>;
|
||||
}
|
||||
if (value.length === 0) return <span className="text-gray-500">[]</span>;
|
||||
return (
|
||||
<div className="pl-3 border-l-2 border-gray-200">
|
||||
{value.map((item, i) => (
|
||||
@@ -52,11 +57,9 @@ function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (typeof value === "object") {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 0) {
|
||||
return <span className="text-gray-500">{"{}"}</span>;
|
||||
}
|
||||
if (keys.length === 0) return <span className="text-gray-500">{"{}"}</span>;
|
||||
return (
|
||||
<div className="pl-3 border-l-2 border-gray-200">
|
||||
{keys.map((key) => (
|
||||
@@ -72,14 +75,58 @@ function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) {
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
// 模拟文件列表数据
|
||||
export default function FileTable({result, fetchTaskResult}) {
|
||||
function formatFileSize(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0 || !bytes) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]
|
||||
);
|
||||
}
|
||||
|
||||
// 需要读取为文本的文件后缀
|
||||
const TEXT_BASED_EXTS = [
|
||||
"json",
|
||||
"txt",
|
||||
"log",
|
||||
"md",
|
||||
"csv",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"js",
|
||||
"ts",
|
||||
"html",
|
||||
"css",
|
||||
"py",
|
||||
"sql",
|
||||
"sh",
|
||||
];
|
||||
|
||||
// ================= 主组件 =================
|
||||
|
||||
export default function FileTable({ task, result, fetchTaskResult }) {
|
||||
const { id = "" } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const [showFileCompareDialog, setShowFileCompareDialog] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
// ✅ 核心修复:改为存储 Blob 对象,而不是 URL 字符串
|
||||
const [srcPreview, setSrcPreview] = useState<{
|
||||
blob?: Blob;
|
||||
content?: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}>({ loading: false });
|
||||
const [destPreview, setDestPreview] = useState<{
|
||||
blob?: Blob;
|
||||
content?: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}>({ loading: false });
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskResult();
|
||||
}, [id]);
|
||||
@@ -99,19 +146,67 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
setSelectedFileIds(selectedFileIds.filter((id) => id !== fileId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewFileCompare = (file: any) => {
|
||||
setSelectedFile(file);
|
||||
setShowFileCompareDialog(true);
|
||||
// 每次打开重置预览状态
|
||||
setSrcPreview({ loading: false });
|
||||
setDestPreview({ loading: false });
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
// 1. 基础 API 路径
|
||||
const baseUrl = `/api/cleaning/tasks/${id}/result/download`;
|
||||
|
||||
// 2. 🟢 核心修复:自动适配 JWT 开关状态
|
||||
// - 若 JWT=true 且已登录:URL 自动拼接 ?token=xxx (后端 AuthFilter 已支持)
|
||||
// - 若 JWT=false:后端直接放行,带不带 token 均不影响
|
||||
const downloadUrl = appendTokenToUrl(baseUrl);
|
||||
|
||||
// 3. 使用原生 <a> 标签触发浏览器下载器
|
||||
// 彻底避开 fetch 的内存限制、流式缓冲 Bug 及 Chromium 网络层缺陷
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = `task_${id}_files.zip`;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
message.error(t("dataCleansing.detail.fileTable.downloadFilesFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/cleaning/tasks/${id}/result/download`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed');
|
||||
// const response = await fetch(`/api/cleaning/tasks/${id}/result/download`);
|
||||
|
||||
// 1. 获取 Token
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
// 2. 如果有 Token,放入 Header
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 3. 将 headers 传入 fetch
|
||||
const response = await fetch(
|
||||
`/api/cleaning/tasks/${id}/result/download`,
|
||||
{
|
||||
headers: headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `task_${id}_files.zip`;
|
||||
document.body.appendChild(a);
|
||||
@@ -119,21 +214,184 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
console.error("Download failed:", error);
|
||||
message.error(t("dataCleansing.detail.fileTable.downloadFilesFailed"));
|
||||
}
|
||||
};
|
||||
**/
|
||||
|
||||
function formatFileSize(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
// ✅ 增强版加载逻辑
|
||||
const loadPreview = async (type: "src" | "dest") => {
|
||||
if (!selectedFile || !task) return;
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const isSrc = type === "src";
|
||||
const fileId = isSrc ? selectedFile.srcFileId : selectedFile.destFileId;
|
||||
const datasetId = isSrc ? task.srcDatasetId : task.destDatasetId;
|
||||
const fileName = isSrc ? selectedFile.srcName : selectedFile.destName;
|
||||
const setFn = isSrc ? setSrcPreview : setDestPreview;
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
// 如果已经加载过,直接返回
|
||||
if (
|
||||
isSrc
|
||||
? srcPreview.blob || srcPreview.content
|
||||
: destPreview.blob || destPreview.content
|
||||
)
|
||||
return;
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
||||
}
|
||||
setFn({ loading: true, error: undefined });
|
||||
|
||||
try {
|
||||
if (!datasetId) throw new Error("无法获取数据集 ID");
|
||||
if (!fileId) throw new Error("无法获取文件 ID");
|
||||
|
||||
// 1. 拼接请求 URL
|
||||
// const fileUrl = `/api/data-management/datasets/${datasetId}/files/${fileId}/download`;
|
||||
const fileUrl = appendTokenToUrl(`/api/data-management/datasets/${datasetId}/files/${fileId}/download`);
|
||||
console.log(`[Preview] Fetching: ${fileUrl}`);
|
||||
|
||||
const response = await fetch(fileUrl, { credentials: "include" });
|
||||
|
||||
// 2. 🛡️ 拦截 404 错误(文件不存在)
|
||||
if (response.status === 404) {
|
||||
throw new Error(
|
||||
type === "dest"
|
||||
? "处理后文件不存在 (404)。可能该文件格式未被算子支持,或处理过程被跳过。"
|
||||
: "原始文件不存在 (404),请检查数据集。"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 拦截其他 HTTP 错误
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`请求失败 (${response.status}): ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// 4. 🛡️ 拦截空文件(0 字节)
|
||||
// 对应您提到的“文本文件转换后为 0 长度”的情况
|
||||
if (blob.size === 0) {
|
||||
throw new Error(
|
||||
"处理后文件内容为空 (0 字节)。\n请检查任务执行日志,确认是否有数据被清洗掉或算子逻辑有误。"
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 判断是否需要读取为文本
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
const isTextBased = TEXT_BASED_EXTS.includes(ext);
|
||||
|
||||
let textContent: string | undefined = undefined;
|
||||
if (isTextBased && blob.size < 5 * 1024 * 1024) {
|
||||
try {
|
||||
textContent = await blob.text();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 成功保存
|
||||
setFn({ loading: false, blob, content: textContent, error: undefined });
|
||||
} catch (error: any) {
|
||||
console.error(`Load ${type} preview error:`, error);
|
||||
// 将错误信息直接传递给 UI 展示
|
||||
setFn({
|
||||
loading: false,
|
||||
blob: undefined,
|
||||
content: undefined,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
// ✅ 增强版加载逻辑:直接保存 Blob 对象
|
||||
const loadPreview = async (type: "src" | "dest") => {
|
||||
if (!selectedFile || !task) return;
|
||||
|
||||
const isSrc = type === "src";
|
||||
const fileId = isSrc ? selectedFile.srcFileId : selectedFile.destFileId;
|
||||
const datasetId = isSrc ? task.srcDatasetId : task.destDatasetId;
|
||||
const fileName = isSrc ? selectedFile.srcName : selectedFile.destName;
|
||||
const setFn = isSrc ? setSrcPreview : setDestPreview;
|
||||
|
||||
// 如果已经加载过,直接返回
|
||||
if (
|
||||
isSrc
|
||||
? srcPreview.blob || srcPreview.content
|
||||
: destPreview.blob || destPreview.content
|
||||
)
|
||||
return;
|
||||
|
||||
setFn({ loading: true, error: undefined });
|
||||
|
||||
try {
|
||||
// 检查必要的 ID
|
||||
if (!datasetId) {
|
||||
throw new Error("无法获取数据集ID,请检查任务配置");
|
||||
}
|
||||
if (!fileId) {
|
||||
throw new Error("无法获取文件ID");
|
||||
}
|
||||
|
||||
// 1. 拼接正确的下载接口并携带鉴权 Cookie
|
||||
const fileUrl = `/api/data-management/datasets/${datasetId}/files/${fileId}/download`;
|
||||
console.log(`[Preview] Fetching: ${fileUrl}`);
|
||||
|
||||
const response = await fetch(fileUrl, { credentials: "include" });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network error: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
console.log(
|
||||
`[Preview] Blob received. Size: ${blob.size}, Type: ${blob.type}`
|
||||
);
|
||||
|
||||
if (blob.size === 0) throw new Error("Empty file");
|
||||
|
||||
// 2. 判断是否需要读取为文本
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
const isTextBased = TEXT_BASED_EXTS.includes(ext);
|
||||
|
||||
let textContent: string | undefined = undefined;
|
||||
|
||||
if (isTextBased && blob.size < 5 * 1024 * 1024) {
|
||||
// 5MB 以内的文本文件尝试读取
|
||||
try {
|
||||
textContent = await blob.text();
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse text content", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. ✅ 核心修复:直接保存 Blob 对象,而不是创建 URL
|
||||
// FilePreview 组件内部会根据 blob 自动处理转换
|
||||
setFn({ loading: false, blob, content: textContent, error: undefined });
|
||||
} catch (error: any) {
|
||||
console.error(`Load ${type} preview error:`, error);
|
||||
setFn({
|
||||
loading: false,
|
||||
blob: undefined,
|
||||
content: undefined,
|
||||
error: `加载失败: ${error.message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
**/
|
||||
|
||||
const closeCompareDialog = () => {
|
||||
setShowFileCompareDialog(false);
|
||||
// 释放 Blob URL 内存(如果有的话)
|
||||
if (srcPreview.blob)
|
||||
URL.revokeObjectURL(URL.createObjectURL(srcPreview.blob));
|
||||
if (destPreview.blob)
|
||||
URL.revokeObjectURL(URL.createObjectURL(destPreview.blob));
|
||||
setSrcPreview({ loading: false });
|
||||
setDestPreview({ loading: false });
|
||||
};
|
||||
|
||||
const fileColumns = [
|
||||
{
|
||||
@@ -164,107 +422,20 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
dataIndex: "srcName",
|
||||
key: "srcName",
|
||||
width: 200,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileName")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.srcName.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string) => (
|
||||
<span>{text?.replace(/\.[^/.]+$/, "")}</span>
|
||||
),
|
||||
render: (text: string) => <span>{text?.replace(/\.[^/.]+$/, "")}</span>,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.processedFileName"),
|
||||
dataIndex: "destName",
|
||||
key: "destName",
|
||||
width: 200,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileName")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.destName.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string) => (
|
||||
<span>{text?.replace(/\.[^/.]+$/, "")}</span>
|
||||
),
|
||||
render: (text: string) => <span>{text?.replace(/\.[^/.]+$/, "")}</span>,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.fileType"),
|
||||
dataIndex: "srcType",
|
||||
key: "srcType",
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileType")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.srcType.toLowerCase().includes(value.toLowerCase()),
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<span className="font-mono text-sm">{text}</span>
|
||||
),
|
||||
@@ -273,34 +444,7 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
title: t("dataCleansing.detail.fileTable.processedFileType"),
|
||||
dataIndex: "destType",
|
||||
key: "destType",
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileType")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.destType.toLowerCase().includes(value.toLowerCase()),
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<span className="font-mono text-sm">{text || "-"}</span>
|
||||
),
|
||||
@@ -309,81 +453,29 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
title: t("dataCleansing.detail.fileTable.beforeSize"),
|
||||
dataIndex: "srcSize",
|
||||
key: "srcSize",
|
||||
sorter: (a: any, b: any) => {
|
||||
const getSizeInBytes = (size: string) => {
|
||||
if (!size || size === "-") return 0;
|
||||
const num = Number.parseFloat(size);
|
||||
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
|
||||
if (size.includes("MB")) return num * 1024 * 1024;
|
||||
if (size.includes("KB")) return num * 1024;
|
||||
return num;
|
||||
};
|
||||
return getSizeInBytes(a.originalSize) - getSizeInBytes(b.originalSize);
|
||||
},
|
||||
render: (number: number) => (
|
||||
<span className="font-mono text-sm">{formatFileSize(number)}</span>
|
||||
width: 100,
|
||||
render: (bytes: number) => (
|
||||
<span className="font-mono text-sm">{formatFileSize(bytes)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.afterSize"),
|
||||
dataIndex: "destSize",
|
||||
key: "destSize",
|
||||
sorter: (a: any, b: any) => {
|
||||
const getSizeInBytes = (size: string) => {
|
||||
if (!size || size === "-") return 0;
|
||||
const num = Number.parseFloat(size);
|
||||
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
|
||||
if (size.includes("MB")) return num * 1024 * 1024;
|
||||
if (size.includes("KB")) return num * 1024;
|
||||
return num;
|
||||
};
|
||||
return (
|
||||
getSizeInBytes(a.processedSize) - getSizeInBytes(b.processedSize)
|
||||
);
|
||||
},
|
||||
render: (number: number) => (
|
||||
<span className="font-mono text-sm">{formatFileSize(number)}</span>
|
||||
width: 100,
|
||||
render: (bytes: number) => (
|
||||
<span className="font-mono text-sm">{formatFileSize(bytes)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.result"),
|
||||
dataIndex: "result",
|
||||
key: "result",
|
||||
width: 120,
|
||||
render: (text: string, record: any) => {
|
||||
// 如果结果为空或特殊值,则不展示
|
||||
if (!text || text === '' || text === '{}' || text === '[]' ||
|
||||
text === 'null' || text === 'undefined' || text.trim() === '') {
|
||||
render: (text: string) => {
|
||||
if (!text || text === "{}" || text === "[]" || text === "null")
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
// 尝试解析JSON
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
// 如果是空对象或空数组则不展示
|
||||
if (Array.isArray(parsed) && parsed.length === 0) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
// 检查 reason 字段:如果只有 reason 且为 null/空,则不展示
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
// 如果只有 reason 字段且为空
|
||||
if (keys.length > 0 && keys.includes('reason')) {
|
||||
const reason = parsed['reason'];
|
||||
if (reason === null || reason === undefined || reason === '' ||
|
||||
(typeof reason === 'string' && reason.trim() === '')) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 有内容需要展示,显示 {...} 点击查看详情
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
@@ -400,83 +492,41 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
</Popover>
|
||||
);
|
||||
} catch {
|
||||
// 普通字符串:如果为空则不展示
|
||||
if (text.trim() === '') {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
// 有内容的普通字符串,点击查看详情(支持换行)
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className="max-w-md max-h-64 overflow-auto text-sm bg-gray-50 p-3 rounded whitespace-pre-wrap">
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
title={t("dataCleansing.detail.fileTable.resultDetail")}
|
||||
trigger="click"
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600 cursor-pointer hover:text-blue-800 underline">
|
||||
{"{...}"}
|
||||
</span>
|
||||
</Popover>
|
||||
<span className="font-mono text-sm text-gray-600 truncate block max-w-[150px]">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
filters: [
|
||||
{ text: t("dataCleansing.status.completed"), value: "COMPLETED" },
|
||||
{ text: t("dataCleansing.status.failed"), value: "FAILED" },
|
||||
],
|
||||
onFilter: (value: string, record: any) => record.status === value,
|
||||
width: 100,
|
||||
render: (status: string) => (
|
||||
<Badge
|
||||
status={
|
||||
status === "COMPLETED"
|
||||
? "success"
|
||||
: "error"
|
||||
}
|
||||
text={getTaskStatusMap(t)[status as TaskStatus].label}
|
||||
status={status === "COMPLETED" ? "success" : "error"}
|
||||
text={getTaskStatusMap(t)[status as TaskStatus]?.label || status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.actions"),
|
||||
key: "action",
|
||||
width: 150,
|
||||
width: 220,
|
||||
render: (_text: string, record: any) => (
|
||||
<div className="flex">
|
||||
{record.status === "COMPLETED" ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleViewFileCompare(record)}
|
||||
>
|
||||
{t("dataCleansing.actions.compare")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled
|
||||
>
|
||||
{t("dataCleansing.actions.compare")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleDownload()}
|
||||
disabled={record.status !== "COMPLETED"}
|
||||
onClick={() => handleViewFileCompare(record)}
|
||||
>
|
||||
{t("dataCleansing.actions.compare")}
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => handleDownload()}>
|
||||
{t("dataCleansing.actions.download")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -489,7 +539,9 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
{selectedFileIds.length > 0 && (
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">
|
||||
{t("dataCleansing.detail.fileTable.selectedFilesCount", { count: selectedFileIds.length })}
|
||||
{t("dataCleansing.detail.fileTable.selectedFilesCount", {
|
||||
count: selectedFileIds.length,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
@@ -512,53 +564,129 @@ export default function FileTable({result, fetchTaskResult}) {
|
||||
{/* 文件对比弹窗 */}
|
||||
<Modal
|
||||
open={showFileCompareDialog}
|
||||
onCancel={() => setShowFileCompareDialog(false)}
|
||||
onCancel={closeCompareDialog}
|
||||
footer={null}
|
||||
width={900}
|
||||
title={<span>{t("dataCleansing.detail.fileTable.compareDialogTitle", {fileName: selectedFile?.fileName})}</span>}
|
||||
width={1200}
|
||||
title={
|
||||
<span>
|
||||
{t("dataCleansing.detail.fileTable.compareDialogTitle", {
|
||||
fileName: selectedFile?.srcName,
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6 py-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{t("dataCleansing.detail.fileTable.beforeProcessing")}</h4>
|
||||
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="w-16 h-16 bg-gray-300 rounded-lg mx-auto mb-2" />
|
||||
<div className="text-sm">{t("dataCleansing.detail.fileTable.originalFilePreview")}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("dataCleansing.detail.fileTable.size")}: {formatFileSize(selectedFile?.srcSize)}
|
||||
<div className="grid grid-cols-2 gap-6 py-4">
|
||||
{/* --- 左侧:原始文件 --- */}
|
||||
<div className="flex flex-col h-[600px]">
|
||||
<h4 className="font-medium text-gray-900 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full"></span>
|
||||
{t("dataCleansing.detail.fileTable.beforeProcessing")}
|
||||
{srcPreview.loading && (
|
||||
<span className="text-xs text-blue-500 flex items-center">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" /> Loading...
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-50 overflow-hidden relative">
|
||||
{srcPreview.blob || srcPreview.content ? (
|
||||
// ✅ 修复:直接传入 blob 对象,而不是 blobUrl
|
||||
<FilePreview
|
||||
fileName={selectedFile.srcName}
|
||||
blob={srcPreview.blob}
|
||||
content={srcPreview.content}
|
||||
/>
|
||||
) : srcPreview.error ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-500 p-4 text-center">
|
||||
<span className="text-2xl mb-2">⚠️</span>
|
||||
<span className="text-sm font-medium">
|
||||
{srcPreview.error}
|
||||
</span>
|
||||
<span className="text-xs mt-1 text-gray-500">
|
||||
请检查接口权限或路径
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => loadPreview("src")}
|
||||
size="small"
|
||||
className="mt-2"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-3 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">{t("dataCleansing.detail.fileTable.fileFormat")}:</span> {selectedFile?.srcType}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => loadPreview("src")}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gray-200 group-hover:bg-blue-100 rounded-full flex items-center justify-center mb-3 transition-colors">
|
||||
<Eye className="w-8 h-8 text-gray-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 group-hover:text-blue-600 font-medium">
|
||||
点击预览原始文件
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 mt-1">
|
||||
{formatFileSize(selectedFile?.srcSize)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{t("dataCleansing.detail.fileTable.afterProcessing")}</h4>
|
||||
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="w-16 h-16 bg-blue-300 rounded-lg mx-auto mb-2" />
|
||||
<div className="text-sm">{t("dataCleansing.detail.fileTable.processedFilePreview")}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("dataCleansing.detail.fileTable.size")}: {formatFileSize(selectedFile?.destSize)}
|
||||
|
||||
{/* --- 右侧:处理后文件 --- */}
|
||||
<div className="flex flex-col h-[600px]">
|
||||
<h4 className="font-medium text-gray-900 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
{t("dataCleansing.detail.fileTable.afterProcessing")}
|
||||
{destPreview.loading && (
|
||||
<span className="text-xs text-blue-500 flex items-center">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" /> Loading...
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-50 overflow-hidden relative">
|
||||
{destPreview.blob || destPreview.content ? (
|
||||
// ✅ 修复:直接传入 blob 对象
|
||||
<FilePreview
|
||||
fileName={selectedFile.destName}
|
||||
blob={destPreview.blob}
|
||||
content={destPreview.content}
|
||||
/>
|
||||
) : destPreview.error ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-500 p-4 text-center">
|
||||
<span className="text-2xl mb-2">⚠️</span>
|
||||
<span className="text-sm font-medium">
|
||||
{destPreview.error}
|
||||
</span>
|
||||
<span className="text-xs mt-1 text-gray-500">
|
||||
请检查接口权限或路径
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => loadPreview("dest")}
|
||||
size="small"
|
||||
className="mt-2"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-3 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">{t("dataCleansing.detail.fileTable.fileFormat")}:</span> {selectedFile?.destType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 mt-6 pt-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">{t("dataCleansing.detail.fileTable.processingEffect")}</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="font-medium text-green-700">{t("dataCleansing.detail.fileTable.sizeOptimization")}</div>
|
||||
<div className="text-green-600">{t("dataCleansing.detail.fileTable.reduced", {percent: (100 * (selectedFile?.srcSize - selectedFile?.destSize) / selectedFile?.srcSize).toFixed(2)})}</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => loadPreview("dest")}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gray-200 group-hover:bg-blue-100 rounded-full flex items-center justify-center mb-3 transition-colors">
|
||||
<Eye className="w-8 h-8 text-gray-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 group-hover:text-blue-600 font-medium">
|
||||
点击预览处理后文件
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 mt-1">
|
||||
{formatFileSize(selectedFile?.destSize)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,202 +1,278 @@
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useEffect, useState, useRef, useMemo, useCallback, useLayoutEffect } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { FileClock, Download } from "lucide-react";
|
||||
import { Download, Copy, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { App } from "antd";
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { streamCleaningTaskLog, downloadCleaningTaskLog } from "../../cleansing.api";
|
||||
import { queryCleaningTaskLogByIdUsingGet, streamCleaningTaskLog, downloadCleaningTaskLog } from "../../cleansing.api";
|
||||
import { TaskStatus } from "../../cleansing.model";
|
||||
|
||||
interface LogEntry {
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LogEntryWithIndex extends LogEntry {
|
||||
index: number;
|
||||
}
|
||||
interface LogEntry { level: string; message: string; }
|
||||
interface LogEntryWithIndex extends LogEntry { index: number; }
|
||||
|
||||
export default function LogsTable({
|
||||
taskLog: initialLogs,
|
||||
fetchTaskLog,
|
||||
retryCount,
|
||||
taskName,
|
||||
taskStatus
|
||||
}: {
|
||||
taskLog: LogEntry[],
|
||||
fetchTaskLog: () => Promise<LogEntry[]>,
|
||||
retryCount: number,
|
||||
taskName: string,
|
||||
taskStatus?: TaskStatus
|
||||
retryCount: number;
|
||||
taskName: string;
|
||||
taskStatus?: TaskStatus;
|
||||
}) {
|
||||
const { id = "" } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const [selectedLog, setSelectedLog] = useState(retryCount + 1);
|
||||
const [streamingLogs, setStreamingLogs] = useState<LogEntry[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const { message } = App.useApp();
|
||||
|
||||
// Only stream when task is RUNNING and viewing the latest run
|
||||
const shouldStream = taskStatus === TaskStatus.RUNNING && selectedLog - 1 === retryCount;
|
||||
// 内部日志状态
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [selectedRun, setSelectedRun] = useState(retryCount + 1);
|
||||
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const prevRetryCountRef = useRef(retryCount);
|
||||
|
||||
// 🔴 核心修复:用于测量容器高度的 Ref
|
||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [listHeight, setListHeight] = useState(500); // 默认高度,防止闪烁
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldStream) {
|
||||
startStreaming();
|
||||
} else {
|
||||
stopStreaming();
|
||||
fetchTaskLog(selectedLog - 1).then(() => {
|
||||
// Static logs loaded, safe to clear streaming logs now
|
||||
setStreamingLogs([]);
|
||||
});
|
||||
}
|
||||
return () => stopStreaming();
|
||||
}, [id, selectedLog, retryCount, shouldStream]);
|
||||
|
||||
const startStreaming = () => {
|
||||
stopStreaming();
|
||||
setStreamingLogs([]);
|
||||
setIsStreaming(true);
|
||||
|
||||
const eventSource = streamCleaningTaskLog(id, selectedLog - 1);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const logEntry: LogEntry = JSON.parse(event.data);
|
||||
if (logEntry.message === "[END_OF_STREAM]" || logEntry.message === "[HEARTBEAT]") {
|
||||
if (logEntry.message === "[END_OF_STREAM]") {
|
||||
// Don't clear streamingLogs immediately - keep them visible
|
||||
// while the static fetch completes
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStreamingLogs(prev => [...prev, logEntry]);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse log entry:", e);
|
||||
// 🔴 核心修复:动态测量可用高度
|
||||
useLayoutEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (listContainerRef.current) {
|
||||
setListHeight(listContainerRef.current.clientHeight);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
// 监听容器大小变化(例如窗口缩放或标签页切换)
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
if (listContainerRef.current) {
|
||||
resizeObserver.observe(listContainerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("SSE error:", error);
|
||||
stopStreaming();
|
||||
};
|
||||
};
|
||||
|
||||
const stopStreaming = () => {
|
||||
// 1. 停止流
|
||||
const stopStreaming = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
// 2. 开启流 (Tail 模式,仅追加)
|
||||
const startStreaming = useCallback(() => {
|
||||
const isLatest = selectedRun === retryCount + 1;
|
||||
if (!isLatest || isStreaming) return;
|
||||
|
||||
setIsStreaming(true);
|
||||
const es = streamCleaningTaskLog(id, selectedRun - 1);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const logEntry: LogEntry = JSON.parse(event.data);
|
||||
if (logEntry.message === "[END_OF_STREAM]" || logEntry.message === "[WAITING_TIMEOUT]") {
|
||||
if (logEntry.message === "[END_OF_STREAM]") stopStreaming();
|
||||
return;
|
||||
}
|
||||
setLogs(prev => [...prev, logEntry]);
|
||||
} catch (e) { console.error("SSE parse error", e); }
|
||||
};
|
||||
es.onerror = () => stopStreaming();
|
||||
}, [id, selectedRun, retryCount, isStreaming, stopStreaming]);
|
||||
|
||||
// Effect A: 加载历史数据
|
||||
useEffect(() => {
|
||||
stopStreaming();
|
||||
setLogs([]);
|
||||
setIsLoading(true);
|
||||
|
||||
const currentRetry = selectedRun - 1;
|
||||
queryCleaningTaskLogByIdUsingGet(id, currentRetry)
|
||||
.then(res => {
|
||||
setLogs(res.data || []);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => setIsLoading(false));
|
||||
|
||||
}, [selectedRun, id, stopStreaming]);
|
||||
|
||||
// Effect B: 管理实时流连接
|
||||
useEffect(() => {
|
||||
const isLatest = selectedRun === retryCount + 1;
|
||||
const shouldStream = isLatest && taskStatus === TaskStatus.RUNNING;
|
||||
|
||||
if (shouldStream && !isStreaming && !isLoading) {
|
||||
startStreaming();
|
||||
} else if (!shouldStream) {
|
||||
stopStreaming();
|
||||
}
|
||||
}, [selectedRun, retryCount, taskStatus, isStreaming, isLoading, startStreaming, stopStreaming]);
|
||||
|
||||
// Effect C: 智能同步 (只在用户查看最新轮次且任务重启时跟随)
|
||||
useEffect(() => {
|
||||
if (retryCount !== prevRetryCountRef.current) {
|
||||
const wasViewingLatest = (selectedRun - 1) === prevRetryCountRef.current;
|
||||
if (wasViewingLatest) {
|
||||
setSelectedRun(retryCount + 1);
|
||||
}
|
||||
prevRetryCountRef.current = retryCount;
|
||||
}
|
||||
}, [retryCount, selectedRun]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => stopStreaming();
|
||||
}, [stopStreaming]);
|
||||
|
||||
// 3. 复制逻辑
|
||||
const handleCopyLogs = () => {
|
||||
if (logs.length === 0) {
|
||||
message.warning("当前没有可复制的日志");
|
||||
return;
|
||||
}
|
||||
const text = logs.map(log => `[${log.level}] ${log.message}`).join('\n');
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => message.success("日志已复制"));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
};
|
||||
|
||||
// Use streaming logs only when actively streaming, otherwise use initial logs
|
||||
// Keep streamingLogs visible until initialLogs are populated (avoids blank flash)
|
||||
const displayLogs = isStreaming || streamingLogs.length > 0
|
||||
? (streamingLogs.length > 0 ? streamingLogs : initialLogs)
|
||||
: initialLogs;
|
||||
|
||||
// Add index to logs for virtual list key
|
||||
const logsWithIndex: LogEntryWithIndex[] = useMemo(() => {
|
||||
return (displayLogs || []).map((log, index) => ({ ...log, index }));
|
||||
}, [displayLogs]);
|
||||
|
||||
// Get log level color class
|
||||
const getLevelClass = (level: string) => {
|
||||
if (level === "ERROR" || level === "FATAL") return "text-red-500";
|
||||
if (level === "WARNING" || level === "WARN") return "text-yellow-500";
|
||||
return "text-green-500";
|
||||
};
|
||||
|
||||
const handleSelectChange = (value: number) => {
|
||||
setSelectedLog(value);
|
||||
setStreamingLogs([]);
|
||||
const fallbackCopy = (text: string) => {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
message.success("日志已复制");
|
||||
} catch (err) {
|
||||
message.error("复制失败");
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const blob = await downloadCleaningTaskLog(id, selectedLog - 1);
|
||||
const blob = await downloadCleaningTaskLog(id, selectedRun - 1);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${taskName}_${t("dataCleansing.logTable.nthRun", { num: selectedLog })}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.href = url; a.download = `${taskName}_run_${selectedRun}.log`;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("Failed to download log:", error);
|
||||
message.error(t("dataCleansing.detail.logTable.downloadFailed"));
|
||||
}
|
||||
} catch (e) { console.error("Download failed", e); }
|
||||
};
|
||||
|
||||
return displayLogs?.length > 0 || isStreaming ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-500">{t("dataCleansing.detail.logTable.selectRun")}:</label>
|
||||
<select
|
||||
value={selectedLog}
|
||||
onChange={(e) => handleSelectChange(Number(e.target.value))}
|
||||
className="bg-gray-700 border border-gray-600 !text-white text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block px-2.5 py-1.5 min-w-[120px]"
|
||||
>
|
||||
{Array.from({ length: retryCount + 1 }, (_, i) => retryCount + 1 - i).map((num) => (
|
||||
<option key={num} value={num}>
|
||||
{t("dataCleansing.detail.logTable.currentDisplay", { num: num })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStreaming && (
|
||||
<span className="text-xs text-blue-400 animate-pulse">{t("dataCleansing.detail.logTable.streaming")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-s text-gray-500 px-2">{t("dataCleansing.detail.logTable.nthRun", { num: selectedLog })}</span>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isStreaming}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 border border-blue-500 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>{t("dataCleansing.detail.logTable.download")}</span>
|
||||
</button>
|
||||
const hasContent = logs.length > 0;
|
||||
const logsWithIndex: LogEntryWithIndex[] = useMemo(() =>
|
||||
logs.map((log, index) => ({ ...log, index })), [logs]);
|
||||
|
||||
const getLevelClass = (level: string) => {
|
||||
if (level.includes("ERROR") || level.includes("FATAL")) return "text-red-500";
|
||||
if (level.includes("WARN")) return "text-yellow-500";
|
||||
return "text-green-500";
|
||||
};
|
||||
|
||||
const isWaitingForLogs = !hasContent && !isLoading && taskStatus === TaskStatus.RUNNING;
|
||||
|
||||
return (
|
||||
// 🔴 核心修复:使用 Flex 布局 + 固定高度 + overflow-hidden 切断外部滚动
|
||||
<div className="relative flex flex-col h-[600px] w-full bg-gray-800 border border-gray-700 rounded-lg overflow-hidden text-gray-300">
|
||||
|
||||
{/* 1. 头部区域 (绝对固定,不参与滚动) */}
|
||||
<div className="flex-none p-3 bg-gray-800 border-b border-gray-700 z-10 shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-500">{t("dataCleansing.detail.logTable.selectRun")}:</label>
|
||||
|
||||
<select
|
||||
value={selectedRun}
|
||||
onChange={(e) => setSelectedRun(Number(e.target.value))}
|
||||
className="bg-gray-700 border border-gray-600 !text-white text-sm rounded-md px-2.5 py-1.5 min-w-[120px]"
|
||||
>
|
||||
{Array.from({ length: retryCount + 1 }, (_, i) => retryCount + 1 - i).map((num) => (
|
||||
<option key={num} value={num}>
|
||||
{t("dataCleansing.detail.logTable.currentDisplay", { num }) || `第 ${num} 次运行`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{isStreaming ? (
|
||||
<span className="text-xs text-blue-400 animate-pulse flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400"></span>实时输出中...
|
||||
</span>
|
||||
) : isWaitingForLogs ? (
|
||||
<span className="text-xs text-yellow-400 animate-pulse flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-400"></span>正在连接...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">历史日志</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleCopyLogs} disabled={!hasContent} className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-gray-600 hover:bg-gray-700 border border-gray-500 rounded-md transition-colors disabled:opacity-50">
|
||||
<Copy className="w-4 h-4" />
|
||||
<span>复制</span>
|
||||
</button>
|
||||
<button onClick={handleDownload} className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 border border-blue-500 rounded-md transition-colors">
|
||||
<Download className="w-4 h-4" />
|
||||
<span>下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-300 p-4 border border-gray-700 bg-gray-800 rounded-lg">
|
||||
<div className="font-mono text-sm">
|
||||
<VirtualList
|
||||
data={logsWithIndex}
|
||||
height={580}
|
||||
itemHeight={22}
|
||||
|
||||
{/* 2. 内容区域 (自适应剩余高度,内部滚动) */}
|
||||
{/* 🔴 关键:ref 用于测量高度,overflow-hidden 确保内容不溢出 */}
|
||||
<div className="flex-1 relative overflow-hidden bg-gray-900/50" ref={listContainerRef}>
|
||||
{hasContent ? (
|
||||
// 🔴 VirtualList 使用动态计算的高度,确保滚动条只在这个区域内生成
|
||||
<VirtualList
|
||||
key={selectedRun}
|
||||
data={logsWithIndex}
|
||||
height={listHeight}
|
||||
itemHeight={22}
|
||||
itemKey="index"
|
||||
// 确保滚动条样式正常(部分浏览器可能需要)
|
||||
className="scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent"
|
||||
>
|
||||
{(log: LogEntryWithIndex) => (
|
||||
<div className="flex gap-3 py-0.5">
|
||||
<span className={`min-w-20 ${getLevelClass(log.level)}`}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<div className="flex gap-3 py-0.5 whitespace-pre-wrap px-4">
|
||||
<span className={`min-w-20 ${getLevelClass(log.level)}`}>[{log.level}]</span>
|
||||
<span className="text-gray-100">{log.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</VirtualList>
|
||||
{isStreaming && (
|
||||
<div className="flex gap-3 animate-pulse py-0.5">
|
||||
<span className="text-gray-400">...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
|
||||
<p>正在加载日志...</p>
|
||||
</>
|
||||
) : isWaitingForLogs ? (
|
||||
<>
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
|
||||
<p className="text-sm">等待任务产生日志...</p>
|
||||
<p className="text-xs text-gray-600 mt-1">Runtime 正在初始化。</p>
|
||||
</>
|
||||
) : taskStatus === TaskStatus.PENDING ? (
|
||||
<p>任务排队中,等待执行...</p>
|
||||
) : (
|
||||
<p className="text-center">当前任务无可用日志</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileClock className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{t("dataCleansing.detail.logTable.noLogs")}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,145 +1,278 @@
|
||||
import {Badge, Button, Input, Table, Typography} from "antd";
|
||||
import {useNavigate} from "react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Badge, Button, Table, Typography, Modal, message, Space, Popconfirm,
|
||||
Tabs, Alert, Input
|
||||
} from "antd";
|
||||
import { EditOutlined, DeleteOutlined, SaveOutlined, FormOutlined, CodeOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/Card"
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { List } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskStatus } from "@/pages/DataCleansing/cleansing.model";
|
||||
import { updateTask } from "../../cleansing.api";
|
||||
|
||||
export default function OperatorTable({ task }: { task: any }) {
|
||||
import ParamConfig from "../../Create/components/ParamConfig";
|
||||
|
||||
// ==========================================
|
||||
// 🌍 全局提交策略控制开关
|
||||
// ==========================================
|
||||
// 'slim' : 瘦身模式(推荐)- 剔除与默认值相同的参数,只提交差异部分
|
||||
// 'full' : 全量模式 - 提交所有参数,无论是否修改
|
||||
// ==========================================
|
||||
const SUBMIT_MODE: 'slim' | 'full' = 'slim';
|
||||
|
||||
/**
|
||||
* 🔪 数据瘦身函数
|
||||
* 遍历 overrides,若值与 schema 定义的 defaultVal 一致,则剔除
|
||||
*/
|
||||
const cleanOverrides = (overrides: Record<string, any>, schema: Record<string, any>) => {
|
||||
if (SUBMIT_MODE === 'full') return overrides;
|
||||
|
||||
const cleaned: Record<string, any> = {};
|
||||
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
const meta = schema[key];
|
||||
// 获取默认值(兼容嵌套对象 { defaultVal: x } 或直接值 x)
|
||||
const defaultVal = meta && typeof meta === 'object' && 'defaultVal' in meta
|
||||
? meta.defaultVal
|
||||
: meta;
|
||||
|
||||
// 安全比较函数(支持基本类型、null、简单对象/数组)
|
||||
const isEqual = (a: any, b: any) => {
|
||||
if (a === b) return true;
|
||||
if (a == null && b == null) return true;
|
||||
if (typeof a === 'object' && typeof b === 'object') return JSON.stringify(a) === JSON.stringify(b);
|
||||
return false;
|
||||
};
|
||||
|
||||
// 仅保留与默认值不同的字段
|
||||
if (!isEqual(value, defaultVal)) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
/**
|
||||
* 🛡️ JSON Schema 校验器
|
||||
*/
|
||||
const validateJsonSchema = (jsonStr: string, schema: Record<string, any>): string | null => {
|
||||
let parsed: any;
|
||||
try { parsed = JSON.parse(jsonStr); } catch { return "JSON 语法错误,请检查引号、逗号或括号"; }
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return "格式错误:必须是键值对对象 {}";
|
||||
|
||||
const errors: string[] = [];
|
||||
Object.entries(schema).forEach(([key, meta]: [string, any]) => {
|
||||
const val = parsed[key];
|
||||
if (val === undefined) return;
|
||||
|
||||
const type = (meta.type || 'input').toLowerCase();
|
||||
if (type === 'inputnumber' || type === 'slider') {
|
||||
if (typeof val !== 'number') errors.push(`"${key}" 必须是数字`);
|
||||
else {
|
||||
if (meta.min !== undefined && val < meta.min) errors.push(`"${key}" 小于最小值 ${meta.min}`);
|
||||
if (meta.max !== undefined && val > meta.max) errors.push(`"${key}" 大于最大值 ${meta.max}`);
|
||||
}
|
||||
} else if (type === 'switch' && typeof val !== 'boolean') {
|
||||
errors.push(`"${key}" 必须是 true 或 false`);
|
||||
} else if ((type === 'select' || type === 'radio') && typeof val === 'string' && meta.options?.length) {
|
||||
const valid = meta.options.map((o: any) => typeof o === 'string' ? o : o.value);
|
||||
if (!valid.includes(val)) errors.push(`"${key}" 必须是 [${valid.join(', ')}] 之一`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors.length ? `参数校验失败:\n• ${errors.join('\n• ')}` : null;
|
||||
};
|
||||
|
||||
export default function OperatorTable({ task, refresh }: { task: any; refresh?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const operatorColumns = [
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.serialNumber"),
|
||||
dataIndex: "index",
|
||||
key: "index",
|
||||
width: 80,
|
||||
render: (text: any, record: any, index: number) => index + 1,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.operatorName"),
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text, record) => (
|
||||
<Typography.Link
|
||||
onClick={() => navigate(`/data/operator-market/plugin-detail/${record.id}`)}
|
||||
>
|
||||
{text}
|
||||
</Typography.Link>
|
||||
),
|
||||
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.operatorTable.searchOperatorName")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) => record.name.toLowerCase().includes(value.toLowerCase()),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.startTime"),
|
||||
dataIndex: "startTime",
|
||||
key: "startTime",
|
||||
sorter: (a: any, b: any) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.endTime"),
|
||||
dataIndex: "endTime",
|
||||
key: "endTime",
|
||||
sorter: (a: any, b: any) => new Date(a.endTime).getTime() - new Date(b.endTime).getTime(),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.duration"),
|
||||
dataIndex: "duration",
|
||||
key: "duration",
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.processedFiles"),
|
||||
dataIndex: "processedFiles",
|
||||
key: "processedFiles",
|
||||
sorter: (a: any, b: any) => a.processedFiles - b.processedFiles,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.successRate"),
|
||||
dataIndex: "successRate",
|
||||
key: "successRate",
|
||||
sorter: (a: any, b: any) => a.successRate - b.successRate,
|
||||
render: (rate: number) => `${rate}%`,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
filters: [
|
||||
{ text: t("dataCleansing.detail.operatorTable.completed"), value: "completed" },
|
||||
{ text: t("dataCleansing.detail.operatorTable.failed"), value: "failed" },
|
||||
{ text: t("dataCleansing.detail.operatorTable.running"), value: "running" },
|
||||
{ text: t("dataCleansing.detail.operatorTable.partialSuccess"), value: "partialSuccess" },
|
||||
],
|
||||
onFilter: (value: string, record: any) => record.statusValue === value,
|
||||
render: (statusObj: { label: string; value: TaskStatus }) => {
|
||||
let badgeStatus: "default" | "processing" | "success" | "error" | "warning" = "default";
|
||||
|
||||
if (statusObj?.value === TaskStatus.COMPLETED) {
|
||||
badgeStatus = "success";
|
||||
} else if (statusObj?.value === TaskStatus.RUNNING) {
|
||||
badgeStatus = "processing";
|
||||
} else if (statusObj?.value === TaskStatus.PARTIAL_SUCCESS) {
|
||||
badgeStatus = "warning";
|
||||
} else if (statusObj?.value === TaskStatus.FAILED) {
|
||||
badgeStatus = "error";
|
||||
}
|
||||
|
||||
return <Badge status={badgeStatus} text={statusObj?.label} />;
|
||||
},
|
||||
},
|
||||
]
|
||||
const [localInstances, setLocalInstances] = useState<any[]>([]);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [currentOpId, setCurrentOpId] = useState<string | null>(null);
|
||||
const [parsedConfig, setParsedConfig] = useState<Record<string, any>>({});
|
||||
const [tempOverrides, setTempOverrides] = useState<Record<string, any>>({});
|
||||
|
||||
const [editMode, setEditMode] = useState<"visual" | "json">("visual");
|
||||
const [editJson, setEditJson] = useState("");
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [formRefreshKey, setFormRefreshKey] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return task?.instance?.length > 0 && (
|
||||
<>
|
||||
<Card className="bg-white/70 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-5 h-5 text-purple-600" />
|
||||
{t("dataCleansing.detail.operatorTable.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("dataCleansing.detail.operatorTable.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table
|
||||
columns={operatorColumns}
|
||||
dataSource={Object.values(task?.instance).map((item) => ({
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
startTime: new Date(task?.startedAt).toLocaleTimeString(),
|
||||
endTime: task?.finishedAt
|
||||
? new Date(task.finishedAt).toLocaleTimeString()
|
||||
: '-',
|
||||
duration: task.duration,
|
||||
status: task.status,
|
||||
statusValue: task.status.value,
|
||||
processedFiles: task.progress.finishedFileNum,
|
||||
successRate: task?.progress.successRate,
|
||||
}))}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
// 1. 初始化算子列表
|
||||
useEffect(() => {
|
||||
if (task?.instance) {
|
||||
const list = Array.isArray(task.instance) ? task.instance : Object.values(task.instance);
|
||||
const globalStats = {
|
||||
startTime: task.startedAt ? new Date(task.startedAt).toLocaleTimeString() : '-',
|
||||
endTime: task.finishedAt ? new Date(task.finishedAt).toLocaleTimeString() : '-',
|
||||
duration: task.duration ?? '-',
|
||||
processedFiles: task.progress?.finishedFileNum ?? 0,
|
||||
successRate: task.progress?.successRate ?? task.progress?.process ?? 0,
|
||||
status: task.status,
|
||||
statusValue: typeof task.status === 'object' ? task.status?.value : task.status,
|
||||
};
|
||||
|
||||
const merged = list.map((item: any) => {
|
||||
let defaults = {};
|
||||
try { defaults = typeof item.settings === 'string' ? JSON.parse(item.settings) : (item.settings || {}); } catch (e) {}
|
||||
// const overrides = item.overrides || {};
|
||||
// return { ...item, mergedConfig: { ...defaults, ...overrides }, ...globalStats };
|
||||
const overrides = item.overrides || {};
|
||||
const mergedConfig = Object.entries(defaults).reduce((acc, [key, meta]) => {
|
||||
acc[key] = {
|
||||
...(meta as any),
|
||||
value: overrides[key] ?? (meta as any).defaultVal
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
return { ...item, mergedConfig, ...globalStats };
|
||||
});
|
||||
setLocalInstances(merged);
|
||||
}
|
||||
}, [task]);
|
||||
|
||||
// 2. 打开编辑弹窗
|
||||
const handleOpenEdit = (record: any) => {
|
||||
setCurrentOpId(record.id);
|
||||
const config = record.mergedConfig || {};
|
||||
const normalizedConfig: Record<string, any> = {};
|
||||
const flatValues: Record<string, any> = {};
|
||||
|
||||
Object.entries(config).forEach(([key, value]) => {
|
||||
if (value && typeof value === 'object' && 'defaultVal' in value) {
|
||||
normalizedConfig[key] = value;
|
||||
flatValues[key] = record.overrides?.[key] ?? value.defaultVal;
|
||||
} else {
|
||||
normalizedConfig[key] = { defaultVal: value, type: typeof value === 'number' ? 'inputNumber' : typeof value === 'boolean' ? 'switch' : 'input', description: key };
|
||||
flatValues[key] = record.overrides?.[key] ?? value;
|
||||
}
|
||||
});
|
||||
|
||||
setParsedConfig(normalizedConfig);
|
||||
setTempOverrides(flatValues);
|
||||
setEditJson(JSON.stringify(flatValues, null, 2));
|
||||
setJsonError(null);
|
||||
setEditMode("visual");
|
||||
setFormRefreshKey(0);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setEditJson(val);
|
||||
setJsonError(validateJsonSchema(val, parsedConfig));
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
const targetMode = key as "visual" | "json";
|
||||
if (targetMode === "visual") {
|
||||
if (jsonError) { message.error("无法切换:JSON 数据未通过校验,请修正后切换"); return; }
|
||||
try {
|
||||
setTempOverrides(JSON.parse(editJson));
|
||||
setFormRefreshKey(prev => prev + 1);
|
||||
setEditMode("visual");
|
||||
} catch { message.error("JSON 解析失败"); }
|
||||
} else {
|
||||
setEditJson(JSON.stringify(tempOverrides, null, 2));
|
||||
setJsonError(null);
|
||||
setEditMode("json");
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 统一提交逻辑(含瘦身处理)
|
||||
const submitTaskConfig = async (rawOverrides: Record<string, any>) => {
|
||||
if (!task?.id || !currentOpId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 🔪 调用瘦身函数
|
||||
const finalOverrides = cleanOverrides(rawOverrides, parsedConfig);
|
||||
|
||||
// 🐛 调试用:可在浏览器 Console 观察瘦身效果
|
||||
console.log(`[提交策略: ${SUBMIT_MODE}] 原始数据:`, rawOverrides);
|
||||
console.log(`[提交策略: ${SUBMIT_MODE}] 最终提交数据:`, finalOverrides);
|
||||
|
||||
const updatedInstances = localInstances.map(item => {
|
||||
if (item.id === currentOpId) return { ...item, overrides: finalOverrides };
|
||||
return item;
|
||||
});
|
||||
|
||||
setLocalInstances(updatedInstances);
|
||||
setEditModalOpen(false);
|
||||
|
||||
const payload = {
|
||||
id: task.id, name: task.name, description: task.description,
|
||||
src_dataset_id: task.src_dataset_id, dest_dataset_id: task.dest_dataset_id,
|
||||
instance: updatedInstances.map(item => ({ id: item.id, name: item.name, overrides: item.overrides }))
|
||||
};
|
||||
|
||||
await updateTask(task.id, payload);
|
||||
message.success(SUBMIT_MODE === 'slim' ? "✅ 差异配置已保存" : "✅ 全量配置已保存");
|
||||
if (refresh) refresh();
|
||||
} catch (err: any) {
|
||||
message.error("❌ 保存失败: " + (err.message || "未知错误"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editMode === "json") {
|
||||
if (jsonError) { message.error("请先修正 JSON 校验错误"); return; }
|
||||
try { await submitTaskConfig(JSON.parse(editJson)); } catch { message.error("JSON 格式异常"); }
|
||||
} else {
|
||||
await submitTaskConfig(tempOverrides);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOperator = async (record: any) => {
|
||||
const newInstances = localInstances.filter(item => item.id !== record.id);
|
||||
setLocalInstances(newInstances);
|
||||
if (!task?.id) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = { id: task.id, name: task.name, description: task.description, src_dataset_id: task.src_dataset_id, dest_dataset_id: task.dest_dataset_id, instance: newInstances.map(item => ({ id: item.id, name: item.name, overrides: item.overrides })) };
|
||||
await updateTask(task.id, payload);
|
||||
message.success("✅ 算子已删除");
|
||||
if (refresh) refresh();
|
||||
} catch { message.error("❌ 删除失败"); } finally { setIsSubmitting(false); }
|
||||
};
|
||||
|
||||
const operatorColumns = [
|
||||
{ title: "序号", width: 60, render: (_: any, __: any, index: number) => index + 1 },
|
||||
{ title: "算子名称", dataIndex: "name", render: (text, r) => (<Typography.Link onClick={() => navigate(`/data/operator-market/plugin-detail/${r.id}`)}>{text}</Typography.Link>) },
|
||||
{ title: "开始时间", dataIndex: "startTime" }, { title: "结束时间", dataIndex: "endTime" },
|
||||
{ title: "时长", dataIndex: "duration" }, { title: "处理文件", dataIndex: "processedFiles" },
|
||||
{ title: "成功率", dataIndex: "successRate", render: (v: number) => `${v}%` },
|
||||
{ title: "状态", dataIndex: "status", render: (s: any) => {
|
||||
const v = typeof s === 'object' ? s?.value : s; const l = typeof s === 'object' ? s?.label : s;
|
||||
let c = "default"; if (v === 'COMPLETED') c = "success"; else if (v === 'RUNNING') c = "processing"; else if (v === 'FAILED') c = "error";
|
||||
return <Badge status={c as any} text={l || v} />;
|
||||
}},
|
||||
{ title: "操作", key: "action", render: (_, r) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteOperator(r)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
if (!localInstances?.length) return null;
|
||||
|
||||
return (
|
||||
<Card className="shadow-sm border-0">
|
||||
<Table columns={operatorColumns} dataSource={localInstances} rowKey="id" pagination={false} size="small" />
|
||||
<Modal title={`编辑算子: ${localInstances.find(i => i.id === currentOpId)?.name}`} open={editModalOpen} onCancel={() => setEditModalOpen(false)} footer={<Space><Button onClick={() => setEditModalOpen(false)}>取消</Button><Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={isSubmitting}>保存并应用</Button></Space>} width={850}>
|
||||
<Tabs activeKey={editMode} onChange={handleTabChange} items={[
|
||||
{ key: "visual", label: <span><FormOutlined /> 可视化编辑</span>, children: (<div key={formRefreshKey} className="max-h-[60vh] overflow-y-auto pr-2 mt-2">{Object.entries(parsedConfig).map(([key, m]) => (<ParamConfig key={key} operator={{ id: currentOpId, overrides: tempOverrides } as any} paramKey={key} param={{ ...(m as any), value: tempOverrides[key] ?? m.defaultVal }} onParamChange={(_, k, v) => setTempOverrides(p => ({ ...p, [k]: v }))} />))}</div>) },
|
||||
{ key: "json", label: <span><CodeOutlined /> JSON 编辑</span>, children: (<div className="mt-2"><Alert message={jsonError ? "校验失败" : `提示 (${SUBMIT_MODE === 'slim' ? '瘦身模式' : '全量模式'})`} description={jsonError || "删除字段视为恢复默认值。仅提交与默认值不同的差异参数。"} type={jsonError ? "error" : "info"} showIcon style={{ marginBottom: 8, whiteSpace: "pre-line" }} /><Input.TextArea value={editJson} onChange={handleJsonChange} rows={20} style={{ fontFamily: 'monospace', fontSize: '13px' }} className={jsonError ? "!border-red-500 focus:!border-red-500" : ""} /></div>) }
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { get, post, put, del } from "@/utils/request";
|
||||
import { appendTokenToUrl, getAuthToken } from "@/utils/request";
|
||||
|
||||
// 清洗任务相关接口
|
||||
export function queryCleaningTasksUsingGet(params?: any) {
|
||||
@@ -18,7 +19,12 @@ export function queryCleaningTaskResultByIdUsingGet(taskId: string | number) {
|
||||
}
|
||||
|
||||
export function downloadCleaningTaskFiles(taskId: string | number) {
|
||||
const url = `/api/cleaning/tasks/${taskId}/result/download`;
|
||||
// 原始 URL
|
||||
const baseUrl = `/api/cleaning/tasks/${taskId}/result/download`;
|
||||
|
||||
// 🆕 修改:拼接 Token 参数
|
||||
const url = appendTokenToUrl(baseUrl);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `task_${taskId}_files.zip`;
|
||||
@@ -32,18 +38,48 @@ export function queryCleaningTaskLogByIdUsingGet(taskId: string | number, retryC
|
||||
}
|
||||
|
||||
export function streamCleaningTaskLog(taskId: string | number, retryCount: number = 0): EventSource {
|
||||
const url = `/api/cleaning/tasks/${taskId}/log/stream?retry_count=${retryCount}`;
|
||||
const baseUrl = `/api/cleaning/tasks/${taskId}/log/stream?retry_count=${retryCount}`;
|
||||
const url = appendTokenToUrl(baseUrl);
|
||||
return new EventSource(url);
|
||||
}
|
||||
|
||||
// 替换旧的 fetch 逻辑
|
||||
export function downloadCleaningTaskLog(taskId: string | number, retryCount: number = 0) {
|
||||
const baseUrl = `/api/cleaning/tasks/${taskId}/log/${retryCount}/download`;
|
||||
|
||||
// 🟢 核心修复:拼接 Token (若 JWT 开启) 并触发原生下载
|
||||
const url = appendTokenToUrl(baseUrl);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `task_${taskId}_retry_${retryCount}.log`;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
export async function downloadCleaningTaskLog(taskId: string | number, retryCount: number = 0): Promise<Blob> {
|
||||
const url = `/api/cleaning/tasks/${taskId}/log/${retryCount}/download`;
|
||||
const response = await fetch(url);
|
||||
const baseUrl = `/api/cleaning/tasks/${taskId}/log/${retryCount}/download`;
|
||||
|
||||
// 🆕 新增:手动添加 Header
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
headers: headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download log file');
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
*/
|
||||
|
||||
export function updateCleaningTaskByIdUsingPut(taskId: string | number, data: any) {
|
||||
return put(`/api/cleaning/tasks/${taskId}`, data);
|
||||
@@ -81,3 +117,12 @@ export function updateCleaningTemplateByIdUsingPut(templateId: string | number,
|
||||
export function deleteCleaningTemplateByIdUsingDelete(templateId: string | number) {
|
||||
return del(`/api/cleaning/templates/${templateId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务配置(复用创建任务的数据结构)
|
||||
* 对应后端接口:PUT /api/cleaning/tasks/{taskId}
|
||||
*/
|
||||
export function updateTask(taskId: string, data: any) {
|
||||
// 假设后端路由路径为 /api/cleaning/tasks/{taskId}
|
||||
return put(`/api/cleaning/tasks/${taskId}`, data);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { mapTaskExecution } from "@/pages/DataCollection/collection.const.ts";
|
||||
import { queryExecutionLogFileByIdUsingGet } from "../collection.apis";
|
||||
import { FileTextOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { appendTokenToUrl } from "@/utils/request";
|
||||
|
||||
export default function Execution({ taskId }: { taskId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -131,7 +132,7 @@ export default function Execution({ taskId }: { taskId?: string }) {
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: any) => ((
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
<Tag color={status?.color || "default"}>{status?.label || status}</Tag>
|
||||
)
|
||||
),
|
||||
},
|
||||
|
||||
@@ -71,12 +71,16 @@ export default function TaskManagement() {
|
||||
30000,
|
||||
false,
|
||||
[],
|
||||
0
|
||||
// xuhuanqing
|
||||
1
|
||||
);
|
||||
|
||||
// xuhuanqing
|
||||
/**
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [t]);
|
||||
*/
|
||||
|
||||
const handleStartTask = async (taskId: string) => {
|
||||
await executeTaskByIdUsingPost(taskId);
|
||||
|
||||
@@ -42,6 +42,12 @@ export function getStatusMap(
|
||||
color: "cyan",
|
||||
value: TaskStatus.PENDING,
|
||||
},
|
||||
// xuhuanqing
|
||||
[TaskStatus.SUBMITTED]: {
|
||||
label: t("common.status.task.submitted"),
|
||||
color: "orange",
|
||||
value: TaskStatus.SUBMITTED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,8 +112,53 @@ export function mapCollectionTask(task: CollectionTask, t: (key: string) => stri
|
||||
};
|
||||
}
|
||||
|
||||
// xuhuanqing
|
||||
export function mapTaskExecution(execution: TaskExecution, t: (key: string) => string): any {
|
||||
const statusMap = getStatusMap(t);
|
||||
const rawStatus = execution.status as string;
|
||||
|
||||
// 1. 尝试从标准映射表中获取
|
||||
let mappedStatus = statusMap[rawStatus as TaskStatus];
|
||||
|
||||
// 2. 🔍 调试与兜底逻辑
|
||||
if (!mappedStatus) {
|
||||
// 打印日志方便排查
|
||||
console.warn(`🐛 [Debug Trace] 缺失状态映射: "${rawStatus}"`);
|
||||
|
||||
// 针对 SUBMITTED 的强制兜底(不依赖枚举,防止枚举缺失导致报错)
|
||||
if (rawStatus === 'SUBMITTED') {
|
||||
mappedStatus = {
|
||||
label: "已提交",
|
||||
color: "orange",
|
||||
value: "SUBMITTED"
|
||||
};
|
||||
} else {
|
||||
// 针对其他未知状态的兜底,防止页面崩溃
|
||||
mappedStatus = {
|
||||
label: rawStatus,
|
||||
color: "default",
|
||||
value: rawStatus
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 🚨 核心修复:返回对象的顺序!
|
||||
// 必须把 status: mappedStatus 放在 ...execution 之后,否则会覆盖失败!
|
||||
return {
|
||||
...execution, // 先展开原始数据(此时 status 是字符串 "SUBMITTED")
|
||||
status: mappedStatus, // 后赋值对象(此时 status 变成 { label, color },覆盖掉字符串)
|
||||
|
||||
// 处理时间,防止 null 报错
|
||||
startedAt: execution.startedAt ? formatDateTime(execution.startedAt) : '-',
|
||||
completedAt: execution.completedAt ? formatDateTime(execution.completedAt) : '-',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
export function mapTaskExecution(execution: TaskExecution, t: (key: string) => string): any {
|
||||
const statusMap = getStatusMap(t);
|
||||
|
||||
return {
|
||||
...execution,
|
||||
status: statusMap[execution.status as TaskStatus],
|
||||
@@ -115,3 +166,4 @@ export function mapTaskExecution(execution: TaskExecution, t: (key: string) => s
|
||||
completedAt: formatDateTime(execution.completedAt),
|
||||
};
|
||||
}
|
||||
**/
|
||||
|
||||
@@ -5,6 +5,8 @@ export enum TaskStatus {
|
||||
COMPLETED = "COMPLETED",
|
||||
FAILED = "FAILED",
|
||||
STOPPED = "STOPPED",
|
||||
// xuhuanqing
|
||||
SUBMITTED = 'SUBMITTED',
|
||||
}
|
||||
|
||||
export enum SyncMode {
|
||||
|
||||
@@ -256,13 +256,27 @@ export function useFilesOperation(dataset: Dataset, onDatasetUpdate?: () => Prom
|
||||
else if (isPdfFile(fileName)) {
|
||||
setPreviewBlob(blob);
|
||||
}
|
||||
// xuhuanqing
|
||||
// ✅ 新增 Excel 文档处理
|
||||
else if (fileName.toLowerCase().endsWith('.xlsx') || fileName.toLowerCase().endsWith('.xls')) {
|
||||
setPreviewBlob(blob);
|
||||
}
|
||||
// 🔴 新增:视频文件支持
|
||||
else if (['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv'].some(ext => fileName.toLowerCase().endsWith(ext))) {
|
||||
setPreviewBlob(blob); // 保持 Blob 格式,由 VideoPreview 组件处理
|
||||
}
|
||||
// 🔴 新增:音频文件支持
|
||||
else if (['.mp3', '.wav', '.ogg', '.aac', '.flac'].some(ext => fileName.toLowerCase().endsWith(ext))) {
|
||||
setPreviewBlob(blob); // 保持 Blob 格式,由 AudioPreview 组件处理
|
||||
}
|
||||
// 其他文件(纯文本)
|
||||
else {
|
||||
const text = await blob.text();
|
||||
setPreviewContent(text);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error({ content: "文件预览失败" });
|
||||
} catch (error) {
|
||||
console.error("🔍 预览失败详情:", error); // 🆕 打印真实错误到控制台
|
||||
message.error({ content: `文件预览失败: ${error.message || '未知错误'}` });
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
|
||||
+312
-321
@@ -8,77 +8,86 @@ import {
|
||||
MessageSquare,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
Globe,
|
||||
Layers,
|
||||
Code,
|
||||
Cpu,
|
||||
ChevronRight,
|
||||
Activity
|
||||
} from "lucide-react";
|
||||
import { features, menuItems } from "../Layout/Menu.tsx";
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from "react-router";
|
||||
import { Card, Dropdown, Button } from "antd";
|
||||
import { Dropdown } from "antd";
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n";
|
||||
import { useCurrentRoles } from "@/hooks/useCurrentRoles";
|
||||
|
||||
// --- 组件:科技感卡片 (带流光边框) ---
|
||||
const TechCard = ({ children, className = "", onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`group relative rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||
>
|
||||
{/* 背景底色 */}
|
||||
<div className="absolute inset-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200 dark:border-slate-700 transition-colors duration-300 group-hover:border-blue-400/50" />
|
||||
|
||||
{/* 悬停流光效果 */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none">
|
||||
<div className="absolute top-[-50%] left-[-50%] w-[200%] h-[200%] bg-[conic-gradient(from_0deg,transparent_0_340deg,white_360deg)] opacity-10 animate-[spin_4s_linear_infinite]" />
|
||||
</div>
|
||||
|
||||
{/* 内容层 */}
|
||||
<div className="relative z-10 h-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- 组件:高对比度霓虹按钮 (修复文字清晰度) ---
|
||||
const NeonButton = ({ children, onClick, color = "blue", className = "", disabled }: { children: React.ReactNode, onClick: () => void, color?: string, className?: string, disabled?: boolean }) => {
|
||||
// 🛠️ 修复:使用深色阶 (600-700) 确保白字清晰可见
|
||||
const colors: any = {
|
||||
blue: "from-blue-600 to-indigo-700 shadow-blue-500/40 hover:shadow-blue-500/60",
|
||||
purple: "from-purple-600 to-pink-700 shadow-purple-500/40 hover:shadow-purple-500/60",
|
||||
orange: "from-orange-600 to-red-600 shadow-orange-500/40 hover:shadow-orange-500/60",
|
||||
};
|
||||
const gradient = disabled ? "from-gray-400 to-gray-500 cursor-not-allowed opacity-70" : (colors[color] || colors.blue);
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
className={`relative overflow-hidden h-12 px-8 rounded-xl bg-gradient-to-r ${gradient} text-white font-bold text-base shadow-lg hover:scale-[1.02] active:scale-95 transition-all flex items-center justify-center gap-2 ${className}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2 drop-shadow-md">{children}</span>
|
||||
{/* 高光叠加层 */}
|
||||
{!disabled && <div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/20 pointer-events-none" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function WelcomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { roles } = useCurrentRoles();
|
||||
|
||||
// 与侧边栏一致的角色过滤
|
||||
const visibleMenuItems = useMemo(
|
||||
() =>
|
||||
menuItems.filter(
|
||||
(item) => !item.roles || item.roles.some((r) => roles.includes(r))
|
||||
),
|
||||
[roles]
|
||||
);
|
||||
|
||||
// 首屏 CTA 指向第一个可见模块;无可见模块时回退到对话页(所有角色可用)
|
||||
const firstVisiblePath = visibleMenuItems.length
|
||||
? visibleMenuItems[0].children
|
||||
? `/data/${visibleMenuItems[0].children[0].id}`
|
||||
: `/data/${visibleMenuItems[0].id}`
|
||||
: "/chat";
|
||||
|
||||
// 语言切换菜单
|
||||
const languageMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'zh',
|
||||
label: t('header.simplifiedChinese'),
|
||||
onClick: () => {
|
||||
i18n.changeLanguage('zh');
|
||||
localStorage.setItem('language', 'zh');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'en',
|
||||
label: t('header.english'),
|
||||
onClick: () => {
|
||||
i18n.changeLanguage('en');
|
||||
localStorage.setItem('language', 'en');
|
||||
},
|
||||
}
|
||||
{ key: 'zh', label: t('header.simplifiedChinese'), onClick: () => { i18n.changeLanguage('zh'); localStorage.setItem('language', 'zh'); } },
|
||||
{ key: 'en', label: t('header.english'), onClick: () => { i18n.changeLanguage('en'); localStorage.setItem('language', 'en'); } }
|
||||
];
|
||||
|
||||
const checkDeepFlowDeploy = async (): Promise<boolean> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch('/deepflow-backend/api/config', {
|
||||
const response = await fetch('/deep-flow-backend/config', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('接口检查失败:', error);
|
||||
}
|
||||
@@ -87,17 +96,11 @@ export default function WelcomePage() {
|
||||
|
||||
const handleChatClick = async () => {
|
||||
if (isChecking) return;
|
||||
|
||||
setIsChecking(true);
|
||||
|
||||
try {
|
||||
const isDeepFlowDeploy = await checkDeepFlowDeploy();
|
||||
|
||||
if (isDeepFlowDeploy) {
|
||||
window.location.href = "/chat";
|
||||
} else {
|
||||
navigate("/chat");
|
||||
}
|
||||
if (isDeepFlowDeploy) window.location.href = "/chat";
|
||||
else navigate("/chat");
|
||||
} catch (error) {
|
||||
console.error('检查过程中发生错误:', error);
|
||||
navigate("/chat");
|
||||
@@ -107,309 +110,297 @@ export default function WelcomePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 relative">
|
||||
<Dropdown
|
||||
menu={{ items: languageMenuItems }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" className="flex items-center gap-2 absolute top-4 right-4 z-50 bg-white/80 hover:bg-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{i18n.language === 'zh' ? t('header.simplifiedChinese') : t('header.english')}</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-white relative overflow-hidden font-sans">
|
||||
|
||||
{/* 1. 背景层:网格与极光 */}
|
||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||
{/* 网格纹理 */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.03)_1px,transparent_1px)] bg-[size:40px_40px] dark:bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_60%_at_50%_50%,black_40%,transparent_100%)]" />
|
||||
{/* 极光 */}
|
||||
<div className="absolute top-[-20%] left-[-10%] w-[800px] h-[800px] bg-blue-400/20 rounded-full blur-[120px] animate-pulse" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[700px] h-[700px] bg-purple-400/20 rounded-full blur-[120px] animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-16">
|
||||
{/* xuhuanqing */}
|
||||
{/*
|
||||
<div className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{t('home.hero.subtitle')}
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 pb-24 space-y-24">
|
||||
|
||||
{/* 2. 顶部导航 */}
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<Database className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-xl tracking-tight">DeepData</span>
|
||||
</div>
|
||||
*/}
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-900 mb-6">
|
||||
DeepData
|
||||
<span className="text-blue-600"> {t('home.hero.title')}</span>
|
||||
<Dropdown menu={{ items: languageMenuItems }} placement="bottomRight">
|
||||
<button className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/50 hover:bg-white border border-slate-200 shadow-sm backdrop-blur-sm transition-all text-sm font-medium">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{i18n.language === 'zh' ? '中文' : 'English'}</span>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* ================= 3. Hero 区域 ================= */}
|
||||
<div className="text-center space-y-8 max-w-4xl mx-auto relative">
|
||||
{/* 装饰光环 */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[200px] bg-blue-500/20 blur-[80px] rounded-full -z-10" />
|
||||
|
||||
{/**
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-blue-100/50 border border-blue-200 text-blue-700 dark:text-blue-300 text-sm font-medium backdrop-blur-sm">
|
||||
<Activity className="w-3 h-3 animate-pulse" />
|
||||
{t('home.hero.subtitle') || "AI-Native Data Intelligence Platform"}
|
||||
</div>
|
||||
**/}
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-extrabold tracking-tight">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-slate-900 via-blue-800 to-slate-900 dark:from-white dark:via-blue-300 dark:to-white">
|
||||
{t('home.hero.title') || "Unlock Data Potential"}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto mb-8">
|
||||
|
||||
<p className="text-xl text-slate-600 dark:text-slate-400 leading-relaxed max-w-2xl mx-auto">
|
||||
{t('home.hero.description')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<span
|
||||
onClick={() => navigate(firstVisiblePath)}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white shadow-lg"
|
||||
>
|
||||
<Database className="mr-2 w-4 h-4" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-6">
|
||||
<NeonButton onClick={() => navigate("/data/management")} color="blue">
|
||||
<Database className="w-4 h-4" />
|
||||
{t('home.hero.getStartedButton')}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleChatClick}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<MessageSquare className="mr-2 w-4 h-4" />
|
||||
{isChecking ? t('home.hero.checkingButton') : t('home.hero.chatAssistantButton')}
|
||||
</span>
|
||||
<span
|
||||
onClick={() => navigate("/orchestration")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-700 hover:to-amber-700 text-white shadow-lg"
|
||||
>
|
||||
</NeonButton>
|
||||
|
||||
<NeonButton onClick={handleChatClick} color="purple" disabled={isChecking}>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
{isChecking ? t('home.hero.checkingButton') : t('home.hero.chatAssistantButton')}
|
||||
</NeonButton>
|
||||
|
||||
<NeonButton onClick={() => navigate("/orchestration")} color="orange">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
{t('home.hero.dataOrchestrationButton')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</span>
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-5 gap-6 mb-16">
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="border-0 shadow-lg hover:shadow-xl transition-shadow"
|
||||
>
|
||||
<div className="text-center pb-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<feature.icon className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-lg">{t(feature.titleKey || 'home.features.title' + index)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 text-sm">{t(feature.descriptionKey || 'home.features.description' + index)}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Menu Items Grid */}
|
||||
<div className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
{/* ================= 4. 菜单入口 (HUD 控制台风格) ================= */}
|
||||
<div className="space-y-10">
|
||||
<h2 className="text-3xl font-bold text-center relative">
|
||||
{t('home.sections.featuresTitle')}
|
||||
<div className="w-16 h-1 bg-blue-500 mx-auto mt-2 rounded-full" />
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{visibleMenuItems.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() => navigate(item.children ? `/data/${item.children[0].id}`: `/data/${item.id}`)}
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-0 shadow-md relative overflow-hidden group"
|
||||
>
|
||||
<div className="text-center relative">
|
||||
<div
|
||||
className={`w-16 h-16 ${item.color} rounded-xl flex items-center justify-center mx-auto mb-4 shadow-lg group-hover:scale-110 transition-transform duration-200`}
|
||||
>
|
||||
<item.icon className="w-8 h-8 text-white" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{menuItems.map((item) => {
|
||||
// 🛠️ 修复跳转逻辑
|
||||
const targetPath = item.children && item.children.length > 0
|
||||
? `/data/${item.children[0].id}`
|
||||
: `/data/${item.id}`;
|
||||
|
||||
return (
|
||||
<TechCard
|
||||
key={item.id}
|
||||
onClick={() => navigate(targetPath)}
|
||||
className="p-6 h-full flex flex-col items-center justify-center group"
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<div className={`w-16 h-16 ${item.color || 'bg-slate-600'} rounded-2xl flex items-center justify-center mx-auto shadow-lg group-hover:scale-110 group-hover:rotate-3 transition-all duration-300`}>
|
||||
<item.icon className="w-8 h-8 text-white drop-shadow-md" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xl font-bold group-hover:text-blue-600 transition-colors">
|
||||
{t(item.i18Key)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed line-clamp-2">
|
||||
{t(item.descriptionKey)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2"></div>
|
||||
<div className="text-xl group-hover:text-blue-600 transition-colors">
|
||||
{t(item.i18Key)}
|
||||
{/* 悬浮出现的箭头 */}
|
||||
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-all text-blue-500 transform translate-x-2 group-hover:translate-x-0">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm group-hover:text-gray-700 transition-colors">
|
||||
{item.descriptionKey ? t(item.descriptionKey) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</TechCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Orchestration Highlight */}
|
||||
<div className="mb-16">
|
||||
<Card className="bg-gradient-to-br from-orange-50 to-amber-50 border-orange-200 shadow-lg">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-amber-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<GitBranch className="w-8 h-8 text-white" />
|
||||
{/* ================= 5. 数据编排亮点 (完整保留内容) ================= */}
|
||||
<TechCard className="p-8 md:p-12 bg-gradient-to-br from-orange-50/60 to-amber-50/60 dark:from-orange-900/10 dark:to-amber-900/10 border-orange-200 dark:border-orange-900/50">
|
||||
<div className="flex flex-col md:flex-row gap-10">
|
||||
{/* 左侧:标题与特性 */}
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-orange-500 to-amber-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<GitBranch className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-orange-900 mb-2">
|
||||
<h3 className="text-3xl font-bold text-orange-900 dark:text-orange-100">
|
||||
{t('home.orchestrationHighlight.title')}
|
||||
</h3>
|
||||
<p className="text-orange-700">
|
||||
{t('home.orchestrationHighlight.description')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-orange-800 dark:text-orange-200 text-lg leading-relaxed">
|
||||
{t('home.orchestrationHighlight.description')}
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-6">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-orange-900">
|
||||
{t('home.orchestrationHighlight.coreFeaturesTitle')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-orange-800">
|
||||
{t('home.orchestrationHighlight.feature1')}
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-orange-800">
|
||||
{t('home.orchestrationHighlight.feature2')}
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-orange-800">
|
||||
{t('home.orchestrationHighlight.feature3')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-bold text-orange-900 dark:text-orange-100 flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-orange-500" />
|
||||
{t('home.orchestrationHighlight.coreFeaturesTitle')}
|
||||
</h4>
|
||||
{[
|
||||
t('home.orchestrationHighlight.feature1'),
|
||||
t('home.orchestrationHighlight.feature2'),
|
||||
t('home.orchestrationHighlight.feature3')
|
||||
].map((text, i) => (
|
||||
<div key={i} className="flex items-start gap-3 bg-white/60 dark:bg-white/5 rounded-lg p-3 border border-orange-100 dark:border-orange-800/30">
|
||||
<div className="w-5 h-5 rounded-full bg-orange-500 text-white flex items-center justify-center text-xs font-bold mt-0.5 shrink-0">{i + 1}</div>
|
||||
<span className="text-sm text-orange-900 dark:text-orange-200">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-orange-900">
|
||||
{t('home.orchestrationHighlight.smartFeaturesTitle')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-orange-800">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
{t('home.orchestrationHighlight.smartFeature1')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-orange-800">
|
||||
<Target className="w-4 h-4 text-orange-500" />
|
||||
{t('home.orchestrationHighlight.smartFeature2')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-orange-800">
|
||||
<Sparkles className="w-4 h-4 text-orange-500" />
|
||||
{t('home.orchestrationHighlight.smartFeature3')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span
|
||||
onClick={() => navigate("/orchestration")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-700 hover:to-amber-700 text-white shadow-lg"
|
||||
>
|
||||
<GitBranch className="mr-2 w-4 h-4" />
|
||||
{t('home.orchestrationHighlight.startButton')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data Agent Highlight */}
|
||||
<div className="mb-16">
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-purple-200 shadow-lg">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<MessageSquare className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-purple-900 mb-2">
|
||||
{/* 右侧:智能特性与按钮 */}
|
||||
<div className="flex-1 space-y-8 bg-white/40 dark:bg-black/20 rounded-xl p-6 border border-white/30 backdrop-blur-sm">
|
||||
<h4 className="font-bold text-orange-900 dark:text-orange-100 flex items-center gap-2 text-lg">
|
||||
<Sparkles className="w-5 h-5 text-orange-500" />
|
||||
{t('home.orchestrationHighlight.smartFeaturesTitle')}
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ icon: <Zap className="w-5 h-5 text-orange-500" />, text: t('home.orchestrationHighlight.smartFeature1') },
|
||||
{ icon: <Target className="w-5 h-5 text-orange-500" />, text: t('home.orchestrationHighlight.smartFeature2') },
|
||||
{ icon: <Sparkles className="w-5 h-5 text-orange-500" />, text: t('home.orchestrationHighlight.smartFeature3') }
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-3 text-sm text-orange-900 dark:text-orange-200">
|
||||
<div className="p-1.5 bg-orange-100 dark:bg-orange-900/50 rounded-md">{item.icon}</div>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-4 text-center md:text-left">
|
||||
<NeonButton onClick={() => navigate("/orchestration")} color="orange" className="w-full md:w-auto justify-center">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
{t('home.orchestrationHighlight.startButton')}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TechCard>
|
||||
|
||||
{/* ================= 6. Data Agent 亮点 (完整保留内容) ================= */}
|
||||
<TechCard className="p-8 md:p-12 bg-gradient-to-br from-purple-50/60 to-pink-50/60 dark:from-purple-900/10 dark:to-pink-900/10 border-purple-200 dark:border-purple-900/50">
|
||||
<div className="flex flex-col md:flex-row-reverse gap-10">
|
||||
{/* 右侧:标题与示例 */}
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="flex items-center justify-end gap-4 mb-2">
|
||||
<h3 className="text-3xl font-bold text-purple-900 dark:text-purple-100">
|
||||
{t('home.dataAgentHighlight.title')}
|
||||
</h3>
|
||||
<p className="text-purple-700">
|
||||
{t('home.dataAgentHighlight.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-6">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-purple-900">
|
||||
{t('home.dataAgentHighlight.examplesTitle')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-purple-800">
|
||||
"{t('home.dataAgentHighlight.example1')}"
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-purple-800">
|
||||
"{t('home.dataAgentHighlight.example2')}"
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-purple-800">
|
||||
"{t('home.dataAgentHighlight.example3')}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-purple-900">
|
||||
🚀 智能特性:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-purple-800">
|
||||
<Zap className="w-4 h-4 text-purple-500" />
|
||||
{t('home.dataAgentHighlight.smartFeature1')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-purple-800">
|
||||
<Target className="w-4 h-4 text-purple-500" />
|
||||
{t('home.dataAgentHighlight.smartFeature2')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-purple-800">
|
||||
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||
{t('home.dataAgentHighlight.smartFeature3')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<MessageSquare className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-purple-800 dark:text-purple-200 text-lg leading-relaxed text-right">
|
||||
{t('home.dataAgentHighlight.description')}
|
||||
</p>
|
||||
|
||||
<div className="text-center">
|
||||
<span
|
||||
onClick={handleChatClick}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg"
|
||||
>
|
||||
<MessageSquare className="mr-2 w-4 h-4" />
|
||||
{isChecking ? t('home.hero.checkingButton') : t('home.dataAgentHighlight.startButton')}
|
||||
</span>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-bold text-purple-900 dark:text-purple-100 flex items-center justify-end gap-2">
|
||||
{t('home.dataAgentHighlight.examplesTitle')}
|
||||
<Code className="w-5 h-5 text-purple-500" />
|
||||
</h4>
|
||||
{[
|
||||
t('home.dataAgentHighlight.example1'),
|
||||
t('home.dataAgentHighlight.example2'),
|
||||
t('home.dataAgentHighlight.example3')
|
||||
].map((text, i) => (
|
||||
<div key={i} className="bg-white/60 dark:bg-black/20 rounded-lg p-3 text-sm font-mono text-purple-900 dark:text-purple-100 border border-purple-100 dark:border-purple-800/30 text-right">
|
||||
"{text}"
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Workflow Showcase */}
|
||||
<div className="mb-16">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-blue-200 shadow-lg">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-bold text-blue-900 mb-2">
|
||||
{t('home.workflowShowcase.title')}
|
||||
</h3>
|
||||
<p className="text-blue-700">
|
||||
{t('home.workflowShowcase.description')}
|
||||
</p>
|
||||
{/* 左侧:智能特性与按钮 */}
|
||||
<div className="flex-1 space-y-8 bg-white/40 dark:bg-black/20 rounded-xl p-6 border border-white/30 backdrop-blur-sm">
|
||||
<h4 className="font-bold text-purple-900 dark:text-purple-100 flex items-center gap-2 text-lg">
|
||||
<Cpu className="w-5 h-5 text-purple-500" />
|
||||
🚀 {t('home.dataAgentHighlight.smartFeature1').split(':')[0]}:
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ icon: <Zap className="w-5 h-5 text-purple-500" />, text: t('home.dataAgentHighlight.smartFeature1') },
|
||||
{ icon: <Target className="w-5 h-5 text-purple-500" />, text: t('home.dataAgentHighlight.smartFeature2') },
|
||||
{ icon: <Sparkles className="w-5 h-5 text-purple-500" />, text: t('home.dataAgentHighlight.smartFeature3') }
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-3 text-sm text-purple-900 dark:text-purple-200">
|
||||
<div className="p-1.5 bg-purple-100 dark:bg-purple-900/50 rounded-md">{item.icon}</div>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<FolderOpen className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">{t('home.workflowShowcase.step1Title')}</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
{t('home.workflowShowcase.step1Description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-orange-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<GitBranch className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">{t('home.workflowShowcase.step2Title')}</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
{t('home.workflowShowcase.step2Description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-purple-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Settings className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">{t('home.workflowShowcase.step3Title')}</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
{t('home.workflowShowcase.step3Description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Target className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">{t('home.workflowShowcase.step4Title')}</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
{t('home.workflowShowcase.step4Description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span
|
||||
onClick={() => navigate("/data/management")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg"
|
||||
>
|
||||
<Sparkles className="mr-2 w-4 h-4" />
|
||||
{t('home.workflowShowcase.startButton')}
|
||||
</span>
|
||||
<div className="pt-4 text-center md:text-right">
|
||||
<NeonButton onClick={handleChatClick} color="purple" className="w-full md:w-auto justify-center">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
{isChecking ? t('home.hero.checkingButton') : t('home.dataAgentHighlight.startButton')}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TechCard>
|
||||
|
||||
{/* ================= 7. 工作流展示 (完整保留内容) ================= */}
|
||||
<TechCard className="p-8 md:p-12 bg-gradient-to-br from-blue-50/60 to-indigo-50/60 dark:from-blue-900/10 dark:to-indigo-900/10 border-blue-200 dark:border-blue-900/50">
|
||||
<div className="text-center mb-12 space-y-4">
|
||||
<h3 className="text-3xl font-bold text-blue-900 dark:text-blue-100">
|
||||
{t('home.workflowShowcase.title')}
|
||||
</h3>
|
||||
<p className="text-blue-700 dark:text-blue-300 max-w-2xl mx-auto">
|
||||
{t('home.workflowShowcase.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-12 relative">
|
||||
{/* 连接虚线 */}
|
||||
<div className="hidden md:block absolute top-12 left-[12%] right-[12%] h-0.5 bg-blue-300 dark:bg-blue-800 border-t-2 border-dashed border-blue-400 -z-10" />
|
||||
|
||||
{[
|
||||
{ icon: <FolderOpen />, color: "bg-blue-500", title: t('home.workflowShowcase.step1Title'), desc: t('home.workflowShowcase.step1Description') },
|
||||
{ icon: <GitBranch />, color: "bg-orange-500", title: t('home.workflowShowcase.step2Title'), desc: t('home.workflowShowcase.step2Description') },
|
||||
{ icon: <Settings />, color: "bg-purple-500", title: t('home.workflowShowcase.step3Title'), desc: t('home.workflowShowcase.step3Description') },
|
||||
{ icon: <Target />, color: "bg-green-500", title: t('home.workflowShowcase.step4Title'), desc: t('home.workflowShowcase.step4Description') }
|
||||
].map((step, i) => (
|
||||
<div key={i} className="flex flex-col items-center text-center space-y-4 relative group">
|
||||
{/* 节点中心 */}
|
||||
<div className={`w-24 h-24 ${step.color} rounded-2xl flex items-center justify-center mx-auto shadow-xl group-hover:scale-110 transition-transform duration-300 z-10`}>
|
||||
<div className="text-white w-10 h-10">{step.icon}</div>
|
||||
</div>
|
||||
<div className="bg-white/50 dark:bg-black/20 p-3 rounded-lg backdrop-blur-sm border border-white/30">
|
||||
<h4 className="font-bold text-blue-900 dark:text-blue-100 mb-1 text-sm">{step.title}</h4>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 leading-snug">{step.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<NeonButton onClick={() => navigate("/data/management")} color="blue" className="justify-center">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{t('home.workflowShowcase.startButton')}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</TechCard>
|
||||
|
||||
{/* ================= 8. 底部 Features 网格 ================= */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{features.map((feature, index) => (
|
||||
<TechCard key={index} className="p-5 text-center flex flex-col items-center justify-center h-full" hover={true}>
|
||||
<div className="w-12 h-12 mb-3 bg-blue-50 dark:bg-blue-900/20 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<feature.icon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="font-bold text-sm mb-1 truncate w-full">{t(feature.titleKey || 'home.features.title' + index)}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 leading-relaxed">
|
||||
{t(feature.descriptionKey || 'home.features.description' + index)}
|
||||
</div>
|
||||
</TechCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from "react"
|
||||
import { Button, Form, Input, Modal, message } from "antd"
|
||||
import { Lock } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { put } from "@/utils/request"
|
||||
|
||||
// 定义修改密码的 API 请求函数
|
||||
function changePasswordUsingPut(data: any) {
|
||||
return put("/api/user/change-password", data)
|
||||
}
|
||||
|
||||
interface ChangePasswordDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
confirmNewPassword: string
|
||||
}) => {
|
||||
if (values.newPassword !== values.confirmNewPassword) {
|
||||
message.error(t("user.messages.passwordMismatch"))
|
||||
return
|
||||
}
|
||||
if (values.newPassword.length < 6) {
|
||||
message.error(t("user.validations.passwordTooShort"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await changePasswordUsingPut({
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword,
|
||||
})
|
||||
message.success(t("user.messages.changePasswordSuccess"))
|
||||
onOpenChange(false)
|
||||
form.resetFields()
|
||||
} catch (error) {
|
||||
console.error("Change password error:", error)
|
||||
const errMsg = (error as any)?.data?.message || t("user.messages.changePasswordFailed")
|
||||
message.error(errMsg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5" />
|
||||
<span>{t('user.changePasswordDialog.title')}</span>
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
onOpenChange(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={400}
|
||||
maskClosable={false}
|
||||
>
|
||||
<div className="text-gray-500 mb-6">{t('user.changePasswordDialog.description')}</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="oldPassword"
|
||||
label={t('user.fields.oldPassword')}
|
||||
rules={[{ required: true, message: t('user.validations.passwordRequired') }]}
|
||||
>
|
||||
<Input.Password placeholder={t('user.placeholders.oldPassword')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label={t('user.fields.newPassword')}
|
||||
rules={[
|
||||
{ required: true, message: t('user.validations.passwordRequired') },
|
||||
{ min: 6, message: t('user.validations.passwordTooShort') },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder={t('user.placeholders.newPassword')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmNewPassword"
|
||||
label={t('user.fields.confirmNewPassword')}
|
||||
dependencies={["newPassword"]}
|
||||
rules={[
|
||||
{ required: true, message: t('user.validations.confirmPasswordRequired') },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("newPassword") === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error(t('user.validations.passwordMismatch')))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder={t('user.placeholders.confirmNewPassword')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
>
|
||||
{t('user.changePasswordDialog.submitButton')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +1,348 @@
|
||||
import { User, Globe, LogIn, UserPlus, Sparkles, Shield } from "lucide-react"
|
||||
import { memo, useState, useEffect, useCallback } from "react";
|
||||
import { NavLink } from "react-router";
|
||||
import { Button, Dropdown, message } from "antd"
|
||||
import type { MenuProps } from 'antd'
|
||||
import { LoginDialog } from "./LoginDialog"
|
||||
import { SignupDialog } from "./SignupDialog"
|
||||
import { post, get } from "@/utils/request.ts";
|
||||
import { getCachedHomePageUrl, setCachedHomePageUrl } from "@/utils/systemParam";
|
||||
import { getHomePageUrl } from "@/utils/systemParam";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
interface UserResponse {
|
||||
username: string;
|
||||
email?: string;
|
||||
groupId?: string;
|
||||
authenticated: boolean;
|
||||
authMode: 'SSO' | 'JWT' | 'NONE';
|
||||
requireLogin?: boolean; // 是否强制要求登录(由 DEEPDATA_JWT_ENABLE 控制)
|
||||
role?: string; // Casdoor 主角色:admin/dev/annotator/viewer(由 RoleResolver 解析)
|
||||
groups?: string[]; // Casdoor 用户组(含 deepdata-<角色> 前缀组)
|
||||
}
|
||||
|
||||
function loginUsingPost(data: any) {
|
||||
return post("/api/user/login", data);
|
||||
}
|
||||
|
||||
function signupUsingPost(data: any) {
|
||||
return post("/api/user/signup", data);
|
||||
}
|
||||
|
||||
function getCurrentUser() {
|
||||
return get<UserResponse>("/api/user/me");
|
||||
}
|
||||
|
||||
// DeepData SSO 登录 URL(根据实际环境修改)
|
||||
const ME_LOGIN_URL = import.meta.env.VITE_ME_LOGIN_URL || 'https://deepdata.ailab.yilise.com/login';
|
||||
const OMS_LOGOUT_URL = import.meta.env.VITE_OMS_LOGOUT_URL || 'https://oms-service/logout';
|
||||
|
||||
export function Header() {
|
||||
const { t } = useTranslation();
|
||||
const [loginOpen, setLoginOpen] = useState(false)
|
||||
const [signupOpen, setSignupOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<UserResponse | null>(null);
|
||||
const [authMode, setAuthMode] = useState<'SSO' | 'JWT' | 'NONE'>('NONE');
|
||||
const [userLoading, setUserLoading] = useState(true);
|
||||
|
||||
const handleLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await loginUsingPost(values);
|
||||
// Store the token in localStorage
|
||||
localStorage.setItem('session', JSON.stringify(response.data));
|
||||
message.success(t('user.messages.loginSuccess'));
|
||||
setLoginOpen(false);
|
||||
// Optionally refresh the page or update the UI
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
message.error(t('user.messages.loginFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (values: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string
|
||||
}) => {
|
||||
if (values.password !== values.confirmPassword) {
|
||||
message.error(t('user.messages.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { username, email, password } = values;
|
||||
const response = await signupUsingPost({ username, email, password });
|
||||
|
||||
message.success(t('user.messages.signupSuccess'));
|
||||
localStorage.setItem('session', JSON.stringify(response.data));
|
||||
setSignupOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
message.error(t('user.messages.signupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (authMode === 'SSO') {
|
||||
// SSO 模式:检查是否配置了有效的登出 URL
|
||||
const logoutUrl = OMS_LOGOUT_URL;
|
||||
|
||||
// 如果配置的是默认值(内部 service 名称),只清除本地状态
|
||||
if (logoutUrl.includes('oms-service') || logoutUrl.includes('localhost')) {
|
||||
console.warn('OMS logout URL not configured or using internal address, skipping redirect');
|
||||
setCurrentUser(null);
|
||||
setAuthMode('NONE');
|
||||
message.success(t('user.messages.logoutSuccess'));
|
||||
window.location.reload();
|
||||
} else {
|
||||
// 使用配置的登出 URL
|
||||
window.location.href = `${logoutUrl}?redirect=${encodeURIComponent(window.location.href)}`;
|
||||
}
|
||||
} else {
|
||||
// JWT 模式:清除本地 session
|
||||
localStorage.removeItem('session');
|
||||
setCurrentUser(null);
|
||||
setAuthMode('NONE');
|
||||
message.success(t('user.messages.logoutSuccess'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const openLoginDialog = () => {
|
||||
setLoginOpen(true);
|
||||
};
|
||||
|
||||
const openSignupDialog = () => {
|
||||
setSignupOpen(true);
|
||||
};
|
||||
|
||||
const handleHomeClick = useCallback((e: React.MouseEvent) => {
|
||||
const homeUrl = getCachedHomePageUrl();
|
||||
if (homeUrl) {
|
||||
e.preventDefault();
|
||||
window.location.href = homeUrl;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 已登录时后台刷新缓存,保持与后端同步
|
||||
useEffect(() => {
|
||||
getHomePageUrl().then(url => setCachedHomePageUrl(url)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 检测是否在生产环境(deepdata 域名)
|
||||
const isSSOAvailable = () => {
|
||||
const hostname = window.location.hostname;
|
||||
// 通过域名或注入的全局变量判断
|
||||
return hostname.includes('deepdata') ||
|
||||
hostname.includes('yilise.com') ||
|
||||
(window as any).__DEEPDATA_ENV__ === true;
|
||||
};
|
||||
|
||||
// 获取当前用户信息(支持双模式)
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await getCurrentUser();
|
||||
if (response.data) {
|
||||
setCurrentUser(response.data);
|
||||
setAuthMode(response.data.authMode);
|
||||
|
||||
// 如果未登录,根据 requireLogin 决定是否弹出登录框
|
||||
if (!response.data.authenticated) {
|
||||
if (response.data.requireLogin) {
|
||||
// 强制要求登录:弹出登录框
|
||||
window.dispatchEvent(new CustomEvent('show-login'));
|
||||
}
|
||||
// 不强制登录:允许匿名访问
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch current user:', error);
|
||||
// 请求失败时,保持未登录状态
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
const languageMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'zh',
|
||||
label: t('header.simplifiedChinese'),
|
||||
onClick: () => {
|
||||
i18n.changeLanguage('zh');
|
||||
localStorage.setItem('language', 'zh');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'en',
|
||||
label: t('header.english'),
|
||||
onClick: () => {
|
||||
i18n.changeLanguage('en');
|
||||
localStorage.setItem('language', 'en');
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
const userDropdownItems: MenuProps['items'] = currentUser?.authenticated
|
||||
? [
|
||||
{
|
||||
key: 'profile',
|
||||
label: (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{currentUser.username}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{authMode === 'SSO' ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
{t('user.authMode.sso')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('user.authMode.jwt')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
currentUser.groupId && {
|
||||
key: 'groupId',
|
||||
label: `${t('user.group')}: ${currentUser.groupId}`,
|
||||
disabled: true,
|
||||
},
|
||||
currentUser.email && {
|
||||
key: 'email',
|
||||
label: currentUser.email,
|
||||
disabled: true,
|
||||
},
|
||||
// 只有 JWT 模式才显示退出登录按钮
|
||||
...(authMode !== 'SSO' ? [
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: t('user.actions.logout'),
|
||||
icon: <LogIn className="h-4 w-4" />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
] : []),
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'login',
|
||||
label: authMode === 'SSO' || isSSOAvailable()
|
||||
? t('user.actions.gotoLogin')
|
||||
: t('user.actions.login'),
|
||||
icon: <LogIn className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||
// SSO 模式:跳转到 ME 登录
|
||||
window.location.href = `${ME_LOGIN_URL}?redirect=${encodeURIComponent(window.location.href)}`;
|
||||
} else {
|
||||
// JWT 模式:显示登录对话框
|
||||
setLoginOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'register',
|
||||
label: t('user.actions.register'),
|
||||
icon: <UserPlus className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||
message.info(t('user.messages.useSSO'));
|
||||
} else {
|
||||
setSignupOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<NavLink to="/" onClick={handleHomeClick} className="flex items-center gap-2 cursor-pointer">
|
||||
{/* xuhuanqing */}
|
||||
{/*
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
*/}
|
||||
<span className="text-lg font-bold text-gray-900">DeepData</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dropdown
|
||||
menu={{ items: languageMenuItems }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{i18n.language === 'zh' ? t('header.simplifiedChinese') : t('header.english')}</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: userDropdownItems }}
|
||||
placement="bottomRight"
|
||||
overlayClassName="w-40"
|
||||
>
|
||||
<Button type="text" icon={<User className="h-4 w-4" />} loading={userLoading}>
|
||||
{currentUser?.authenticated && !userLoading && (
|
||||
<span className="ml-1">{currentUser.username}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
onOpenChange={setLoginOpen}
|
||||
onLogin={handleLogin}
|
||||
loading={loading}
|
||||
onSignupClick={openSignupDialog}
|
||||
/>
|
||||
<SignupDialog
|
||||
open={signupOpen}
|
||||
onOpenChange={setSignupOpen}
|
||||
onSignup={handleSignup}
|
||||
loading={loading}
|
||||
onLoginClick={openLoginDialog}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Header);
|
||||
import { User, Globe, LogIn, UserPlus, Sparkles, Shield, Lock } from "lucide-react"
|
||||
import { memo, useState, useEffect, useCallback } from "react";
|
||||
import { NavLink } from "react-router";
|
||||
import { Button, Dropdown, message } from "antd"
|
||||
import type { MenuProps } from 'antd'
|
||||
import { LoginDialog } from "./LoginDialog"
|
||||
import { SignupDialog } from "./SignupDialog"
|
||||
import { ChangePasswordDialog } from "./ChangePasswordDialog" // 引入新组件
|
||||
import { post, get } from "@/utils/request.ts";
|
||||
import { getCachedHomePageUrl, setCachedHomePageUrl } from "@/utils/systemParam";
|
||||
import { getHomePageUrl } from "@/utils/systemParam";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
interface UserResponse {
|
||||
username: string;
|
||||
email?: string;
|
||||
groupId?: string;
|
||||
authenticated: boolean;
|
||||
authMode: 'SSO' | 'JWT' | 'NONE';
|
||||
requireLogin?: boolean; // 是否强制要求登录(由 DEEPDATA_jwt_enable 控制)
|
||||
}
|
||||
|
||||
function loginUsingPost(data: any) {
|
||||
return post("/api/user/login", data);
|
||||
}
|
||||
|
||||
function signupUsingPost(data: any) {
|
||||
return post("/api/user/signup", data);
|
||||
}
|
||||
|
||||
function getCurrentUser() {
|
||||
return get<UserResponse>("/api/user/me");
|
||||
}
|
||||
|
||||
// ME 登录 URL(根据实际环境修改)
|
||||
const ME_LOGIN_URL = import.meta.env.VITE_ME_LOGIN_URL || 'https://modelengine.com/login';
|
||||
const OMS_LOGOUT_URL = import.meta.env.VITE_OMS_LOGOUT_URL || 'https://oms-service/logout';
|
||||
|
||||
export function Header() {
|
||||
const { t } = useTranslation();
|
||||
const [loginOpen, setLoginOpen] = useState(false)
|
||||
const [signupOpen, setSignupOpen] = useState(false)
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false) // 新增:修改密码弹窗状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<UserResponse | null>(null);
|
||||
const [authMode, setAuthMode] = useState<'SSO' | 'JWT' | 'NONE'>('NONE');
|
||||
const [userLoading, setUserLoading] = useState(true);
|
||||
|
||||
const handleLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await loginUsingPost(values);
|
||||
// Store the token in localStorage
|
||||
localStorage.setItem('session', JSON.stringify(response.data));
|
||||
message.success(t('user.messages.loginSuccess'));
|
||||
setLoginOpen(false);
|
||||
// Optionally refresh the page or update the UI
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
message.error(t('user.messages.loginFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (values: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string
|
||||
}) => {
|
||||
if (values.password !== values.confirmPassword) {
|
||||
message.error(t('user.messages.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { username, email, password } = values;
|
||||
const response = await signupUsingPost({ username, email, password });
|
||||
|
||||
message.success(t('user.messages.signupSuccess'));
|
||||
localStorage.setItem('session', JSON.stringify(response.data));
|
||||
setSignupOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
message.error(t('user.messages.signupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (authMode === 'SSO') {
|
||||
// SSO 模式:检查是否配置了有效的登出 URL
|
||||
const logoutUrl = OMS_LOGOUT_URL;
|
||||
|
||||
// 如果配置的是默认值(内部 service 名称),只清除本地状态
|
||||
if (logoutUrl.includes('oms-service') || logoutUrl.includes('localhost')) {
|
||||
console.warn('OMS logout URL not configured or using internal address, skipping redirect');
|
||||
setCurrentUser(null);
|
||||
setAuthMode('NONE');
|
||||
message.success(t('user.messages.logoutSuccess'));
|
||||
window.location.reload();
|
||||
} else {
|
||||
// 使用配置的登出 URL
|
||||
window.location.href = `${logoutUrl}?redirect=${encodeURIComponent(window.location.href)}`;
|
||||
}
|
||||
} else {
|
||||
// JWT 模式:清除本地 session
|
||||
localStorage.removeItem('session');
|
||||
setCurrentUser(null);
|
||||
setAuthMode('NONE');
|
||||
message.success(t('user.messages.logoutSuccess'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const openLoginDialog = () => {
|
||||
setLoginOpen(true);
|
||||
};
|
||||
|
||||
const openSignupDialog = () => {
|
||||
setSignupOpen(true);
|
||||
};
|
||||
|
||||
const handleHomeClick = useCallback((e: React.MouseEvent) => {
|
||||
const homeUrl = getCachedHomePageUrl();
|
||||
if (homeUrl) {
|
||||
e.preventDefault();
|
||||
window.location.href = homeUrl;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 已登录时后台刷新缓存,保持与后端同步
|
||||
useEffect(() => {
|
||||
getHomePageUrl().then(url => setCachedHomePageUrl(url)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 检测是否在 ME 环境
|
||||
const isSSOAvailable = () => {
|
||||
const hostname = window.location.hostname;
|
||||
// 通过域名或注入的全局变量判断
|
||||
return hostname.includes('modelengine') ||
|
||||
hostname.includes('me-platform') ||
|
||||
(window as any).__ME_ENV__ === true;
|
||||
};
|
||||
|
||||
// 获取当前用户信息(支持双模式)
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await getCurrentUser();
|
||||
if (response.data) {
|
||||
setCurrentUser(response.data);
|
||||
setAuthMode(response.data.authMode);
|
||||
|
||||
// 如果未登录,根据 requireLogin 决定是否弹出登录框
|
||||
if (!response.data.authenticated) {
|
||||
if (response.data.requireLogin) {
|
||||
// 强制要求登录:弹出登录框
|
||||
window.dispatchEvent(new CustomEvent('show-login'));
|
||||
}
|
||||
// 不强制登录:允许匿名访问
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch current user:', error);
|
||||
// 请求失败时,保持未登录状态
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
const languageMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'zh',
|
||||
label: t('header.simplifiedChinese'),
|
||||
onClick: () => {
|
||||
i18n.changeLanguage('zh');
|
||||
localStorage.setItem('language', 'zh');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'en',
|
||||
label: t('header.english'),
|
||||
onClick: () => {
|
||||
i18n.changeLanguage('en');
|
||||
localStorage.setItem('language', 'en');
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
const userDropdownItems: MenuProps['items'] = currentUser?.authenticated
|
||||
? [
|
||||
{
|
||||
key: 'profile',
|
||||
label: (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{currentUser.username}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{authMode === 'SSO' ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
{t('user.authMode.sso')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('user.authMode.jwt')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
currentUser.groupId && {
|
||||
key: 'groupId',
|
||||
label: `${t('user.group')}: ${currentUser.groupId}`,
|
||||
disabled: true,
|
||||
},
|
||||
currentUser.email && {
|
||||
key: 'email',
|
||||
label: currentUser.email,
|
||||
disabled: true,
|
||||
},
|
||||
// 只有 JWT 模式才显示修改密码和退出登录按钮
|
||||
...(authMode !== 'SSO' ? [
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
// 🔴 新增:修改密码菜单项
|
||||
key: 'changePassword',
|
||||
label: t('user.actions.changePassword'),
|
||||
icon: <Lock className="h-4 w-4" />,
|
||||
onClick: () => setChangePasswordOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: t('user.actions.logout'),
|
||||
icon: <LogIn className="h-4 w-4" />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
] : []),
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'login',
|
||||
label: authMode === 'SSO' || isSSOAvailable()
|
||||
? t('user.actions.gotoLogin')
|
||||
: t('user.actions.login'),
|
||||
icon: <LogIn className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||
// SSO 模式:跳转到 ME 登录
|
||||
window.location.href = `${ME_LOGIN_URL}?redirect=${encodeURIComponent(window.location.href)}`;
|
||||
} else {
|
||||
// JWT 模式:显示登录对话框
|
||||
setLoginOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'register',
|
||||
label: t('user.actions.register'),
|
||||
icon: <UserPlus className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||
message.info(t('user.messages.useSSO'));
|
||||
} else {
|
||||
setSignupOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<NavLink to="/" onClick={handleHomeClick} className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-lg font-bold text-gray-900">DeepData</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dropdown
|
||||
menu={{ items: languageMenuItems }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{i18n.language === 'zh' ? t('header.simplifiedChinese') : t('header.english')}</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: userDropdownItems }}
|
||||
placement="bottomRight"
|
||||
overlayClassName="w-40"
|
||||
>
|
||||
<Button type="text" icon={<User className="h-4 w-4" />} loading={userLoading}>
|
||||
{currentUser?.authenticated && !userLoading && (
|
||||
<span className="ml-1">{currentUser.username}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
onOpenChange={setLoginOpen}
|
||||
onLogin={handleLogin}
|
||||
loading={loading}
|
||||
onSignupClick={openSignupDialog}
|
||||
/>
|
||||
<SignupDialog
|
||||
open={signupOpen}
|
||||
onOpenChange={setSignupOpen}
|
||||
onSignup={handleSignup}
|
||||
loading={loading}
|
||||
onLoginClick={openLoginDialog}
|
||||
/>
|
||||
|
||||
{/* 🔴 新增:修改密码弹窗 */}
|
||||
<ChangePasswordDialog
|
||||
open={changePasswordOpen}
|
||||
onOpenChange={setChangePasswordOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Header);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Button, Divider, Form, Input, Modal } from "antd"
|
||||
import { LogIn, ShieldCheck } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button, Form, Input, Modal } from "antd"
|
||||
import { LogIn } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { get } from "@/utils/request"
|
||||
|
||||
interface LoginDialogProps {
|
||||
open: boolean
|
||||
@@ -12,57 +10,9 @@ interface LoginDialogProps {
|
||||
onSignupClick?: () => void
|
||||
}
|
||||
|
||||
interface SsoConfig {
|
||||
enabled?: boolean;
|
||||
loginUrl?: string;
|
||||
logoutUrl?: string;
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
// 模块级缓存:LoginDialog 会被 Header 与 AuthGuard 各渲染一份,避免重复请求
|
||||
let ssoConfigCache: SsoConfig | null = null;
|
||||
|
||||
function fetchSsoConfig(): Promise<SsoConfig> {
|
||||
if (ssoConfigCache) return Promise.resolve(ssoConfigCache);
|
||||
return get("/sso/casdoor/config")
|
||||
.then((res: unknown) => {
|
||||
ssoConfigCache = (res as { data?: SsoConfig })?.data ?? { enabled: false };
|
||||
return ssoConfigCache;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// 拉取失败则静默隐藏 SSO 入口,不影响本地账号登录
|
||||
console.warn("[LoginDialog] 获取 Casdoor 配置失败,隐藏 SSO 入口:", err);
|
||||
ssoConfigCache = { enabled: false };
|
||||
return ssoConfigCache;
|
||||
});
|
||||
}
|
||||
|
||||
export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClick }: LoginDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm()
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false)
|
||||
const [ssoLoginUrl, setSsoLoginUrl] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchSsoConfig().then((cfg) => {
|
||||
if (!alive) return;
|
||||
setSsoEnabled(!!cfg.enabled && !!cfg.loginUrl);
|
||||
if (cfg.loginUrl) setSsoLoginUrl(cfg.loginUrl);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCasdoorLogin = () => {
|
||||
// 跳转后端 SSO 入口:后端负责 state 生成/校验与令牌交换(clientSecret 不落前端),
|
||||
// 最终 302 回 /sso/callback.html?sso_token=...
|
||||
const next = encodeURIComponent("/sso/callback.html");
|
||||
const sep = ssoLoginUrl.includes("?") ? "&" : "?";
|
||||
window.location.href = `${ssoLoginUrl}${sep}next=${next}`;
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||
await onLogin(values);
|
||||
@@ -85,23 +35,6 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
>
|
||||
<div className="text-gray-500 mb-6">{t('user.loginDialog.description')}</div>
|
||||
|
||||
{ssoEnabled && (
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
block
|
||||
icon={<ShieldCheck className="w-4 h-4" />}
|
||||
onClick={handleCasdoorLogin}
|
||||
>
|
||||
{t('user.loginDialog.casdoorLogin')}
|
||||
</Button>
|
||||
<Divider plain style={{ margin: '12px 0' }}>
|
||||
{t('user.loginDialog.or')}
|
||||
</Divider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
@@ -133,6 +66,8 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
>
|
||||
{t('user.loginDialog.submitButton')}
|
||||
</Button>
|
||||
{/* xuhuanqing */}
|
||||
{/*
|
||||
<div className="mt-4 text-center text-sm">
|
||||
{t('user.loginDialog.noAccount')}
|
||||
<a
|
||||
@@ -146,6 +81,7 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
{t('user.loginDialog.registerNow')}
|
||||
</a>
|
||||
</div>
|
||||
*/}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -14,31 +14,7 @@ import {
|
||||
Merge,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type RoleKey = "admin" | "dev" | "annotator" | "viewer";
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
i18Key: string;
|
||||
icon: LucideIcon;
|
||||
description?: string;
|
||||
descriptionKey?: string;
|
||||
color?: string;
|
||||
badge?: number;
|
||||
/** 角色可见性矩阵(与 docs/升级方案-目录菜单体系.md §4.2 保持一致)。未配置 = 所有登录用户可见 */
|
||||
roles?: RoleKey[];
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单矩阵(默认值,见文档 §4.2):
|
||||
* - collection/management/cleansing/synthesis/evaluation/knowledge-base/operator-market = admin, dev
|
||||
* - annotation = admin, dev, annotator
|
||||
* - chat(对话)不在侧边栏菜单中,所有角色可用(Home 按钮)
|
||||
*/
|
||||
export const menuItems: MenuItem[] = [
|
||||
export const menuItems = [
|
||||
{
|
||||
id: "collection",
|
||||
title: "数据归集",
|
||||
@@ -47,7 +23,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "创建、导入和管理数据集",
|
||||
descriptionKey: "dataCollection.description",
|
||||
color: "bg-orange-500",
|
||||
roles: ["admin", "dev"],
|
||||
},
|
||||
{
|
||||
id: "management",
|
||||
@@ -57,7 +32,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "创建、导入和管理数据集",
|
||||
descriptionKey: "dataManagement.description",
|
||||
color: "bg-blue-500",
|
||||
roles: ["admin", "dev"],
|
||||
},
|
||||
{
|
||||
id: "cleansing",
|
||||
@@ -67,7 +41,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "数据清洗、处理和转换",
|
||||
descriptionKey: "dataCleansing.description",
|
||||
color: "bg-purple-500",
|
||||
roles: ["admin", "dev"],
|
||||
},
|
||||
{
|
||||
id: "annotation",
|
||||
@@ -77,7 +50,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "对数据进行标注和标记",
|
||||
descriptionKey: "dataAnnotation.home.description",
|
||||
color: "bg-green-500",
|
||||
roles: ["admin", "dev", "annotator"],
|
||||
},
|
||||
{
|
||||
id: "synthesis",
|
||||
@@ -87,7 +59,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "智能数据合成和配比",
|
||||
descriptionKey: "synthesisTask.description",
|
||||
color: "bg-pink-500",
|
||||
roles: ["admin", "dev"],
|
||||
children: [
|
||||
{
|
||||
id: "synthesis/task",
|
||||
@@ -112,7 +83,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "质量分析、性能评估和偏见检测",
|
||||
descriptionKey: "dataEvaluation.home.description",
|
||||
color: "bg-indigo-500",
|
||||
roles: ["admin", "dev"],
|
||||
},
|
||||
{
|
||||
id: "knowledge-base",
|
||||
@@ -122,7 +92,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "面向RAG的知识库构建",
|
||||
descriptionKey: "knowledgeBase.description",
|
||||
color: "bg-teal-500",
|
||||
roles: ["admin", "dev"],
|
||||
},
|
||||
{
|
||||
id: "operator-market",
|
||||
@@ -132,7 +101,6 @@ export const menuItems: MenuItem[] = [
|
||||
description: "算子上传与管理",
|
||||
descriptionKey: "operatorMarket.description",
|
||||
color: "bg-yellow-500",
|
||||
roles: ["admin", "dev"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { Button, Drawer, Menu, Popover } from "antd";
|
||||
import {
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ClipboardList, X, ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { menuItems } from "@/pages/Layout/Menu.tsx";
|
||||
import { useCurrentRoles } from "@/hooks/useCurrentRoles";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import TaskUpload from "./TaskUpload";
|
||||
import SettingsPage from "../SettingsPage/SettingsPage";
|
||||
@@ -22,21 +21,11 @@ const AsiderAndHeaderLayout = () => {
|
||||
const [taskCenterVisible, setTaskCenterVisible] = useState(false);
|
||||
const settingVisible = useAppSelector((state) => state.settings.visible);
|
||||
const dispatch = useAppDispatch();
|
||||
const { roles } = useCurrentRoles();
|
||||
|
||||
// 角色过滤:item 未配置 roles = 所有登录用户可见;配置后 = 仅拥有其中任一角色的用户可见
|
||||
const visibleMenuItems = useMemo(
|
||||
() =>
|
||||
menuItems.filter(
|
||||
(item) => !item.roles || item.roles.some((r) => roles.includes(r))
|
||||
),
|
||||
[roles]
|
||||
);
|
||||
|
||||
// Initialize active item based on current pathname
|
||||
const initActiveItem = () => {
|
||||
for (let index = 0; index < visibleMenuItems.length; index++) {
|
||||
const element = visibleMenuItems[index];
|
||||
for (let index = 0; index < menuItems.length; index++) {
|
||||
const element = menuItems[index];
|
||||
if (element.children) {
|
||||
element.children.forEach((subItem) => {
|
||||
if (pathname.includes(subItem.id)) {
|
||||
@@ -87,7 +76,7 @@ const AsiderAndHeaderLayout = () => {
|
||||
<Menu
|
||||
mode="inline"
|
||||
inlineCollapsed={!sidebarOpen}
|
||||
items={visibleMenuItems.map((item) => ({
|
||||
items={menuItems.map((item) => ({
|
||||
key: item.id,
|
||||
label: t(item.i18Key),
|
||||
icon: item.icon ? <item.icon className="w-4 h-4" /> : null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, message } from "antd";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
|
||||
@@ -1,213 +1,215 @@
|
||||
import { Button, Checkbox, Tooltip } from "antd";
|
||||
import { FilterOutlined } from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CategoryI, CategoryTreeI } from "../../operator.model";
|
||||
|
||||
interface FilterOption {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
icon?: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
total: number;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
showIcons?: boolean;
|
||||
badgeColor?: string;
|
||||
}
|
||||
|
||||
const FilterSection: React.FC<FilterSectionProps> = ({
|
||||
total,
|
||||
title,
|
||||
options,
|
||||
selectedValues,
|
||||
onSelectionChange,
|
||||
showIcons = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const handleCheckboxChange = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onSelectionChange([...selectedValues, value]);
|
||||
} else {
|
||||
onSelectionChange(selectedValues.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
|
||||
// 全选功能
|
||||
const isAllSelected =
|
||||
options.length > 0 && selectedValues.length === options.length;
|
||||
const isIndeterminate =
|
||||
selectedValues.length > 0 && selectedValues.length < options.length;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// 全选
|
||||
onSelectionChange(options.map((option) => option.key));
|
||||
} else {
|
||||
// 全不选
|
||||
onSelectionChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-900">{title}</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
{/* 全选选项 */}
|
||||
{options.length > 1 && (
|
||||
<label className="flex items-center space-x-2 cursor-pointer border-b border-gray-100 pb-1 ">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-1 ml-1">
|
||||
<span className="text-gray-600 font-medium">{t("operatorMarket.home.filters.selectAll")}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">({total})</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 各个选项 */}
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(option.key)}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange(option.key, e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-1 ml-1">
|
||||
{showIcons && option.icon}
|
||||
<span className={`text-gray-700 ${option.color || ""}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FiltersProps {
|
||||
categoriesTree: CategoryTreeI[];
|
||||
selectedFilters: { [key: string]: string[] };
|
||||
selectedStar: boolean;
|
||||
starCount: number;
|
||||
hideFilter: () => void;
|
||||
setSelectedFilters: (filters: { [key: string]: string[] }) => void;
|
||||
setSelectedStar: (item: boolean) => void;
|
||||
}
|
||||
|
||||
const Filters: React.FC<FiltersProps> = ({
|
||||
categoriesTree,
|
||||
selectedFilters,
|
||||
selectedStar,
|
||||
starCount,
|
||||
hideFilter,
|
||||
setSelectedFilters,
|
||||
setSelectedStar,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const clearAllFilters = () => {
|
||||
const newFilters = Object.keys(selectedFilters).reduce((acc, key) => {
|
||||
acc[key] = [];
|
||||
return acc;
|
||||
}, {} as { [key: string]: string[] });
|
||||
setSelectedFilters(newFilters);
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(selectedFilters).some(
|
||||
(filters) => Array.isArray(filters) && filters.length > 0
|
||||
);
|
||||
|
||||
const starCategory = {
|
||||
id: "starStatus",
|
||||
count: starCount,
|
||||
name: t("operatorMarket.home.filters.starStatus"),
|
||||
categories: [{
|
||||
id: "isStar",
|
||||
count: starCount,
|
||||
name: t("operatorMarket.home.filters.starred")
|
||||
}]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4 h-full overflow-y-auto">
|
||||
{/* Filter Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
<Tooltip title={t("operatorMarket.home.filters.hide")}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FilterOutlined />}
|
||||
onClick={hideFilter}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
<span>{t("operatorMarket.home.filters.title")}</span>
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<span
|
||||
onClick={clearAllFilters}
|
||||
className="cursor-pointer text-sm text-gray-500 hover:text-blue-500"
|
||||
>
|
||||
{t("operatorMarket.home.filters.clear")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Sections */}
|
||||
{categoriesTree.map((category: CategoryTreeI) => (
|
||||
<FilterSection
|
||||
key={category.id}
|
||||
total={category.count}
|
||||
title={category.name}
|
||||
options={category.categories.map((cat: CategoryI) => ({
|
||||
key: cat.id.toString(),
|
||||
label: cat.name,
|
||||
count: cat.count,
|
||||
}))}
|
||||
selectedValues={selectedFilters[category.id] || []}
|
||||
onSelectionChange={(values) =>
|
||||
setSelectedFilters({ ...selectedFilters, [category.id]: values })
|
||||
}
|
||||
showIcons={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FilterSection
|
||||
key={starCategory.id}
|
||||
total={starCategory.count}
|
||||
title={starCategory.name}
|
||||
options={starCategory.categories.map(cat => ({
|
||||
key: cat.id.toString(),
|
||||
label: cat.name,
|
||||
count: cat.count,
|
||||
}))}
|
||||
selectedValues={selectedStar ? ["isStar"] : []}
|
||||
onSelectionChange={(values) => {
|
||||
values.length > 0 ? setSelectedStar(true) : setSelectedStar(false);
|
||||
}}
|
||||
showIcons={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
import { Button, Checkbox, Tooltip } from "antd";
|
||||
import { FilterOutlined } from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CategoryI, CategoryTreeI } from "../../operator.model";
|
||||
|
||||
interface FilterOption {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
icon?: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
total: number;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
showIcons?: boolean;
|
||||
badgeColor?: string;
|
||||
}
|
||||
|
||||
const FilterSection: React.FC<FilterSectionProps> = ({
|
||||
total,
|
||||
title,
|
||||
options,
|
||||
selectedValues,
|
||||
onSelectionChange,
|
||||
showIcons = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const handleCheckboxChange = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onSelectionChange([...selectedValues, value]);
|
||||
} else {
|
||||
onSelectionChange(selectedValues.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
|
||||
// 全选功能
|
||||
const isAllSelected =
|
||||
options.length > 0 && selectedValues.length === options.length;
|
||||
const isIndeterminate =
|
||||
selectedValues.length > 0 && selectedValues.length < options.length;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// 全选
|
||||
onSelectionChange(options.map((option) => option.key));
|
||||
} else {
|
||||
// 全不选
|
||||
onSelectionChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-900">{title}</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
{/* 全选选项 */}
|
||||
{options.length > 1 && (
|
||||
<label className="flex items-center space-x-2 cursor-pointer border-b border-gray-100 pb-1 ">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-1 ml-1">
|
||||
<span className="text-gray-600 font-medium">{t("operatorMarket.home.filters.selectAll")}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">({total})</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 各个选项 */}
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(option.key)}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange(option.key, e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-1 ml-1">
|
||||
{showIcons && option.icon}
|
||||
<span className={`text-gray-700 ${option.color || ""}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FiltersProps {
|
||||
categoriesTree: CategoryTreeI[];
|
||||
selectedFilters: { [key: string]: string[] };
|
||||
selectedStar: boolean;
|
||||
starCount: number;
|
||||
hideFilter: () => void;
|
||||
setSelectedFilters: (filters: { [key: string]: string[] }) => void;
|
||||
setSelectedStar: (item: boolean) => void;
|
||||
}
|
||||
|
||||
const Filters: React.FC<FiltersProps> = ({
|
||||
categoriesTree,
|
||||
selectedFilters,
|
||||
selectedStar,
|
||||
starCount,
|
||||
hideFilter,
|
||||
setSelectedFilters,
|
||||
setSelectedStar,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const clearAllFilters = () => {
|
||||
const newFilters = Object.keys(selectedFilters).reduce((acc, key) => {
|
||||
acc[key] = [];
|
||||
return acc;
|
||||
}, {} as { [key: string]: string[] });
|
||||
setSelectedFilters(newFilters);
|
||||
// 同时清除收藏筛选状态
|
||||
setSelectedStar(false);
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(selectedFilters).some(
|
||||
(filters) => Array.isArray(filters) && filters.length > 0
|
||||
);
|
||||
|
||||
const starCategory = {
|
||||
id: "starStatus",
|
||||
count: starCount,
|
||||
name: t("operatorMarket.home.filters.starStatus"),
|
||||
categories: [{
|
||||
id: "isStar",
|
||||
count: starCount,
|
||||
name: t("operatorMarket.home.filters.starred")
|
||||
}]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4 h-full overflow-y-auto">
|
||||
{/* Filter Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
<Tooltip title={t("operatorMarket.home.filters.hide")}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FilterOutlined />}
|
||||
onClick={hideFilter}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
<span>{t("operatorMarket.home.filters.title")}</span>
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<span
|
||||
onClick={clearAllFilters}
|
||||
className="cursor-pointer text-sm text-gray-500 hover:text-blue-500"
|
||||
>
|
||||
{t("operatorMarket.home.filters.clear")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Sections */}
|
||||
{categoriesTree.map((category: CategoryTreeI) => (
|
||||
<FilterSection
|
||||
key={category.id}
|
||||
total={category.count}
|
||||
title={category.name}
|
||||
options={category.categories.map((cat: CategoryI) => ({
|
||||
key: cat.id.toString(),
|
||||
label: cat.name,
|
||||
count: cat.count,
|
||||
}))}
|
||||
selectedValues={selectedFilters[category.id] || []}
|
||||
onSelectionChange={(values) =>
|
||||
setSelectedFilters({ ...selectedFilters, [category.id]: values })
|
||||
}
|
||||
showIcons={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FilterSection
|
||||
key={starCategory.id}
|
||||
total={starCategory.count}
|
||||
title={starCategory.name}
|
||||
options={starCategory.categories.map(cat => ({
|
||||
key: cat.id.toString(),
|
||||
label: cat.name,
|
||||
count: cat.count,
|
||||
}))}
|
||||
selectedValues={selectedStar ? ["isStar"] : []}
|
||||
onSelectionChange={(values) => {
|
||||
values.length > 0 ? setSelectedStar(true) : setSelectedStar(false);
|
||||
}}
|
||||
showIcons={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
|
||||
+99
-204
@@ -1,11 +1,53 @@
|
||||
import {message} from "antd";
|
||||
import { message } from "antd";
|
||||
import Loading from "./loading";
|
||||
import {errorConfigStore} from "@/utils/errorConfigStore.ts";
|
||||
import { errorConfigStore } from "@/utils/errorConfigStore.ts";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
// ==========================================
|
||||
// 🆕 1. 核心认证辅助函数 (独立于类之外,避免 this 问题)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 通用请求工具类
|
||||
* 内部函数:安全从 localStorage 获取 Token
|
||||
*/
|
||||
function _getTokenFromStorage(): string | null {
|
||||
const session = localStorage.getItem("session");
|
||||
if (session) {
|
||||
try {
|
||||
const sessionData = JSON.parse(session);
|
||||
// 兼容 { token: '...' } 和 { data: { token: '...' } } 结构
|
||||
return sessionData.token || sessionData?.data?.token || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出函数:获取当前认证 Token
|
||||
* 供组件直接调用,如: import { getAuthToken } from "@/utils/request";
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
return _getTokenFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出函数:为 URL 自动拼接 Token
|
||||
* 专用于 <a href>、window.open 等无法携带 Header 的场景
|
||||
*/
|
||||
export function appendTokenToUrl(url: string): string {
|
||||
if (!url) return url;
|
||||
const token = _getTokenFromStorage();
|
||||
if (!token) return url;
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
return `${url}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. Request 核心类
|
||||
// ==========================================
|
||||
|
||||
class Request {
|
||||
constructor(baseURL = "") {
|
||||
this.baseURL = baseURL;
|
||||
@@ -61,7 +103,6 @@ class Request {
|
||||
for (const interceptor of this.requestInterceptors) {
|
||||
processedConfig = (await interceptor(processedConfig)) || processedConfig;
|
||||
}
|
||||
|
||||
return processedConfig;
|
||||
}
|
||||
|
||||
@@ -70,12 +111,10 @@ class Request {
|
||||
*/
|
||||
async executeResponseInterceptors(response, config) {
|
||||
let processedResponse = response;
|
||||
|
||||
for (const interceptor of this.responseInterceptors) {
|
||||
processedResponse =
|
||||
(await interceptor(processedResponse, config)) || processedResponse;
|
||||
}
|
||||
|
||||
return processedResponse;
|
||||
}
|
||||
|
||||
@@ -87,31 +126,23 @@ class Request {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url);
|
||||
|
||||
// 设置请求头
|
||||
if (config.headers) {
|
||||
Object.keys(config.headers).forEach((key) => {
|
||||
xhr.setRequestHeader(key, config.headers[key]);
|
||||
});
|
||||
}
|
||||
|
||||
// 监听上传进度
|
||||
xhr.upload.addEventListener("progress", function (event) {
|
||||
if (event.lengthComputable) {
|
||||
if (onProgress) {
|
||||
onProgress(event);
|
||||
}
|
||||
if (onDownloadProgress) {
|
||||
onDownloadProgress(event);
|
||||
}
|
||||
if (onProgress) onProgress(event);
|
||||
if (onDownloadProgress) onDownloadProgress(event);
|
||||
}
|
||||
});
|
||||
|
||||
// 请求完成处理
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
let response;
|
||||
try {
|
||||
// 尝试解析JSON
|
||||
const contentType = xhr.getResponseHeader("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
@@ -130,52 +161,40 @@ class Request {
|
||||
xhr: xhr,
|
||||
});
|
||||
} else {
|
||||
// xuhuanqing
|
||||
// reject(new Error(`HTTP error! status: ${xhr.status}`));
|
||||
// fix
|
||||
// ✅ 错误分支也要解析后端返回的JSON,挂载到自定义error
|
||||
let respData;
|
||||
let respData;
|
||||
try {
|
||||
const contentType = xhr.getResponseHeader("content-type");
|
||||
if (contentType && contentType.includes("application/json"))
|
||||
{
|
||||
respData = JSON.parse(xhr.responseText);
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
respData = JSON.parse(xhr.responseText);
|
||||
} else {
|
||||
respData = xhr.responseText;
|
||||
respData = xhr.responseText;
|
||||
}
|
||||
} catch (_e) {
|
||||
respData = xhr.responseText;
|
||||
}
|
||||
|
||||
// 创建错误对象,附加response属性,模拟axios的结构,方便上层catch统一处理
|
||||
const err = new Error(`HTTP error! status: ${xhr.status}`);
|
||||
err.response = {
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
data: respData, // 后端返回完整业务json {code,message,data}
|
||||
data: respData,
|
||||
headers: xhr.getAllResponseHeaders(),
|
||||
};
|
||||
err.xhr = xhr;
|
||||
reject(err);
|
||||
// fix end
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
// 请求错误
|
||||
xhr.addEventListener("error", function () {
|
||||
console.error("网络错误");
|
||||
if (onError) onError(new Error("网络错误"));
|
||||
});
|
||||
|
||||
// 请求中止
|
||||
xhr.addEventListener("abort", function () {
|
||||
console.log("上传已取消");
|
||||
if (onError) onError(new Error("上传已取消"));
|
||||
});
|
||||
|
||||
xhr.send(config.body);
|
||||
|
||||
return xhr; // 返回 xhr 对象以便后续控制
|
||||
return xhr;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,35 +220,25 @@ class Request {
|
||||
* 处理响应
|
||||
*/
|
||||
async handleResponse(response, config) {
|
||||
// 如果显示了loading,需要隐藏
|
||||
if (config.showLoading) {
|
||||
this.count--;
|
||||
}
|
||||
|
||||
// 执行响应拦截器
|
||||
const processedResponse = await this.executeResponseInterceptors(
|
||||
response,
|
||||
config
|
||||
);
|
||||
const processedResponse = await this.executeResponseInterceptors(response, config);
|
||||
|
||||
if (!processedResponse.ok) {
|
||||
const error = new Error(
|
||||
`HTTP error! status: ${processedResponse.status}`
|
||||
);
|
||||
const error = new Error(`HTTP error! status: ${processedResponse.status}`);
|
||||
error.status = processedResponse.status;
|
||||
error.statusText = processedResponse.statusText;
|
||||
|
||||
try {
|
||||
error.data = await processedResponse.json();
|
||||
// message.error(`请求失败,错误信息: ${errorData.message}`);
|
||||
} catch {
|
||||
// 忽略JSON解析错误
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 检查响应是否为空
|
||||
const contentType = processedResponse.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
return await processedResponse.json();
|
||||
@@ -242,7 +251,6 @@ class Request {
|
||||
* 处理XHR响应
|
||||
*/
|
||||
async handleXHRResponse(xhrResponse, config) {
|
||||
// 模拟fetch响应格式用于拦截器(添加 clone/json 方法)
|
||||
const mockResponse = {
|
||||
ok: xhrResponse.status >= 200 && xhrResponse.status < 300,
|
||||
status: xhrResponse.status,
|
||||
@@ -260,7 +268,6 @@ class Request {
|
||||
},
|
||||
};
|
||||
|
||||
// 执行响应拦截器
|
||||
await this.executeResponseInterceptors(mockResponse, config);
|
||||
|
||||
if (!mockResponse.ok) {
|
||||
@@ -279,15 +286,12 @@ class Request {
|
||||
* 通用请求方法
|
||||
*/
|
||||
async request(url, config) {
|
||||
// 处理showLoading参数
|
||||
if (config.showLoading) {
|
||||
this.count++;
|
||||
}
|
||||
|
||||
// 执行请求拦截器
|
||||
const processedConfig = await this.executeRequestInterceptors(config);
|
||||
|
||||
// 如果需要进度监听,使用XMLHttpRequest
|
||||
if (config.onUploadProgress || config.onDownloadProgress) {
|
||||
const xhrResponse = await this.createXHRWithProgress(
|
||||
url,
|
||||
@@ -298,21 +302,15 @@ class Request {
|
||||
return await this.handleXHRResponse(xhrResponse, processedConfig);
|
||||
}
|
||||
// 否则使用fetch
|
||||
if (processedConfig.body instanceof FormData) {
|
||||
}
|
||||
const response = await fetch(url, processedConfig);
|
||||
return await this.handleResponse(response, processedConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} params - 查询参数
|
||||
* @param {object} options - 额外的fetch选项,包括showLoading, onDownloadProgress
|
||||
*/
|
||||
async get(url, params = null, options = {}) {
|
||||
const fullURL = this.buildURL(url, params);
|
||||
|
||||
const config = {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
@@ -323,15 +321,11 @@ class Request {
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
return this.request(fullURL, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} data - 请求体数据
|
||||
* @param {object} options - 额外的fetch选项,包括showLoading, onUploadProgress, onDownloadProgress
|
||||
*/
|
||||
async post(url, data = {}, options = {}) {
|
||||
let config = {
|
||||
@@ -346,15 +340,12 @@ class Request {
|
||||
...options,
|
||||
};
|
||||
|
||||
const isFormData = data instanceof FormData;
|
||||
if (isFormData) {
|
||||
if (data instanceof FormData) {
|
||||
config = {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
headers: {
|
||||
...options.headers, // FormData不需要Content-Type
|
||||
},
|
||||
headers: { ...options.headers },
|
||||
body: data,
|
||||
...options,
|
||||
};
|
||||
@@ -364,9 +355,6 @@ class Request {
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} data - 请求体数据
|
||||
* @param {object} options - 额外的fetch选项,包括showLoading, onUploadProgress, onDownloadProgress
|
||||
*/
|
||||
async put(url, data = null, options = {}) {
|
||||
const config = {
|
||||
@@ -380,15 +368,11 @@ class Request {
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
...options,
|
||||
};
|
||||
|
||||
return this.request(this.baseURL + url, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} params - 查询参数或请求体数据
|
||||
* @param {object} options - 额外的fetch选项,包括showLoading
|
||||
*/
|
||||
async delete(url, params = null, options = {}) {
|
||||
let fullURL = this.baseURL + url;
|
||||
@@ -406,9 +390,7 @@ class Request {
|
||||
...options,
|
||||
};
|
||||
|
||||
// 判断params是否应该作为查询参数或请求体
|
||||
if (params && typeof params === "object" && !Array.isArray(params)) {
|
||||
// 如果params是普通对象,检查是否应该作为查询参数
|
||||
const isQueryParams =
|
||||
Object.keys(params).length === 1 &&
|
||||
(Object.prototype.hasOwnProperty.call(params, "id") ||
|
||||
@@ -418,14 +400,11 @@ class Request {
|
||||
if (isQueryParams) {
|
||||
fullURL = this.buildURL(url, params);
|
||||
} else {
|
||||
// 作为请求体发送
|
||||
config.body = JSON.stringify(params);
|
||||
}
|
||||
} else if (Array.isArray(params)) {
|
||||
// 数组形式的数据作为请求体发送
|
||||
config.body = JSON.stringify(params);
|
||||
} else if (params) {
|
||||
// 其他情况作为查询参数
|
||||
fullURL = this.buildURL(url, { id: params });
|
||||
}
|
||||
|
||||
@@ -434,9 +413,6 @@ class Request {
|
||||
|
||||
/**
|
||||
* PATCH请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} data - 请求体数据
|
||||
* @param {object} options - 额外的fetch选项,包括showLoading, onUploadProgress, onDownloadProgress
|
||||
*/
|
||||
async patch(url, data = null, options = {}) {
|
||||
const config = {
|
||||
@@ -450,7 +426,6 @@ class Request {
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
...options,
|
||||
};
|
||||
|
||||
return this.request(this.baseURL + url, config);
|
||||
}
|
||||
|
||||
@@ -459,88 +434,59 @@ class Request {
|
||||
*/
|
||||
parseContentDisposition(contentDisposition) {
|
||||
if (!contentDisposition) return null;
|
||||
|
||||
// 匹配 filename="..." 或 filename=...
|
||||
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
|
||||
const matches = filenameRegex.exec(contentDisposition);
|
||||
|
||||
if (matches && matches[1]) {
|
||||
let fileName = matches[1].replace(/['"]/g, '');
|
||||
// 处理 URL 编码的文件名(如中文文件名)
|
||||
try {
|
||||
fileName = decodeURIComponent(fileName);
|
||||
} catch {
|
||||
// 如果解码失败,使用原始文件名
|
||||
// 忽略
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前认证 token
|
||||
* 兼容旧版:类方法获取 Token
|
||||
*/
|
||||
getAuthToken() {
|
||||
const session = localStorage.getItem("session");
|
||||
if (session) {
|
||||
try {
|
||||
const sessionData = JSON.parse(session);
|
||||
return sessionData.token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return _getTokenFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} params - 查询参数
|
||||
* @param {string} filename - 下载文件名
|
||||
* @param {string} action - 行为,包括下载文件和预览文件
|
||||
* @param {object} options - 额外的fetch选项,包括showLoading, onDownloadProgress
|
||||
*/
|
||||
async download(url, params = null, filename = "", action = "download", options = {}) {
|
||||
// 对于预览,使用 fetch + blob 方式
|
||||
// 🟢 修复:直接使用模块级函数,不依赖 this,彻底解决 TypeError
|
||||
const token = _getTokenFromStorage();
|
||||
const downloadParams = token ? { ...params, token } : params;
|
||||
|
||||
// 1. 预览模式 (使用 fetch)
|
||||
if (action === "preview") {
|
||||
const fullURL = this.buildURL(url, params);
|
||||
const config = {
|
||||
method: "GET",
|
||||
...options,
|
||||
};
|
||||
|
||||
// 注意:fetch 请求会经过 executeRequestInterceptors,
|
||||
// 拦截器会自动添加 Header。这里同时添加 URL 参数以保万全。
|
||||
const fullURL = this.buildURL(url, downloadParams);
|
||||
const config = { method: "GET", ...options };
|
||||
const processedConfig = await this.executeRequestInterceptors(config);
|
||||
|
||||
const response = await fetch(fullURL, processedConfig);
|
||||
|
||||
// 文件预览不需要执行响应拦截器(因为响应是二进制数据,不是JSON)
|
||||
// 直接检查响应状态
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const name = filename || this.parseContentDisposition(response.headers.get("Content-Disposition")) || `download_${Date.now()}`;
|
||||
const name = filename || this.parseContentDisposition(response.headers.get("Content-Disposition")) || `preview_${Date.now()}`;
|
||||
|
||||
return {
|
||||
blob,
|
||||
blobUrl,
|
||||
filename: name,
|
||||
size: blob.size,
|
||||
revoke: () => window.URL.revokeObjectURL(blobUrl)
|
||||
};
|
||||
return { blob, blobUrl, filename: name, size: blob.size, revoke: () => window.URL.revokeObjectURL(blobUrl) };
|
||||
}
|
||||
|
||||
// 对于下载,使用原生 <a> 标签 + token 参数
|
||||
// 2. 下载模式 (使用原生 <a> 标签)
|
||||
if (action === "download") {
|
||||
// 获取 token 并添加到 URL 参数中
|
||||
const token = this.getAuthToken();
|
||||
const downloadParams = token ? { ...params, token } : params;
|
||||
const fullURL = this.buildURL(url, downloadParams);
|
||||
|
||||
// 创建隐藏的 <a> 标签触发下载
|
||||
const link = document.createElement("a");
|
||||
link.href = fullURL;
|
||||
link.download = filename || `download_${Date.now()}`;
|
||||
@@ -548,7 +494,6 @@ class Request {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -556,15 +501,10 @@ class Request {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(专门的上传方法)
|
||||
* @param {string} url - 上传URL
|
||||
* @param {FormData|File} data - 文件数据
|
||||
* @param {object} options - 选项,包括onUploadProgress回调
|
||||
* 上传文件
|
||||
*/
|
||||
async upload(url, data, options = {}) {
|
||||
let formData = data;
|
||||
|
||||
// 如果传入的是File对象,包装成FormData
|
||||
if (data instanceof File) {
|
||||
formData = new FormData();
|
||||
formData.append("file", data);
|
||||
@@ -572,30 +512,26 @@ class Request {
|
||||
|
||||
return this.post(url, formData, {
|
||||
...options,
|
||||
showLoading: options.showLoading !== false, // 上传默认显示loading
|
||||
onUploadProgress: options.onUploadProgress, // 上传进度回调
|
||||
showLoading: options.showLoading !== false,
|
||||
onUploadProgress: options.onUploadProgress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
// ==========================================
|
||||
// 3. 实例创建与全局拦截器
|
||||
// ==========================================
|
||||
|
||||
const request = new Request();
|
||||
|
||||
// 添加默认请求拦截器 - Token处理
|
||||
// 默认请求拦截器 - Token & 语言注入
|
||||
request.addRequestInterceptor((config) => {
|
||||
const session = localStorage.getItem("session");
|
||||
if (session) {
|
||||
try {
|
||||
const sessionData = JSON.parse(session);
|
||||
if (sessionData.token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'Authorization': `Bearer ${sessionData.token}`,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse session data', e);
|
||||
}
|
||||
const token = _getTokenFromStorage();
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
const language = i18n.language || localStorage.getItem('language') || 'zh';
|
||||
@@ -607,105 +543,67 @@ request.addRequestInterceptor((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- 常量配置 ---
|
||||
const DEFAULT_ERROR_MSG = '系统繁忙,请稍后重试';
|
||||
// 需要触发重新登录的 Code 集合 (包含 HTTP 401 和 业务 Token 过期码)
|
||||
// 注意:后端返回的是 "common.0401"(有前导零)
|
||||
const AUTH_ERR_CODES = [401, '401', 'common.401', 'common.0401'];
|
||||
|
||||
// --- 辅助函数:防抖处理登录失效 ---
|
||||
let isRelogging = false;
|
||||
// 全局标记:是否需要登录(从 /api/user/me 获取)
|
||||
let requireLoginMode = false;
|
||||
|
||||
// 设置是否需要登录
|
||||
export function setRequireLoginMode(value: boolean) {
|
||||
requireLoginMode = value;
|
||||
}
|
||||
|
||||
const handleLoginRedirect = () => {
|
||||
// 如果不需要登录,直接返回
|
||||
if (!requireLoginMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRelogging) {
|
||||
return;
|
||||
}
|
||||
if (!requireLoginMode || isRelogging) return;
|
||||
|
||||
isRelogging = true;
|
||||
|
||||
localStorage.removeItem('session');
|
||||
window.dispatchEvent(new CustomEvent('show-login'));
|
||||
|
||||
setTimeout(() => {
|
||||
isRelogging = false;
|
||||
}, 3000);
|
||||
setTimeout(() => { isRelogging = false; }, 3000);
|
||||
};
|
||||
|
||||
// 默认响应拦截器
|
||||
request.addResponseInterceptor(async (response, config) => {
|
||||
// 1. 基础防御:如果没有 response (比如网络直接断了),可能需要走 reject
|
||||
if (!response) {
|
||||
return Promise.reject(new Error('网络连接异常'));
|
||||
}
|
||||
if (!response) return Promise.reject(new Error('网络连接异常'));
|
||||
|
||||
const { status } = response;
|
||||
|
||||
let resData: {};
|
||||
let resData = {};
|
||||
|
||||
try {
|
||||
// 关键点 1: response.data 在原生 fetch 中是不存在的,必须用 .json() 解析
|
||||
// 关键点 2: 必须用 .clone(),因为流只能读一次。读了克隆的,原版 response 还能留给外面用
|
||||
// 关键点 3: 必须 await,因为读取流是异步的
|
||||
resData = await response.clone().json();
|
||||
} catch (e) {
|
||||
// 如果后端返回的不是 JSON (比如 404 HTML 页面,或者空字符串),json() 会报错
|
||||
// 这里捕获异常,保证 resData 至少是个空对象,不会导致后面取值 crash
|
||||
resData = {};
|
||||
}
|
||||
|
||||
// 2. 获取统一的错误码 (转为字符串以匹配 JSON 配置的 Key)
|
||||
// 优先取后端 body 里的 business code,没有则取 HTTP status
|
||||
const code = resData.code ?? status;
|
||||
const codeStr = String(code);
|
||||
|
||||
// 3. 判断成功 (根据你的后端约定:200/0 为成功)
|
||||
// 如果是成功状态,直接返回 response,不拦截
|
||||
if (status === 200 && (code === 200 || code === 0 || code === '0')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- 进入错误处理分支 ---
|
||||
|
||||
// 4. 获取错误文案 (这是我们优化的核心)
|
||||
// 优先级:Store配置文件 > 后端返回的 message > 默认文案
|
||||
const configMsg = errorConfigStore.getMessage(codeStr);
|
||||
const errorMsg = configMsg || resData.message || resData.msg || DEFAULT_ERROR_MSG;
|
||||
|
||||
// 5. 打印日志 (方便开发排查)
|
||||
console.warn(`[API Error] Path: ${config?.url}, Code: ${codeStr}, Msg: ${errorMsg}`);
|
||||
|
||||
// 6. 全局弹窗提示 (排除 401,因为 401 通常直接弹登录框,不需要再弹个 Toast)
|
||||
if (!AUTH_ERR_CODES.includes(code) && !AUTH_ERR_CODES.includes(codeStr)) {
|
||||
// TODO: 替换为你实际的 UI 弹窗,例如 message.error(errorMsg)
|
||||
message.error(errorMsg);
|
||||
}
|
||||
|
||||
// 7. 处理 Token 过期 / 未登录
|
||||
const isAuthError = AUTH_ERR_CODES.includes(code) || AUTH_ERR_CODES.includes(codeStr);
|
||||
if (isAuthError) {
|
||||
handleLoginRedirect();
|
||||
}
|
||||
|
||||
// 8. 返回拒绝的 Promise,中断后续业务代码的 .then() 执行
|
||||
// 将处理过的 errorMsg 塞回去,方便组件内 catch 使用
|
||||
return Promise.reject({
|
||||
...resData,
|
||||
message: errorMsg,
|
||||
status: status
|
||||
});
|
||||
return Promise.reject({ ...resData, message: errorMsg, status });
|
||||
});
|
||||
|
||||
// 导出方法
|
||||
// ==========================================
|
||||
// 4. 导出
|
||||
// ==========================================
|
||||
|
||||
export const get = request.get.bind(request);
|
||||
export const post = request.post.bind(request);
|
||||
export const put = request.put.bind(request);
|
||||
@@ -714,8 +612,5 @@ export const patch = request.patch.bind(request);
|
||||
export const download = request.download.bind(request);
|
||||
export const upload = request.upload.bind(request);
|
||||
|
||||
// 导出类,允许创建自定义实例
|
||||
export { Request };
|
||||
|
||||
// 默认导出
|
||||
export default request;
|
||||
|
||||
@@ -77,46 +77,6 @@ export default defineConfig({
|
||||
for (const p of javaPaths) {
|
||||
proxy[`/api/${p}`] = javaProxyConfig;
|
||||
}
|
||||
|
||||
// ===== Casdoor SSO(M3) =====
|
||||
// 注意代理键必须用 /sso(不是 /sso/casdoor),且 public/sso/callback.html 是静态文件,
|
||||
// 由 Vite 直接返回,不会命中 /sso 代理(代理只转发 API 前缀)。
|
||||
proxy["/sso"] = javaProxyConfig;
|
||||
// /api/user/** 走网关自环路由(UserController / CasdoorOauthController 在网关本地)。
|
||||
// 必须保留 Set-Cookie:CasdoorOauthController 依赖 casdoor_sso_state cookie 做 state 校验,
|
||||
// 仅对非 oauth 路径沿用删除 Set-Cookie 的旧行为。
|
||||
proxy["/api/user"] = {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (
|
||||
proxy: {
|
||||
on: (event: string, handler: (arg: unknown, arg2?: unknown) => void) => void;
|
||||
}
|
||||
) => {
|
||||
proxy.on("proxyReq", (proxyReq: unknown) => {
|
||||
(proxyReq as { removeHeader: (name: string) => void }).removeHeader("referer");
|
||||
(proxyReq as { removeHeader: (name: string) => void }).removeHeader("origin");
|
||||
});
|
||||
proxy.on("proxyRes", (proxyRes: unknown, req: unknown) => {
|
||||
const res = proxyRes as { headers: Record<string, unknown> };
|
||||
const url = (req as { url?: string })?.url ?? "";
|
||||
if (!url.includes("/oauth/")) {
|
||||
delete res.headers["set-cookie"];
|
||||
res.headers["cookies"] = "";
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// deepflow 后端(FastAPI :8000,路由在容器根 /api/*)。
|
||||
// 剥离 /deepflow-backend 前缀:/deepflow-backend/api/config -> /api/config
|
||||
proxy["/deepflow-backend"] = {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (p: string) => p.replace(/^\/deepflow-backend/, ""),
|
||||
};
|
||||
return proxy;
|
||||
})(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user