Merge branch 'main' of https://gitea.kronecker.cc/OurCodesAreAllRight/FilesReadSystem
This commit is contained in:
@@ -597,16 +597,47 @@ class TemplateFillService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if file_type in ["xlsx", "xls"]:
|
if file_type in ["xlsx", "xls"]:
|
||||||
fields = await self._get_template_fields_from_excel(file_path)
|
fields = await self._get_template_fields_from_excel(file_type, file_path)
|
||||||
elif file_type == "docx":
|
elif file_type == "docx":
|
||||||
fields = await self._get_template_fields_from_docx(file_path)
|
fields = await self._get_template_fields_from_docx(file_path)
|
||||||
|
|
||||||
|
# 检查是否需要 AI 生成表头
|
||||||
|
# 条件:没有字段 OR 所有字段都是自动命名的(如"字段1"、"列1"、"Unnamed"开头)
|
||||||
|
needs_ai_generation = (
|
||||||
|
len(fields) == 0 or
|
||||||
|
all(self._is_auto_generated_field(f.name) for f in fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_ai_generation:
|
||||||
|
logger.info(f"模板表头为空或自动生成,尝试 AI 生成表头... (fields={len(fields)})")
|
||||||
|
ai_fields = await self._generate_fields_with_ai(file_path, file_type)
|
||||||
|
if ai_fields:
|
||||||
|
fields = ai_fields
|
||||||
|
logger.info(f"AI 生成表头成功: {len(fields)} 个字段")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"提取模板字段失败: {str(e)}")
|
logger.error(f"提取模板字段失败: {str(e)}")
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
async def _get_template_fields_from_excel(self, file_path: str) -> List[TemplateField]:
|
def _is_auto_generated_field(self, name: str) -> bool:
|
||||||
|
"""检查字段名是否是自动生成的(无效表头)"""
|
||||||
|
import re
|
||||||
|
if not name:
|
||||||
|
return True
|
||||||
|
name_str = str(name).strip()
|
||||||
|
# 匹配 "字段1", "列1", "Field1", "Column1" 等自动生成的名字
|
||||||
|
# 或 "Unnamed: 0" 等 Excel 默认名字
|
||||||
|
if name_str.startswith('Unnamed'):
|
||||||
|
return True
|
||||||
|
if re.match(r'^[列字段ColumnField]+\d+$', name_str, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
if name_str in ['0', '1', '2'] or name_str.startswith('0.') or name_str.startswith('1.'):
|
||||||
|
# 纯数字或类似 "0.1" 的列名
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _get_template_fields_from_excel(self, file_type: str, file_path: str) -> List[TemplateField]:
|
||||||
"""从 Excel 模板提取字段"""
|
"""从 Excel 模板提取字段"""
|
||||||
fields = []
|
fields = []
|
||||||
|
|
||||||
@@ -1409,6 +1440,126 @@ class TemplateFillService:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _generate_fields_with_ai(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
file_type: str
|
||||||
|
) -> Optional[List[TemplateField]]:
|
||||||
|
"""
|
||||||
|
使用 AI 为空表生成表头字段
|
||||||
|
|
||||||
|
当模板文件为空或没有表头时,调用 AI 分析并生成合适的字段名
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 模板文件路径
|
||||||
|
file_type: 文件类型
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
生成的字段列表,如果失败返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 读取 Excel 内容检查是否为空
|
||||||
|
if file_type in ["xlsx", "xls"]:
|
||||||
|
df = pd.read_excel(file_path, header=None)
|
||||||
|
if df.shape[0] == 0 or df.shape[1] == 0:
|
||||||
|
logger.info("Excel 表格为空")
|
||||||
|
# 生成默认字段
|
||||||
|
return [TemplateField(
|
||||||
|
cell=self._column_to_cell(i),
|
||||||
|
name=f"字段{i+1}",
|
||||||
|
field_type="text",
|
||||||
|
required=False,
|
||||||
|
hint="请填写此字段"
|
||||||
|
) for i in range(5)]
|
||||||
|
|
||||||
|
# 表格有数据但没有表头
|
||||||
|
if df.shape[1] > 0:
|
||||||
|
# 读取第一行作为参考,看是否为空
|
||||||
|
first_row = df.iloc[0].tolist() if len(df) > 0 else []
|
||||||
|
if not any(pd.notna(v) and str(v).strip() != '' for v in first_row):
|
||||||
|
# 第一行为空,AI 生成表头
|
||||||
|
content_sample = df.iloc[:10].to_string() if len(df) >= 10 else df.to_string()
|
||||||
|
else:
|
||||||
|
content_sample = df.to_string()
|
||||||
|
else:
|
||||||
|
content_sample = ""
|
||||||
|
|
||||||
|
# 调用 AI 生成表头
|
||||||
|
prompt = f"""你是一个专业的表格设计助手。请为以下空白表格生成合适的表头字段。
|
||||||
|
|
||||||
|
表格内容预览:
|
||||||
|
{content_sample[:2000] if content_sample else "空白表格"}
|
||||||
|
|
||||||
|
请生成5-10个简洁的表头字段名,这些字段应该:
|
||||||
|
1. 简洁明了,易于理解
|
||||||
|
2. 适合作为表格列标题
|
||||||
|
3. 之间有明显的区分度
|
||||||
|
|
||||||
|
请严格按照以下 JSON 格式输出(只需输出 JSON,不要其他内容):
|
||||||
|
{{
|
||||||
|
"fields": [
|
||||||
|
{{"name": "字段名1", "hint": "字段说明提示1"}},
|
||||||
|
{{"name": "字段名2", "hint": "字段说明提示2"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "你是一个专业的表格设计助手。请严格按JSON格式输出。"},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await self.llm.chat(
|
||||||
|
messages=messages,
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=2000
|
||||||
|
)
|
||||||
|
|
||||||
|
content = self.llm.extract_message_content(response)
|
||||||
|
logger.info(f"AI 生成表头返回: {content[:500]}")
|
||||||
|
|
||||||
|
# 解析 JSON
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 清理 markdown 格式
|
||||||
|
cleaned = content.strip()
|
||||||
|
cleaned = re.sub(r'^```json\s*', '', cleaned, flags=re.MULTILINE)
|
||||||
|
cleaned = re.sub(r'^```\s*', '', cleaned, flags=re.MULTILINE)
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
# 查找 JSON
|
||||||
|
json_start = -1
|
||||||
|
for i, c in enumerate(cleaned):
|
||||||
|
if c == '{':
|
||||||
|
json_start = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if json_start == -1:
|
||||||
|
logger.warning("无法找到 JSON 开始位置")
|
||||||
|
return None
|
||||||
|
|
||||||
|
json_text = cleaned[json_start:]
|
||||||
|
result = json.loads(json_text)
|
||||||
|
|
||||||
|
if result and "fields" in result:
|
||||||
|
fields = []
|
||||||
|
for idx, f in enumerate(result["fields"]):
|
||||||
|
fields.append(TemplateField(
|
||||||
|
cell=self._column_to_cell(idx),
|
||||||
|
name=f.get("name", f"字段{idx+1}"),
|
||||||
|
field_type="text",
|
||||||
|
required=False,
|
||||||
|
hint=f.get("hint", "")
|
||||||
|
))
|
||||||
|
return fields
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI 生成表头失败: {str(e)}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ==================== 全局单例 ====================
|
# ==================== 全局单例 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { AuthProvider } from '@/context/AuthContext';
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
|
import { TemplateFillProvider } from '@/context/TemplateFillContext';
|
||||||
import { router } from '@/routes';
|
import { router } from '@/routes';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<RouterProvider router={router} />
|
<TemplateFillProvider>
|
||||||
<Toaster position="top-right" richColors closeButton />
|
<RouterProvider router={router} />
|
||||||
|
<Toaster position="top-right" richColors closeButton />
|
||||||
|
</TemplateFillProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
114
frontend/src/context/TemplateFillContext.tsx
Normal file
114
frontend/src/context/TemplateFillContext.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type SourceFile = {
|
||||||
|
file: File;
|
||||||
|
preview?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TemplateField = {
|
||||||
|
cell: string;
|
||||||
|
name: string;
|
||||||
|
field_type: string;
|
||||||
|
required: boolean;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Step = 'upload' | 'filling' | 'preview';
|
||||||
|
|
||||||
|
interface TemplateFillState {
|
||||||
|
step: Step;
|
||||||
|
templateFile: File | null;
|
||||||
|
templateFields: TemplateField[];
|
||||||
|
sourceFiles: SourceFile[];
|
||||||
|
sourceFilePaths: string[];
|
||||||
|
templateId: string;
|
||||||
|
filledResult: any;
|
||||||
|
setStep: (step: Step) => void;
|
||||||
|
setTemplateFile: (file: File | null) => void;
|
||||||
|
setTemplateFields: (fields: TemplateField[]) => void;
|
||||||
|
setSourceFiles: (files: SourceFile[]) => void;
|
||||||
|
addSourceFiles: (files: SourceFile[]) => void;
|
||||||
|
removeSourceFile: (index: number) => void;
|
||||||
|
setSourceFilePaths: (paths: string[]) => void;
|
||||||
|
setTemplateId: (id: string) => void;
|
||||||
|
setFilledResult: (result: any) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
step: 'upload' as Step,
|
||||||
|
templateFile: null,
|
||||||
|
templateFields: [],
|
||||||
|
sourceFiles: [],
|
||||||
|
sourceFilePaths: [],
|
||||||
|
templateId: '',
|
||||||
|
filledResult: null,
|
||||||
|
setStep: () => {},
|
||||||
|
setTemplateFile: () => {},
|
||||||
|
setTemplateFields: () => {},
|
||||||
|
setSourceFiles: () => {},
|
||||||
|
addSourceFiles: () => {},
|
||||||
|
removeSourceFile: () => {},
|
||||||
|
setSourceFilePaths: () => {},
|
||||||
|
setTemplateId: () => {},
|
||||||
|
setFilledResult: () => {},
|
||||||
|
reset: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TemplateFillContext = createContext<TemplateFillState>(initialState);
|
||||||
|
|
||||||
|
export const TemplateFillProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [step, setStep] = useState<Step>('upload');
|
||||||
|
const [templateFile, setTemplateFile] = useState<File | null>(null);
|
||||||
|
const [templateFields, setTemplateFields] = useState<TemplateField[]>([]);
|
||||||
|
const [sourceFiles, setSourceFiles] = useState<SourceFile[]>([]);
|
||||||
|
const [sourceFilePaths, setSourceFilePaths] = useState<string[]>([]);
|
||||||
|
const [templateId, setTemplateId] = useState<string>('');
|
||||||
|
const [filledResult, setFilledResult] = useState<any>(null);
|
||||||
|
|
||||||
|
const addSourceFiles = (files: SourceFile[]) => {
|
||||||
|
setSourceFiles(prev => [...prev, ...files]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSourceFile = (index: number) => {
|
||||||
|
setSourceFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep('upload');
|
||||||
|
setTemplateFile(null);
|
||||||
|
setTemplateFields([]);
|
||||||
|
setSourceFiles([]);
|
||||||
|
setSourceFilePaths([]);
|
||||||
|
setTemplateId('');
|
||||||
|
setFilledResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateFillContext.Provider
|
||||||
|
value={{
|
||||||
|
step,
|
||||||
|
templateFile,
|
||||||
|
templateFields,
|
||||||
|
sourceFiles,
|
||||||
|
sourceFilePaths,
|
||||||
|
templateId,
|
||||||
|
filledResult,
|
||||||
|
setStep,
|
||||||
|
setTemplateFile,
|
||||||
|
setTemplateFields,
|
||||||
|
setSourceFiles,
|
||||||
|
addSourceFiles,
|
||||||
|
removeSourceFile,
|
||||||
|
setSourceFilePaths,
|
||||||
|
setTemplateId,
|
||||||
|
setFilledResult,
|
||||||
|
reset,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TemplateFillContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTemplateFill = () => useContext(TemplateFillContext);
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useTemplateFill } from '@/context/TemplateFillContext';
|
||||||
|
|
||||||
type DocumentItem = {
|
type DocumentItem = {
|
||||||
doc_id: string;
|
doc_id: string;
|
||||||
@@ -52,29 +53,19 @@ type DocumentItem = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type SourceFile = {
|
|
||||||
file: File;
|
|
||||||
preview?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TemplateField = {
|
|
||||||
cell: string;
|
|
||||||
name: string;
|
|
||||||
field_type: string;
|
|
||||||
required: boolean;
|
|
||||||
hint?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TemplateFill: React.FC = () => {
|
const TemplateFill: React.FC = () => {
|
||||||
const [step, setStep] = useState<'upload' | 'filling' | 'preview'>('upload');
|
const {
|
||||||
const [templateFile, setTemplateFile] = useState<File | null>(null);
|
step, setStep,
|
||||||
const [templateFields, setTemplateFields] = useState<TemplateField[]>([]);
|
templateFile, setTemplateFile,
|
||||||
const [sourceFiles, setSourceFiles] = useState<SourceFile[]>([]);
|
templateFields, setTemplateFields,
|
||||||
const [sourceFilePaths, setSourceFilePaths] = useState<string[]>([]);
|
sourceFiles, setSourceFiles, addSourceFiles, removeSourceFile,
|
||||||
const [templateId, setTemplateId] = useState<string>('');
|
sourceFilePaths, setSourceFilePaths,
|
||||||
|
templateId, setTemplateId,
|
||||||
|
filledResult, setFilledResult,
|
||||||
|
reset
|
||||||
|
} = useTemplateFill();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [filling, setFilling] = useState(false);
|
|
||||||
const [filledResult, setFilledResult] = useState<any>(null);
|
|
||||||
const [previewDoc, setPreviewDoc] = useState<{ name: string; content: string } | null>(null);
|
const [previewDoc, setPreviewDoc] = useState<{ name: string; content: string } | null>(null);
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
@@ -103,8 +94,8 @@ const TemplateFill: React.FC = () => {
|
|||||||
file: f,
|
file: f,
|
||||||
preview: f.type.startsWith('text/') || f.name.endsWith('.md') ? undefined : undefined
|
preview: f.type.startsWith('text/') || f.name.endsWith('.md') ? undefined : undefined
|
||||||
}));
|
}));
|
||||||
setSourceFiles(prev => [...prev, ...newFiles]);
|
addSourceFiles(newFiles);
|
||||||
}, []);
|
}, [addSourceFiles]);
|
||||||
|
|
||||||
const { getRootProps: getSourceProps, getInputProps: getSourceInputProps, isDragActive: isSourceDragActive } = useDropzone({
|
const { getRootProps: getSourceProps, getInputProps: getSourceInputProps, isDragActive: isSourceDragActive } = useDropzone({
|
||||||
onDrop: onSourceDrop,
|
onDrop: onSourceDrop,
|
||||||
@@ -118,10 +109,6 @@ const TemplateFill: React.FC = () => {
|
|||||||
multiple: true
|
multiple: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeSourceFile = (index: number) => {
|
|
||||||
setSourceFiles(prev => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJointUploadAndFill = async () => {
|
const handleJointUploadAndFill = async () => {
|
||||||
if (!templateFile) {
|
if (!templateFile) {
|
||||||
toast.error('请先上传模板文件');
|
toast.error('请先上传模板文件');
|
||||||
@@ -164,40 +151,6 @@ const TemplateFill: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 传统方式:先上传源文档再填表(兼容已有文档库的场景)
|
|
||||||
const handleFillWithExistingDocs = async (selectedDocIds: string[]) => {
|
|
||||||
if (!templateFile || selectedDocIds.length === 0) {
|
|
||||||
toast.error('请选择数据源文档');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setStep('filling');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 先上传模板获取template_id
|
|
||||||
const uploadResult = await backendApi.uploadTemplate(templateFile);
|
|
||||||
|
|
||||||
const fillResult = await backendApi.fillTemplate(
|
|
||||||
uploadResult.template_id,
|
|
||||||
uploadResult.fields || [],
|
|
||||||
selectedDocIds,
|
|
||||||
[],
|
|
||||||
'请从以下文档中提取相关信息填写表格'
|
|
||||||
);
|
|
||||||
|
|
||||||
setTemplateFields(uploadResult.fields || []);
|
|
||||||
setTemplateId(uploadResult.template_id);
|
|
||||||
setFilledResult(fillResult);
|
|
||||||
setStep('preview');
|
|
||||||
toast.success('表格填写完成');
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error('填表失败: ' + (err.message || '未知错误'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!templateFile || !filledResult) return;
|
if (!templateFile || !filledResult) return;
|
||||||
|
|
||||||
@@ -219,16 +172,6 @@ const TemplateFill: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetFlow = () => {
|
|
||||||
setStep('upload');
|
|
||||||
setTemplateFile(null);
|
|
||||||
setTemplateFields([]);
|
|
||||||
setSourceFiles([]);
|
|
||||||
setSourceFilePaths([]);
|
|
||||||
setTemplateId('');
|
|
||||||
setFilledResult(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileIcon = (filename: string) => {
|
const getFileIcon = (filename: string) => {
|
||||||
const ext = filename.split('.').pop()?.toLowerCase();
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
if (['xlsx', 'xls'].includes(ext || '')) {
|
if (['xlsx', 'xls'].includes(ext || '')) {
|
||||||
@@ -253,7 +196,7 @@ const TemplateFill: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{step !== 'upload' && (
|
{step !== 'upload' && (
|
||||||
<Button variant="outline" className="rounded-xl gap-2" onClick={resetFlow}>
|
<Button variant="outline" className="rounded-xl gap-2" onClick={reset}>
|
||||||
<RefreshCcw size={18} />
|
<RefreshCcw size={18} />
|
||||||
<span>重新开始</span>
|
<span>重新开始</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -451,7 +394,7 @@ const TemplateFill: React.FC = () => {
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-center gap-4 mt-6">
|
<div className="flex justify-center gap-4 mt-6">
|
||||||
<Button variant="outline" className="rounded-xl gap-2" onClick={resetFlow}>
|
<Button variant="outline" className="rounded-xl gap-2" onClick={reset}>
|
||||||
<RefreshCcw size={18} />
|
<RefreshCcw size={18} />
|
||||||
<span>继续填表</span>
|
<span>继续填表</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user