fix: 修改管理端的请求api端的CSRF逻辑:

需要 x-csrf-token header
需要 csrf_token cookie
两者必须一致,且是有效的JWT(包含 exp 和 sub=user_id)
This commit is contained in:
npc0-hue
2026-01-22 15:30:36 +08:00
parent 9ed0d7c891
commit 7ba4db8888
23 changed files with 1016 additions and 354 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
type DocProps = {
apiBaseUrl: string
@@ -14,7 +14,9 @@ import type { InputForm } from './type'
import type { Emoji } from '@/app/components/tools/types'
import type { AppData } from '@/models/share'
import { debounce } from 'es-toolkit/compat'
// extend: start messages context handling
import {
Fragment,
memo,
useCallback,
useEffect,
@@ -3,7 +3,7 @@ import { useState } from 'react'
import type { ModelParameterRule } from '../declarations'
import { useLanguage } from '../hooks'
import { isNullOrUndefined } from '../utils'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import Slider from '@/app/components/base/slider'
@@ -42,6 +42,11 @@ import TabHeader from '../../base/tab-header'
import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import ResDownload from './run-batch/res-download'
import BatchProgress from './run-batch/batch-progress' // Extend: Batch import
import Pagination from '@/app/components/base/pagination' // Extend: Batch import
// Extend: Start Batch import
import { downloadBatchApi, fetchBatchWorkflowListApi, processExcelUploadApi } from '@/service/web-extend'
// Extend: Stop Batch import
const GROUP_SIZE = BATCH_CONCURRENCY // to avoid RPM(Request per minute) limit. The group task finished then the next group.
enum TaskStatus {
@@ -155,6 +160,85 @@ const TextGeneration: FC<IMainProps> = ({
doSetAllTaskList(taskList)
allTaskListRef.current = taskList
}
// Extend: Start Batch import - 批量处理相关状态
const [batchJobs, setBatchJobs] = useState<Array<{
id: string
fileName: string
createdAt: string
status: string
totalRows: number
processedRows: number
error?: string
}>>([])
// 分页状态
const [currentPage, setCurrentPage] = useState(1)
const batchJobsLimit = 5 // 每页5个任务
const [totalBatchJobs, setTotalBatchJobs] = useState(0)
const [isLoadingBatchJobs, setIsLoadingBatchJobs] = useState(false)
// 从后端获取批量工作流列表
const loadBatchWorkflows = useCallback(async () => {
if (!appId || currentTab !== 'batch')
return
setIsLoadingBatchJobs(true)
try {
const result = await fetchBatchWorkflowListApi(installedAppInfo?.id, currentPage, batchJobsLimit)
if (result) {
// 转换数据格式以兼容现有组件
const convertedJobs = result.items.map(item => ({
id: item.id,
error: item.error,
error_count: item.error_count,
fileName: item.file_name,
createdAt: item.created_at,
status: item.status,
totalRows: item.total_rows,
processedRows: item.processed_rows,
}))
setBatchJobs(convertedJobs)
setTotalBatchJobs(result.total)
}
}
catch (error) {
console.error('Failed to load batch workflows:', error)
}
finally {
setIsLoadingBatchJobs(false)
}
}, [appId, currentTab, currentPage, installedAppInfo?.id, batchJobsLimit])
// 加载批量工作流列表
useEffect(() => {
loadBatchWorkflows()
}, [loadBatchWorkflows])
// 自动刷新批量工作流列表(每3秒)
useEffect(() => {
if (currentTab !== 'batch' || batchJobs.length === 0)
return
// 检查是否有进行中的任务
const hasActiveJobs = batchJobs.some(job =>
job.status === 'pending' || job.status === 'processing',
)
if (!hasActiveJobs)
return
const refreshInterval = setInterval(() => {
loadBatchWorkflows()
}, 3000) // 每3秒刷新一次
return () => clearInterval(refreshInterval)
}, [currentTab, batchJobs, loadBatchWorkflows])
// 计算分页数据 - 现在数据已经是从后端分页获取的,不需要再切片
const paginatedBatchJobs = batchJobs
// Extend: Stop Batch import
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
const noPendingTask = pendingTaskList.length === 0
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
@@ -328,6 +412,66 @@ const TextGeneration: FC<IMainProps> = ({
// eslint-disable-next-line ts/no-use-before-define
showResultPanel()
}
// Extend: Start Batch import - 处理批量上传
const handleBatchUpload = async (originalFile: File, data: string[][], originalFileName?: string) => {
if (!checkBatchInputs(data))
return
try {
// 创建key-name映射
const keyNameMapping: Record<string, string> = {}
promptConfig?.prompt_variables.forEach((variable) => {
keyNameMapping[variable.name] = variable.key
})
// 直接使用原始文件
const result = await processExcelUploadApi(originalFile, installedAppInfo?.id || '', appId, keyNameMapping)
if (result === null) {
// API调用失败,错误信息已经在processExcelUploadApi中显示
return
}
// 上传成功后,重新加载批量任务列表
await loadBatchWorkflows()
// 显示结果面板
// eslint-disable-next-line ts/no-use-before-define
showResultPanel()
notify({ type: 'success', message: t('batchWorkflow.batchUploadSuccess', { ns: 'extend' }) })
}
catch (error) {
console.error('批量上传失败:', error)
notify({ type: 'error', message: t('batchWorkflow.batchUploadFailed', { ns: 'extend' }) })
}
}
// 下载批量处理结果
const handleBatchDownload = async (batchId: string) => {
try {
const blob = await downloadBatchApi(batchId)
if (blob) {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `batch_results_${batchId}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
}
}
catch (error) {
console.error('下载失败:', error)
notify({ type: 'error', message: t('batchWorkflow.downloadFailed', { ns: 'extend' }) })
}
}
// 处理重试成功回调
const handleRetrySuccess = () => {
// 重试成功后,重新加载批量工作流列表
loadBatchWorkflows()
console.log('批量任务重试成功,已刷新列表')
}
// Extend: Stop Batch import
const handleCompleted = (completionRes: string, taskId?: number, isSuccess?: boolean) => {
const allTaskListLatest = getLatestTaskList()
const batchCompletionResLatest = getBatchCompletionRes()
@@ -464,13 +608,16 @@ const TextGeneration: FC<IMainProps> = ({
: 'bg-chatbot-bg',
)}
>
{isCallBatchAPI && (
{/* Extend: Start Batch import */}
{(isCallBatchAPI || (isInBatchTab && batchJobs.length > 0)) && (
<div className={cn(
'flex shrink-0 items-center justify-between px-14 pb-2 pt-9',
!isPC && 'px-4 pb-1 pt-3',
)}
>
<div className="system-md-semibold-uppercase text-text-primary">{t('generation.executions', { ns: 'share', num: allTaskList.length })}</div>
<div className="system-md-semibold-uppercase text-text-primary">
{isCallBatchAPI ? t('generation.executions', { ns: 'share', num: allTaskList.length }) : t('batchWorkflow.batchJobs', { ns: 'extend', num: batchJobs.length })}
</div>
{allSuccessTaskList.length > 0 && (
<ResDownload
isMobile={!isPC}
@@ -482,17 +629,73 @@ const TextGeneration: FC<IMainProps> = ({
<div className={cn(
'flex h-0 grow flex-col overflow-y-auto',
isPC && 'px-14 py-8',
isPC && isCallBatchAPI && 'pt-0',
isPC && (isCallBatchAPI || (isInBatchTab && batchJobs.length > 0)) && 'pt-0',
!isPC && 'p-0 pb-2',
)}
>
{!isCallBatchAPI ? renderRes() : renderBatchRes()}
{!noPendingTask && (
{!isCallBatchAPI && !(isInBatchTab && batchJobs.length > 0) ? renderRes() : (
<>
{isCallBatchAPI && renderBatchRes()}
{isInBatchTab && batchJobs.length > 0 && (
<div className="space-y-4">
{/* 数据保留提示 */}
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3">
<div className="text-sm text-yellow-800">
<strong>{t('batchWorkflow.dataRetentionNotice', { ns: 'extend' })}:</strong> {t('batchWorkflow.dataRetentionDescription', { ns: 'extend' })}
</div>
</div>
{/* 批量任务列表 */}
<div className="space-y-4">
{isLoadingBatchJobs ? (
<div className="flex justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
</div>
) : paginatedBatchJobs.length > 0 ? (
paginatedBatchJobs.map(job => (
<BatchProgress
key={job.id}
fileName={job.fileName}
batchId={job.id}
workflowId={appId}
jobData={job}
onDownload={() => handleBatchDownload(job.id)}
onRetrySuccess={handleRetrySuccess}
/>
))
) : (
<div className="py-8 text-center text-gray-500">
{t('batchWorkflow.noBatchTasks', { ns: 'extend' })}
</div>
)}
</div>
{/* 分页控件 */}
{totalBatchJobs > batchJobsLimit && (
<div className="mt-6 flex justify-center">
<Pagination
current={currentPage}
onChange={(page) => {
setCurrentPage(page)
// 页面变化时会自动触发useEffect重新加载数据
}}
total={totalBatchJobs}
limit={batchJobsLimit}
className="w-auto"
/>
</div>
)}
</div>
)}
</>
)}
{!noPendingTask && isCallBatchAPI && (
<div className="mt-4">
<Loading type="area" />
</div>
)}
</div>
{/* Extend: Stop Batch import */}
{isCallBatchAPI && allFailedTaskList.length > 0 && (
<div className="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-sm">
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
@@ -590,7 +793,10 @@ const TextGeneration: FC<IMainProps> = ({
<RunBatch
vars={promptConfig.prompt_variables}
onSend={handleRunBatch}
onBatchSend={handleBatchUpload} // Extend: Batch import
isAllFinished={allTasksRun}
isInstalledApp={isInstalledApp} // Extend: Batch import
installedAppInfo={installedAppInfo} // Extend: Batch import
/>
</div>
{currentTab === 'saved' && (
@@ -15,7 +15,7 @@ import { resumeBatchApi, retryFailedTasksApi, stopBatchApi } from '@/service/web
import type { BatchStatus } from '@/utils/batch-progress-manager' // extend: 批量运行工单
import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
export type BatchProgressProps = {
batchId: string
@@ -103,17 +103,17 @@ const BatchProgress: FC<BatchProgressProps> = ({
const getStatusText = (status: BatchStatus) => {
switch (status) {
case 'pending':
return t('extend.batchWorkflow.pending')
return t('batchWorkflow.pending', { ns: 'extend'})
case 'processing':
return t('extend.batchWorkflow.processing')
return t('batchWorkflow.processing', { ns: 'extend'})
case 'completed':
return t('extend.batchWorkflow.completed')
return t('batchWorkflow.completed', { ns: 'extend'})
case 'failed':
return t('extend.batchWorkflow.failed')
return t('batchWorkflow.failed', { ns: 'extend'})
case 'stopped':
return t('extend.batchWorkflow.stopped')
return t('batchWorkflow.stopped', { ns: 'extend'})
default:
return t('extend.batchWorkflow.pending')
return t('batchWorkflow.pending', { ns: 'extend'})
}
}
@@ -188,8 +188,8 @@ const BatchProgress: FC<BatchProgressProps> = ({
{/* 文件信息 */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">{t('extend.batchWorkflow.uploadedFileName')}</div>
<div className="mt-1 text-xs text-gray-500">{t('extend.batchWorkflow.uploadTime')}</div>
<div className="text-sm font-medium text-gray-900">{t('batchWorkflow.uploadedFileName', { ns: 'extend' })}</div>
<div className="mt-1 text-xs text-gray-500">{t('batchWorkflow.uploadTime', { ns: 'extend' })}</div>
</div>
<div>
<div className="text-right">
@@ -234,9 +234,10 @@ const BatchProgress: FC<BatchProgressProps> = ({
{/* 详细进度信息 */}
{jobData.totalRows > 0 && (
<div className="mt-2 text-xs text-gray-500">
{t('extend.batchWorkflow.processed', {
{t('batchWorkflow.processed', {
processed: jobData.processedRows || 0,
total: jobData.totalRows || 0,
ns: 'extend',
})}
</div>
)}
@@ -248,7 +249,7 @@ const BatchProgress: FC<BatchProgressProps> = ({
<RiErrorWarningLine className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="text-sm font-medium text-red-800 mb-1">
{t('extend.batchWorkflow.errorOccurred')}
{t('batchWorkflow.errorOccurred', { ns: 'extend'} )}
</div>
<div className="text-xs text-red-700 break-words">
{jobData.error}
@@ -271,7 +272,7 @@ const BatchProgress: FC<BatchProgressProps> = ({
) : (
<RiStopLine className="h-4 w-4" />
)}
<span className="ml-1">{t('extend.batchWorkflow.stop')}</span>
<span className="ml-1">{t('batchWorkflow.stop', { ns: 'extend'})}</span>
</ActionButton>
)}
{status === 'stopped' && (
@@ -281,7 +282,7 @@ const BatchProgress: FC<BatchProgressProps> = ({
) : (
<RiPlayLargeLine className="h-4 w-4" />
)}
<span className="ml-1">{t('extend.batchWorkflow.resume')}</span>
<span className="ml-1">{t('batchWorkflow.resume', { ns: 'extend'})}</span>
</ActionButton>
)}
{(status === 'failed') && (
@@ -291,7 +292,7 @@ const BatchProgress: FC<BatchProgressProps> = ({
) : (
<RiRefreshLine className="h-4 w-4" />
)}
<span className="ml-1">{t('extend.batchWorkflow.retry')}</span>
<span className="ml-1">{t('batchWorkflow.retry', { ns: 'extend'})}</span>
</ActionButton>
)}
</div>
@@ -300,7 +301,7 @@ const BatchProgress: FC<BatchProgressProps> = ({
{/* 下载按钮 */}
{(status === 'failed' || status === 'completed' || (status === 'processing' && progress >= 100)) && (
<ActionButton onClick={onDownload} size="sm">
<span>{t('extend.batchWorkflow.download')}</span>
<span>{t('batchWorkflow.download', { ns: 'extend'})}</span>
</ActionButton>
)}
</div>
@@ -50,23 +50,30 @@ const CSVDownload: FC<ICSVDownloadProps> = ({
</tbody>
</table>
</div>
<CSVDownloader
className="mt-2 block cursor-pointer"
type={Type.Link}
filename="template"
bom={true}
config={{
// delimiter: ';',
}}
data={[
template,
]}
>
<div className="system-xs-medium flex h-[18px] items-center space-x-1 text-text-accent">
<DownloadIcon className="h-3 w-3" />
<span>{t('generation.downloadTemplate', { ns: 'share' })}</span>
</div>
</CSVDownloader>
{/* Extend: start 聊天批量处理 */}
<div className="mt-2 flex items-center justify-between">
<CSVDownloader
className="cursor-pointer"
type={Type.Link}
filename="template"
bom={true}
config={{
// delimiter: ';',
}}
data={[
template,
]}
>
<div className="system-xs-medium flex h-[18px] items-center space-x-1 text-text-accent">
<DownloadIcon className="h-3 w-3" />
<span>{t('generation.downloadTemplate', { ns: 'share' } )}</span>
</div>
</CSVDownloader>
<span className="system-xs-medium text-text-tertiary">
{t('batchWorkflow.willUseBatchProcessing', { ns: 'extend' } )}
</span>
</div>
{/* Extend: stop 聊天批量处理 */}
</div>
)
@@ -1,28 +1,142 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useState } from 'react'
import type { DragEvent, FC, ReactNode } from 'react'
import React, { useRef, useState } from 'react'
import Papa from 'papaparse'
import jschardet from 'jschardet'
import { useTranslation } from 'react-i18next'
import {
useCSVReader,
} from 'react-papaparse'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
import { cn } from '@/utils/classnames'
export type Props = {
onParsed: (data: string[][]) => void
onParsed: (data: string[][], originalFile?: File) => void // Extend: Batch import
}
// 二开部分 - Begin 自定义CSVReader
type CCProps = {
onUploadAccepted: (results: any, file: File) => void // Extend: Batch import
onDragOver: (event: DragEvent) => void
onDragLeave: (event: DragEvent) => void
children: (props: any) => React.ReactElement
}
const CustomCSVReader: React.FC<CCProps> = ({
onUploadAccepted, onDragOver, onDragLeave, children,
}) => {
const [zoneHover, setZoneHover] = useState(false)
const [acceptedFile, setAcceptedFile] = useState<File | null>(null)
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = (event) => {
const result = event.target?.result as string
// 检测文本编码
const encodingResult = jschardet.detect(result)
let encoding = encodingResult.encoding || 'utf-8'
// 处理可能的误判,将 ISO-8859-2 视为 GBK
if (encoding === 'ISO-8859-2')
encoding = 'gbk'
else if (encodingResult.encoding == null) {
// 判断是否windows
const language = (navigator as any).language || (navigator as any).userLanguage
const isWindows = (navigator.platform && navigator.platform.includes('Win')) || navigator.userAgent.includes('Win')
const isChineseLanguage = /^zh/i.test(language) || (navigator.languages && navigator.languages.some(lang => /^zh/i.test(lang)))
if (isWindows && isChineseLanguage)
encoding = 'gbk'
}
// 处理可能的误判
if (encoding === 'ISO-8859-2' || encoding === 'TIS-620' || !encoding.indexOf('windows'))
encoding = 'gbk'
// 重新用检测到的编码读取文件内容
const correctReader = new FileReader()
correctReader.onload = (e) => {
const text = e.target?.result as string
// 使用 PapaParse 解析 CSV 文件
Papa.parse(text, {
complete: (results: any) => {
onUploadAccepted(results, file)
},
})
}
correctReader.readAsText(file, encoding)
}
reader.readAsBinaryString(file)
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
setZoneHover(false)
const files = event.dataTransfer.files
if (files.length > 0) {
const file = files[0]
setAcceptedFile(file)
readFile(file)
}
}
const inputRef: any = useRef<ReactNode>(null)
const handleClick = () => {
inputRef.current.click()
}
const getRootProps = () => ({
onClick: handleClick,
onDrop: handleDrop,
onDragOver: (event: DragEvent) => {
event.preventDefault()
setZoneHover(true)
},
onDragLeave: (event: DragEvent) => {
event.preventDefault()
setZoneHover(false)
},
})
const renderChildren = () => {
return children({ getRootProps, acceptedFile })
}
return (
<>
<input
accept="text/csv, .csv, application/vnd.ms-excel"
ref={inputRef}
type="file"
style={{ display: 'none' }}
required={false}
multiple={false}
onChange={async (event) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
setAcceptedFile(file)
readFile(file)
}
}}
/>
{renderChildren()}
</>
)
}
// 二开部分 - End 自定义CSVReader
const CSVReader: FC<Props> = ({
onParsed,
}) => {
const { t } = useTranslation()
const { CSVReader } = useCSVReader()
const [zoneHover, setZoneHover] = useState(false)
return (
<CSVReader
onUploadAccepted={(results: any) => {
onParsed(results.data)
<CustomCSVReader
onUploadAccepted={(results: any, file: File) => {
onParsed(results.data, file)
setZoneHover(false)
}}
onDragOver={(event: DragEvent) => {
@@ -50,28 +164,28 @@ const CSVReader: FC<Props> = ({
{
acceptedFile
? (
<div className="flex w-full items-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="flex w-0 grow">
<span className="max-w-[calc(100%_-_30px)] truncate text-text-secondary">{acceptedFile.name.replace(/.csv$/, '')}</span>
<span className="shrink-0 text-text-tertiary">.csv</span>
</div>
<div className="flex w-full items-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="flex w-0 grow">
<span className="max-w-[calc(100%_-_30px)] truncate text-text-secondary">{acceptedFile.name.replace(/.csv$/, '')}</span>
<span className="shrink-0 text-text-tertiary">.csv</span>
</div>
)
</div>
)
: (
<div className="flex w-full items-center justify-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="text-text-tertiary">
{t('generation.csvUploadTitle', { ns: 'share' })}
<span className="cursor-pointer text-text-accent">{t('generation.browse', { ns: 'share' })}</span>
</div>
<div className="flex w-full items-center justify-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="text-text-tertiary">
{t('generation.csvUploadTitle', {ns: 'share'})}
<span className="cursor-pointer text-text-accent">{t('generation.browse', {ns: 'share'})}</span>
</div>
)
</div>
)
}
</div>
</>
)}
</CSVReader>
</CustomCSVReader>
)
}
@@ -40,34 +40,17 @@ const RunBatch: FC<IRunBatchProps> = ({
const [isRecentlyClicked, setIsRecentlyClicked] = React.useState(false)
const handleParsed = (data: string[][], originalFile?: File) => {
console.log('handleParsed 被调用, originalFile:', originalFile ? originalFile.name : 'undefined')
setCsvData(data)
setIsParsed(true)
if (originalFile) {
setFileName(originalFile.name)
setOriginalFile(originalFile)
console.log('originalFile 已设置:', originalFile.name)
}
else {
console.warn('⚠️ originalFile 未传递!')
}
}
const handleSend = async () => {
console.log('=== 批量运行调试信息 ===')
console.log('csvData:', csvData ? csvData.length : 'null')
console.log('originalFile:', originalFile ? originalFile.name : 'null')
console.log('onBatchSend:', onBatchSend ? '已定义' : '未定义')
console.log('isRecentlyClicked:', isRecentlyClicked)
if (!csvData || csvData.length === 0 || !originalFile || isRecentlyClicked) {
console.log('提前返回,原因:', {
noCsvData: !csvData || csvData.length === 0,
noOriginalFile: !originalFile,
isRecentlyClicked,
})
if (!csvData || csvData.length === 0 || !originalFile || isRecentlyClicked)
return
}
// 设置防重复点击状态
setIsRecentlyClicked(true)
@@ -79,13 +62,9 @@ const RunBatch: FC<IRunBatchProps> = ({
const dataRows = csvData.slice(1).filter(row => !row.every(cell => cell === ''))
const rowCount = dataRows.length
console.log('有效数据行数:', rowCount)
console.log('判断条件: rowCount > 10 && onBatchSend =', rowCount > 10, '&&', !!onBatchSend, '=', rowCount > 10 && !!onBatchSend)
// 如果超过10行,使用批量处理
if (rowCount > 10 && onBatchSend) {
console.log('✅ 使用admin后台批量处理')
setIsUploading(true)
try {
await onBatchSend(originalFile, csvData, fileName)
@@ -98,7 +77,7 @@ const RunBatch: FC<IRunBatchProps> = ({
}
}
else {
console.log('❌ 使用旧的前端处理逻辑')
// 10行以内,使用原有的在线处理
onSend(csvData)
}
}
@@ -111,16 +90,37 @@ const RunBatch: FC<IRunBatchProps> = ({
<div className="pt-4">
<CSVReader onParsed={handleParsed} />
<CSVDownload vars={vars} />
{/* 显示行数信息 Extend: Start Batch import */}
{isParsed && csvData.length > 1 && (
<div className="mt-2 text-sm text-gray-500">
{t(
'batchWorkflow.rowCount',
{ ns: 'extend', count: csvData.slice(1).filter(row => !row.every(
cell => cell === '')).length
}
)}
</div>
)}
{/* Extend: Stop Batch import */}
<div className="flex justify-end">
{/* Extend: Start Batch import */}
<Button
variant="primary"
className={cn('mt-4 pl-3 pr-4', !isPC && 'grow')}
onClick={handleSend}
disabled={isDisabled}
>
<Icon className={cn(!isAllFinished && 'animate-spin', 'mr-1 h-4 w-4 shrink-0')} aria-hidden="true" />
<span className="text-[13px] uppercase">{t('generation.run', { ns: 'share' })}</span>
<Icon
className={cn((!isAllFinished || isUploading) && 'animate-spin', 'mr-1 h-4 w-4 shrink-0')}
aria-hidden="true"
/>
<span className="text-[13px] uppercase">
{isUploading ? t('batchWorkflow.uploading', { ns: 'extend' } ) : t('generation.run', { ns: 'share' })}
</span>
</Button>
{/* Extend: Stop Batch import */}
</div>
</div>
)
@@ -9,7 +9,7 @@ import { toJpeg, toPng, toSvg } from 'html-to-image'
import { useNodesReadOnly } from '../hooks'
import TipPopup from './tip-popup'
import { RiExportLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
PortalToFollowElem,
@@ -186,7 +186,7 @@ const ExportImage: FC = () => {
}}
>
<PortalToFollowElemTrigger>
<TipPopup title={t('workflow.common.exportImage')}>
<TipPopup title={t('common.exportImage', { sn: 'workflow' })}>
<div
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
@@ -202,49 +202,49 @@ const ExportImage: FC = () => {
<div className='min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'>
<div className='p-1'>
<div className='px-2 py-1 text-xs font-medium text-text-tertiary'>
{t('workflow.common.currentView')}
{t('common.currentView', { sn: 'workflow' })}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('png')}
>
{t('workflow.common.exportPNG')}
{t('common.exportPNG', { sn: 'workflow' })}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('jpeg')}
>
{t('workflow.common.exportJPEG')}
{t('common.exportJPEG', { sn: 'workflow' })}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('svg')}
>
{t('workflow.common.exportSVG')}
{t('common.exportSVG', { sn: 'workflow' })}
</div>
<div className='border-border-divider mx-2 my-1 border-t' />
<div className='px-2 py-1 text-xs font-medium text-text-tertiary'>
{t('workflow.common.currentWorkflow')}
{t('common.currentWorkflow', { sn: 'workflow' })}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('png', true)}
>
{t('workflow.common.exportPNG')}
{t('common.exportPNG', { sn: 'workflow' })}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('jpeg', true)}
>
{t('workflow.common.exportJPEG')}
{t('common.exportJPEG', { sn: 'workflow' })}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('svg', true)}
>
{t('workflow.common.exportSVG')}
{t('common.exportSVG', { sn: 'workflow' })}
</div>
</div>
</div>
+1
View File
@@ -37,6 +37,7 @@
"batchWorkflow.errorOccurred": "Error occurred",
"batchWorkflow.failed": "Failed",
"batchWorkflow.initializing": "Initializing batch task...",
"batchWorkflow.noBatchTasks": "No batch processing tasks",
"batchWorkflow.pending": "Pending",
"batchWorkflow.pleaseWait": "Please wait",
"batchWorkflow.processed": "Processed {{processed}}/{{total}} rows",
+1
View File
@@ -39,6 +39,7 @@
"batchWorkflow.errorOccurred": "发生错误",
"batchWorkflow.failed": "失败",
"batchWorkflow.initializing": "正在初始化批量任务...",
"batchWorkflow.noBatchTasks": "暂无批量处理任务",
"batchWorkflow.pending": "待处理",
"batchWorkflow.pleaseWait": "请稍候",
"batchWorkflow.processed": "已处理 {{processed}}/{{total}} 行",
+9 -6
View File
@@ -71,12 +71,13 @@
"@sentry/react": "^8.55.0",
"@svgdotjs/svg.js": "^3.2.5",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-form": "^1.23.7",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/react-devtools": "^0.9.0",
"@tanstack/react-form": "^1.23.7",
"@tanstack/react-form-devtools": "^0.2.9",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/papaparse": "^5.5.2",
"abcjs": "^6.5.2",
"ahooks": "^3.9.5",
"class-variance-authority": "^0.7.1",
@@ -86,6 +87,7 @@
"cron-parser": "^5.4.0",
"dayjs": "^1.11.19",
"decimal.js": "^10.6.0",
"dingtalk-jsapi": "^3.2.0",
"dompurify": "^3.3.0",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.5",
@@ -102,6 +104,7 @@
"js-audio-recorder": "^1.0.7",
"js-cookie": "^3.0.5",
"js-yaml": "^4.1.0",
"jschardet": "^3.1.4",
"jsonschema": "^1.5.0",
"katex": "^0.16.25",
"ky": "^1.12.0",
@@ -116,6 +119,7 @@
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"nuqs": "^2.8.6",
"papaparse": "^5.5.3",
"pinyin-pro": "^3.27.0",
"qrcode.react": "^4.2.0",
"qs": "^6.14.0",
@@ -145,16 +149,15 @@
"semver": "^7.7.3",
"sharp": "^0.33.5",
"sortablejs": "^1.15.6",
"swr": "^2.3.6",
"string-ts": "^2.3.1",
"swr": "^2.3.6",
"tailwind-merge": "^2.6.0",
"tldts": "^7.0.17",
"use-context-selector": "^2.0.0",
"uuid": "^10.0.0",
"zod": "^3.25.76",
"zundo": "^2.3.0",
"zustand": "^5.0.9",
"dingtalk-jsapi": "^3.2.0"
"zustand": "^5.0.9"
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.3",
+19 -4
View File
@@ -142,6 +142,9 @@ importers:
'@tanstack/react-query-devtools':
specifier: ^5.90.2
version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.3))(react@19.2.3)
'@types/papaparse':
specifier: ^5.5.2
version: 5.5.2
abcjs:
specifier: ^6.5.2
version: 6.5.2
@@ -220,6 +223,9 @@ importers:
js-yaml:
specifier: ^4.1.0
version: 4.1.1
jschardet:
specifier: ^3.1.4
version: 3.1.4
jsonschema:
specifier: ^1.5.0
version: 1.5.0
@@ -262,6 +268,9 @@ importers:
nuqs:
specifier: ^2.8.6
version: 2.8.6(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react@19.2.3)
papaparse:
specifier: ^5.5.3
version: 5.5.3
pinyin-pro:
specifier: ^3.27.0
version: 3.27.0
@@ -3632,8 +3641,8 @@ packages:
'@types/node@20.19.26':
resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==}
'@types/papaparse@5.5.1':
resolution: {integrity: sha512-esEO+VISsLIyE+JZBmb89NzsYYbpwV8lmv2rPo6oX5y9KhBaIP7hhHgjuTut54qjdKVMufTEcrh5fUl9+58huw==}
'@types/papaparse@5.5.2':
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -6135,6 +6144,10 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jschardet@3.1.4:
resolution: {integrity: sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==}
engines: {node: '>=0.1.90'}
jsdoc-type-pratt-parser@4.8.0:
resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==}
engines: {node: '>=12.0.0'}
@@ -12253,7 +12266,7 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/papaparse@5.5.1':
'@types/papaparse@5.5.2':
dependencies:
'@types/node': 18.15.0
@@ -15138,6 +15151,8 @@ snapshots:
dependencies:
argparse: 2.0.1
jschardet@3.1.4: {}
jsdoc-type-pratt-parser@4.8.0: {}
jsdoc-type-pratt-parser@6.10.0: {}
@@ -16724,7 +16739,7 @@ snapshots:
react-papaparse@4.4.0:
dependencies:
'@types/papaparse': 5.5.1
'@types/papaparse': 5.5.2
papaparse: 5.5.3
react-pdf-highlighter@8.0.0-rc.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+138 -90
View File
@@ -1,5 +1,11 @@
import { request } from './base'
import { API_ADMIN } from '@/config'
import Toast from '@/app/components/base/toast'
// Admin server 使用独立的 JWT 认证,需要从 admin_token 获取
const getAdminToken = () => {
// 优先使用 admin_token,如果没有则尝试使用 console_token
return localStorage.getItem('admin_token') || localStorage.getItem('console_token')
}
type batchProcessing = {
id: string
}
@@ -14,26 +20,33 @@ export const processExcelUploadApi = async (
if (keyNameMapping)
formData.append('key_name_mapping', JSON.stringify(keyNameMapping))
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return null
try {
const s = await request<{ code?: number, data?: batchProcessing, msg?: string }>(
'/gaia/workflow/batch/processing', {
method: 'POST',
body: formData,
headers: new Headers({}),
credentials: 'omit',
}, {
isAdminAPI: true,
bodyStringify: false,
deleteContentType: true,
const response = await fetch(`/admin/gaia/workflow/batch/processing`, {
method: 'POST',
body: formData,
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
Toast.notify({
type: 'error',
message: errorData.msg || errorData.message || '批量处理上传失败',
duration: 6000,
})
return null
}
const s = await response.json() as { code?: number, data?: batchProcessing, msg?: string }
// 检查返回的错误码
if (s?.code && s.code !== 0) {
const Toast = await import('@/app/components/base/toast')
Toast.default.notify({
Toast.notify({
type: 'error',
message: s.msg || '批量处理上传失败',
duration: 6000,
@@ -48,16 +61,7 @@ export const processExcelUploadApi = async (
// 提取错误消息
let errorMessage = '工作流批处理上传excel失败,请重新下载或检查现有模板'
if (error?.response?.json) {
try {
const errorData = await error.response.json()
errorMessage = errorData.msg || errorData.message || errorMessage
}
catch {
// 忽略JSON解析错误
}
}
else if (error?.message) {
if (error?.message) {
errorMessage = error.message
}
else if (typeof error === 'string') {
@@ -65,8 +69,7 @@ export const processExcelUploadApi = async (
}
// 显示错误通知
const Toast = await import('@/app/components/base/toast')
Toast.default.notify({
Toast.notify({
type: 'error',
message: errorMessage,
duration: 6000,
@@ -84,11 +87,12 @@ export const fetchBatchWorkflowListApi = async (
): Promise<{
items: Array<{
id: string
error: string
error_count: number
file_name: string
status: string
total_rows: number
processed_rows: number
error?: string // 添加错误信息字段
created_at: string
updated_at: string
}>
@@ -98,7 +102,7 @@ export const fetchBatchWorkflowListApi = async (
total_pages: number
has_more: boolean
} | null> => {
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return null
@@ -111,16 +115,29 @@ export const fetchBatchWorkflowListApi = async (
if (installedId)
params.append('installed_id', installedId)
const response = await request<{
const response = await fetch(`/admin/gaia/workflow/batch/list?${params.toString()}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (!response.ok) {
console.error('获取批量工作流列表失败:', response.statusText)
return null
}
const responseData = await response.json() as {
code?: number
data?: {
items: Array<{
id: string
file_name: string
status: string
error: string
error_count: number
total_rows: number
processed_rows: number
error?: string // 添加错误信息字段
created_at: string
updated_at: string
}>
@@ -131,22 +148,14 @@ export const fetchBatchWorkflowListApi = async (
has_more: boolean
}
msg?: string
}>(`/gaia/workflow/batch/list?${params.toString()}`, {
method: 'GET',
headers: new Headers({}),
credentials: 'omit',
}, {
isAdminAPI: true,
bodyStringify: false,
deleteContentType: true,
})
}
if (response?.code && response.code !== 0) {
console.error('获取批量工作流列表失败:', response.msg)
if (responseData?.code && responseData.code !== 0) {
console.error('获取批量工作流列表失败:', responseData.msg)
return null
}
return response?.data || null
return responseData?.data || null
}
catch (error: any) {
console.error('获取批量工作流列表失败:', error)
@@ -156,20 +165,23 @@ export const fetchBatchWorkflowListApi = async (
// 获取批量处理进度
export const fetchProgressApi = async (batchId: string) => {
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return null
try {
const s = await request<{ code?: number, data?: any, msg?: string }>(
`/gaia/workflow/batch/${batchId}/progress`, {
method: 'GET',
headers: new Headers({}),
credentials: 'omit',
}, {
isAdminAPI: true,
bodyStringify: false,
deleteContentType: true,
})
const response = await fetch(`/admin/gaia/workflow/batch/${batchId}/progress`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (!response.ok) {
console.error('获取批量处理进度失败:', response.statusText)
return null
}
const s = await response.json() as { code?: number, data?: any, msg?: string }
// 检查返回的错误码
if (s?.code && s.code !== 0) {
@@ -189,19 +201,22 @@ export const fetchProgressApi = async (batchId: string) => {
// 停止批量处理
export const stopBatchApi = async (batchId: string) => {
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return false
try {
const s = await request<{ code: number, msg: string }>(`/gaia/workflow/batch/${batchId}/stop`, {
const response = await fetch(`/admin/gaia/workflow/batch/${batchId}/stop`, {
method: 'POST',
headers: new Headers({}),
credentials: 'omit',
}, {
isAdminAPI: true,
bodyStringify: false,
deleteContentType: true,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (!response.ok)
return false
const s = await response.json() as { code: number, msg: string }
return s?.code === 0
}
catch (error) {
@@ -212,19 +227,22 @@ export const stopBatchApi = async (batchId: string) => {
// 恢复批量处理
export const resumeBatchApi = async (batchId: string) => {
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return false
try {
const s = await request<{ code: number, msg: string }>(`/gaia/workflow/batch/${batchId}/resume`, {
const response = await fetch(`/admin/gaia/workflow/batch/${batchId}/resume`, {
method: 'POST',
headers: new Headers({}),
credentials: 'omit',
}, {
isAdminAPI: true,
bodyStringify: false,
deleteContentType: true,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (!response.ok)
return false
const s = await response.json() as { code: number, msg: string }
return s?.code === 0
}
catch (error) {
@@ -235,19 +253,22 @@ export const resumeBatchApi = async (batchId: string) => {
// 重试批量处理(完全重新开始所有任务)
export const retryBatchApi = async (batchId: string) => {
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return false
try {
const s = await request<{ code: number, msg: string }>(`/gaia/workflow/batch/${batchId}/retry`, {
const response = await fetch(`/admin/gaia/workflow/batch/${batchId}/retry`, {
method: 'POST',
headers: new Headers({}),
credentials: 'omit',
}, {
isAdminAPI: true,
bodyStringify: false,
deleteContentType: true,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (!response.ok)
return false
const s = await response.json() as { code: number, msg: string }
return s?.code === 0
}
catch (error) {
@@ -258,19 +279,22 @@ export const retryBatchApi = async (batchId: string) => {
// 仅重试失败的任务
export const retryFailedTasksApi = async (batchId: string) => {
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return false
try {
const s = await request<{ code: number, msg: string }>(`/gaia/workflow/batch/${batchId}/retry-failed`, {
const response = await fetch(`/admin/gaia/workflow/batch/${batchId}/retry-failed`, {
method: 'POST',
headers: new Headers({}),
credentials: 'omit',
}, {
isAdminAPI: true,
bodyStringify: false,
deleteContentType: true,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (!response.ok)
return false
const s = await response.json() as { code: number, msg: string }
return s?.code === 0
}
catch (error) {
@@ -281,18 +305,42 @@ export const retryFailedTasksApi = async (batchId: string) => {
// 下载批量处理结果
export const downloadBatchApi = async (batchId: string): Promise<Blob | null> => {
const token = localStorage.getItem('console_token')
const token = getAdminToken()
if (!token)
return null
try {
const response = await fetch(`${API_ADMIN}/gaia/workflow/batch/${batchId}/download`, {
const response = await fetch(`/admin/gaia/workflow/batch/${batchId}/download`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Authorization': `Bearer ${token}`,
},
credentials: 'same-origin',
})
// 检查是否返回JSON错误响应
const contentType = response.headers.get('content-type')
console.log('contentType', contentType)
if (contentType && contentType.includes('application/json')) {
// 显示错误消息
Toast.notify({
type: 'error',
message: '您的登录已失效,请重新登陆后再试',
duration: 6000,
})
// 清除本地存储的token
localStorage.removeItem('setup_status')
localStorage.removeItem('console_token')
localStorage.removeItem('refresh_token')
// 清除对话记录
if (localStorage?.getItem('conversationIdInfo'))
localStorage.removeItem('conversationIdInfo')
window.location.href = '/signin'
console.log('signin')
return null
}
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`)