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:
沃巴蒂
2026-10-03 22:44:56 +08:00
parent cbb1048e18
commit ff22ed3a8b
1156 changed files with 129912 additions and 21716 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "edeepdata",
"name": "eDeepData",
"private": true,
"version": "0.0.0",
"type": "module",
-100
View File
@@ -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>
);
};
+23 -6
View File
@@ -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;
-106
View File
@@ -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;
}
+28 -11
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+98
View File
@@ -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
View File
@@ -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>
)
}
+348 -341
View File
@@ -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);
+5 -69
View File
@@ -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>
+1 -33
View File
@@ -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"],
},
];
+4 -15
View File
@@ -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
View File
@@ -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;
-40
View File
@@ -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;
})(),
},