diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index 2c6c0e3..f791517 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -75,6 +75,7 @@ interface EvaluationPointGroup { interface ReviewPointResult { id: string | number; title: string; + pointCode?: string; groupName: string; status: string; content: string; @@ -459,6 +460,7 @@ export async function getReviewPoints(fileId: string, request: Request) { editAuditStatusMessage: editAuditStatus.message, title: message, pointName: point.name || '', + pointCode: String(point.code || ''), groupName: group.name || '', status: point.suggestion_message_type || '', //评查点的评查结果状态 @@ -784,7 +786,13 @@ export async function getReviewPoints(fileId: string, request: Request) { }; // console.log("reviewInfo-------",JSON.stringify(reviewInfo,null,2)); // data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document scoring_proposals->scoringProposalsData - return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument, scoring_proposals: scoringProposalsData }; + // 构建 pointId -> code 映射(供 route loader 补充 pointCode,绕过 Vite tree-shake) + const pointCodeMap: Record = {}; + evaluationPointsData.forEach(point => { + if (point.code) pointCodeMap[String(point.id)] = String(point.code); + }); + + return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument, scoring_proposals: scoringProposalsData, pointCodeMap }; } /** diff --git a/app/components/cross-checking/ReviewPointsList.tsx b/app/components/cross-checking/ReviewPointsList.tsx index 04b9280..023e842 100644 --- a/app/components/cross-checking/ReviewPointsList.tsx +++ b/app/components/cross-checking/ReviewPointsList.tsx @@ -93,32 +93,6 @@ export interface CharPosition { score: number; // OCR识别置信度 } -/** - * text_bbox -> CharPosition[] 转换 - * GraphRAG 抽取结果只有 text_bbox (段落级坐标), 没有 char_positions (字符级坐标)。 - * 将 text_bbox 转为单个 CharPosition 矩形框, 让 PdfPreview 的高亮逻辑复用。 - */ -function resolveCharPositions(data: any): CharPosition[] | undefined { - // 优先用 char_positions - if (data?.char_positions && data.char_positions.length > 0) { - return data.char_positions; - } - // fallback: text_bbox -> CharPosition[] - if (data?.text_bbox) { - const b = data.text_bbox; - if (b.x_min != null && b.y_min != null && b.x_max != null && b.y_max != null - && (b.x_max - b.x_min) > 0 && (b.y_max - b.y_min) > 0) { - return [{ - box: [[b.x_min, b.y_min], [b.x_max, b.y_min], [b.x_max, b.y_max], [b.x_min, b.y_max]], - char: '', - score: 1 - }]; - } - } - return undefined; -} - - /** * 评查点类型定义 * 用于展示单个评查结果 @@ -1538,7 +1512,7 @@ export function ReviewPointsList({ for (const item of chain) { if (item.data.page && typeof onReviewPointSelect === 'function') { hasPage = true; - onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data), item.data.value); + onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions, item.data.value); break; } } @@ -1552,7 +1526,7 @@ export function ReviewPointsList({ // 遍历chain找到第一个有效的page for (const item of chain) { if (item.data.page && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data), item.data.value); + onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions, item.data.value); break; } } @@ -1592,11 +1566,11 @@ export function ReviewPointsList({ // 假设onReviewPointSelect在作用域内可用 const reviewPointId = reviewPoint.id as string; if (reviewPointId && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPointId, Number(item.data.page), resolveCharPositions(item.data), item.data.value); + onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions, item.data.value); } } else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), resolveCharPositions(item.data), item.data.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value); } else{ toastService.error(`没有找到${item.field}对应的索引内容`); @@ -1675,11 +1649,11 @@ export function ReviewPointsList({ if (chain[0].data.page) { const reviewPointId = reviewPoint.id as string; if (reviewPointId && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPointId, chain[0].data.page, resolveCharPositions(chain[0].data), chain[0].data.value); + onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value); } } else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), resolveCharPositions(chain[0].data), chain[0].data.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions, chain[0].data.value); } else{ toastService.error(`没有找到${chain[0].field}对应的索引内容`); @@ -1701,11 +1675,11 @@ export function ReviewPointsList({ if (chain[1].data.page) { const reviewPointId = reviewPoint.id as string; if (reviewPointId && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPointId, chain[1].data.page, resolveCharPositions(chain[1].data), chain[1].data.value); + onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value); } } else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), resolveCharPositions(chain[1].data), chain[1].data.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value); } else{ toastService.error(`没有找到${chain[1].field}对应的索引内容`); @@ -1841,9 +1815,9 @@ export function ReviewPointsList({ onClick={(e) => { e.stopPropagation(); if (mainTypeValue.page && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value); + onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value); }else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value); }else{ toastService.error(`没有找到${fieldKey}对应的索引内容`); } @@ -1852,9 +1826,9 @@ export function ReviewPointsList({ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (mainTypeValue.page && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value); + onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value); }else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value); }else{ toastService.error(`没有找到${fieldKey}对应的索引内容`); } @@ -1922,7 +1896,6 @@ export function ReviewPointsList({ fields?: Record; ai_suggestion?: { @@ -1981,14 +1954,14 @@ export function ReviewPointsList({ - ); - })()} - { reviewPoint.pointName === '签署甲方详细信息校验' && (() => { - const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined; - const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined; - return ( + )} + { reviewPoint.pointName === '签署甲方详细信息校验' && ( - ); - })()} + )} {/*
{reviewPoint.title}
diff --git a/app/components/reviews/FilePreview.tsx b/app/components/reviews/FilePreview.tsx index e692608..adbd7d9 100644 --- a/app/components/reviews/FilePreview.tsx +++ b/app/components/reviews/FilePreview.tsx @@ -53,7 +53,6 @@ interface FilePreviewProps { activeReviewPointResultId: string | null; targetPage?: number; // 新增目标页码参数 charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(仅用于PDF) - textBbox?: { x_min: number; y_min: number; x_max: number; y_max: number }; // GraphRAG段落级坐标 highlightValue?: string; // 高亮文本值(用于DOCX) isStructuredView?: boolean; // 是否显示结构化视图 userInfo?: { @@ -75,7 +74,7 @@ export interface FilePreviewHandle { } // export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) { -export const FilePreview = forwardRef(function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, textBbox, highlightValue, isStructuredView = false, userInfo, aiSuggestionReplace, isTemplate = false }, ref) { +export const FilePreview = forwardRef(function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, highlightValue, isStructuredView = false, userInfo, aiSuggestionReplace, isTemplate = false }, ref) { // 获取文件类型 const real_path = fileContent.path || fileContent.template_contract_path || ''; const fileExtension = real_path.split('.').pop()?.toLowerCase(); @@ -237,7 +236,6 @@ export const FilePreview = forwardRef(funct filePath={real_path} targetPage={targetPage} charPositions={charPositions} - textBbox={textBbox} isStructuredView={isStructuredView} activeReviewPointResultId={activeReviewPointResultId} pageOffset={pageOffset} diff --git a/app/components/reviews/ReviewPointsList.tsx b/app/components/reviews/ReviewPointsList.tsx index 59190e9..7d053a8 100644 --- a/app/components/reviews/ReviewPointsList.tsx +++ b/app/components/reviews/ReviewPointsList.tsx @@ -82,32 +82,6 @@ export interface CharPosition { score: number; // OCR识别置信度 } -/** - * text_bbox -> CharPosition[] 转换 - * GraphRAG 抽取结果只有 text_bbox (段落级坐标), 没有 char_positions (字符级坐标)。 - * 将 text_bbox 转为单个 CharPosition 矩形框, 让 PdfPreview 的高亮逻辑复用。 - */ -function resolveCharPositions(data: any): CharPosition[] | undefined { - // 优先用 char_positions - if (data?.char_positions && data.char_positions.length > 0) { - return data.char_positions; - } - // fallback: text_bbox -> CharPosition[] - if (data?.text_bbox) { - const b = data.text_bbox; - if (b.x_min != null && b.y_min != null && b.x_max != null && b.y_max != null - && (b.x_max - b.x_min) > 0 && (b.y_max - b.y_min) > 0) { - return [{ - box: [[b.x_min, b.y_min], [b.x_max, b.y_min], [b.x_max, b.y_max], [b.x_min, b.y_max]], - char: '', - score: 1 - }]; - } - } - return undefined; -} - - /** * 评查点类型定义 * 用于展示单个评查结果 @@ -120,6 +94,7 @@ export interface ReviewPoint { editAuditStatus: number; editAuditStatusMessage?: string; // 添加审核意见字段 pointName: string; + pointCode?: string; title: string; groupName: string; status: string; @@ -367,9 +342,14 @@ const ReactTableTooltip = ({ content }: { content: string }) => { const [showTooltip, setShowTooltip] = useState(false); const [renderedContent, setRenderedContent] = useState(null); const textRef = useRef(null); - - const isTableLike = content.includes('\t') && content.includes('\n'); - + + const isTabTable = content.includes('\t') && content.includes('\n'); + // 检测 markdown 表格:有 |---| 分隔行,或 pipe 分隔 + 换行(无表头的 pipe 表格) + const isMdTable = content.includes('|') && /\|[-\s:]+\|/.test(content); + const isPipeTable = !isMdTable && content.includes('|') && content.includes('\n') + && content.split('\n').filter(l => l.includes('|')).length >= 2; + const isTableLike = isTabTable || isMdTable || isPipeTable; + useEffect(() => { const checkTextOverflow = () => { const element = textRef.current; @@ -378,33 +358,153 @@ const ReactTableTooltip = ({ content }: { content: string }) => { setShowTooltip(isTableLike || element.scrollHeight > element.clientHeight); } }; - + // 预渲染内容并缓存 - if (isTableLike) { + if (isMdTable) { + setRenderedContent(renderMarkdownTable(content)); + } else if (isPipeTable) { + setRenderedContent(renderPipeTable(content)); + } else if (isTabTable) { setRenderedContent(renderReactTable(content)); } else { setRenderedContent(content); } - + requestAnimationFrame(checkTextOverflow); window.addEventListener('resize', checkTextOverflow); return () => { window.removeEventListener('resize', checkTextOverflow); }; }, [content, isTableLike]); - - // 解析表格数据 + + // 解析表格数据(tab分隔) const parseTableData = (text: string) => { const rows = text.split('\n').map(row => row.split('\t')); return rows; }; - - // 渲染React表格 + + // 解析 markdown 表格(支持多行和单行格式) + const parseMarkdownTable = (text: string): string[][] => { + // 先尝试按换行分割 + const lines = text.split('\n').filter(l => l.trim()); + + // 多行格式:有多行且包含分隔行 + if (lines.length > 2) { + const rows: string[][] = []; + for (const line of lines) { + if (/^\s*\|[-\s:]+\|/.test(line)) continue; // 跳过分隔行 + const cells = line.split('|').map(c => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length); + if (cells.length > 0) rows.push(cells); + } + if (rows.length > 1) return rows; + } + + // 单行格式:整个表格在一行内,通过分隔行 |---|---| 来拆分 + const sepMatch = text.match(/\|[\s-:]+(?:\|[\s-:]+)+\|/); + if (!sepMatch) return []; + + const colCount = (sepMatch[0].match(/---/g) || []).length; + if (colCount === 0) return []; + + const sepIdx = text.indexOf(sepMatch[0]); + const headerPart = text.substring(0, sepIdx); + const bodyPart = text.substring(sepIdx + sepMatch[0].length); + + // 解析 header + const headerCells = headerPart.split('|').map(c => c.trim()).filter(c => c); + // 解析 body:按 | 分割后每 colCount 个cell为一行 + const bodyCells = bodyPart.split('|').map(c => c.trim()).filter(c => c); + const rows: string[][] = [headerCells]; + for (let i = 0; i < bodyCells.length; i += colCount) { + const row = bodyCells.slice(i, i + colCount); + if (row.length > 0) rows.push(row); + } + return rows; + }; + + // 渲染 markdown 表格 + const renderMarkdownTable = (text: string) => { + try { + const tableData = parseMarkdownTable(text); + if (tableData.length === 0) return content; + + return ( +
+ + + + {tableData[0].map((cell, cellIndex) => ( + + ))} + + + + {tableData.slice(1).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {cell || ' '} +
+ {cell || ' '} +
+
+ ); + } catch (error) { + console.error('Markdown表格渲染错误:', error); + return
{content}
; + } + }; + + // 渲染 pipe 分隔表格(无表头,如 "1 | 名称 | 项 | 1\n2 | ...") + const renderPipeTable = (text: string) => { + try { + const rows = text.split('\n') + .filter(l => l.trim() && l.includes('|')) + .map(line => line.split('|').map(c => c.trim())); + if (rows.length === 0) return
{content}
; + return ( +
+ + + {rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {cell || ' '} +
+
+ ); + } catch (error) { + console.error('Pipe表格渲染错误:', error); + return
{content}
; + } + }; + + // 渲染React表格(tab分隔) const renderReactTable = (text: string) => { try { const tableData = parseTableData(text); const hasHeader = tableData.length > 0; - + return (
@@ -412,7 +512,7 @@ const ReactTableTooltip = ({ content }: { content: string }) => { {tableData[0].map((cell, cellIndex) => ( - {row.map((cell, cellIndex) => ( -
@@ -426,7 +526,7 @@ const ReactTableTooltip = ({ content }: { content: string }) => { {tableData.slice(1).map((row, rowIndex) => (
@@ -447,10 +547,54 @@ const ReactTableTooltip = ({ content }: { content: string }) => { + // 表格内容:主区域显示摘要,hover 悬浮显示完整表格 + if (isTableLike) { + // 生成摘要文本:取第一行数据作为预览 + const summaryText = (() => { + const firstLine = content.split('\n').find(l => l.trim() && !/^\s*\|[-\s:]+\|/.test(l)); + return firstLine ? firstLine.trim().substring(0, 80) + (firstLine.length > 80 ? '...' : '') : content.substring(0, 80); + })(); + + // 根据列数动态计算宽度:每列约 120px,最小 400,最大 90vw + const colCount = (() => { + if (isMdTable) { + const sepMatch = content.match(/\|[\s-:]+(?:\|[\s-:]+)+\|/); + return sepMatch ? (sepMatch[0].match(/---/g) || []).length : 4; + } + const firstDataLine = content.split('\n').find(l => l.includes('|')); + return firstDataLine ? firstDataLine.split('|').filter(c => c.trim()).length : 4; + })(); + const tableMaxWidth = Math.min(Math.max(colCount * 140, 400), window.innerWidth * 0.85); + + // 高度不限制,让表格内容完整显示,最大 80vh + const tableMaxHeight = window.innerHeight * 0.8; + + return ( +
+ +
+ + {summaryText} +
+
+
+ ); + } + return (
{showTooltip ? ( - setSearchText(e.target.value)} /> @@ -1288,7 +1433,7 @@ export function ReviewPointsList({ for (const item of chain) { if (item.data.page && typeof onReviewPointSelect === 'function') { hasPage = true; - onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data)); + onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions); break; } } @@ -1302,7 +1447,7 @@ export function ReviewPointsList({ // 遍历chain找到第一个有效的page for (const item of chain) { if (item.data.page && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data)); + onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions); break; } } @@ -1338,15 +1483,15 @@ export function ReviewPointsList({ onClick={(e) => { e.stopPropagation(); if (item.data.page) { - console.log('点击了长链条评查点', resolveCharPositions(item.data), item.data); + console.log('点击了长链条评查点', item.data.char_positions, item.data); // 假设onReviewPointSelect在作用域内可用 const reviewPointId = reviewPoint.id as string; if (reviewPointId && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPointId, Number(item.data.page), resolveCharPositions(item.data), item.data.value); + onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions, item.data.value); } } else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), resolveCharPositions(item.data), item.data.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value); } else{ toastService.error(`没有找到${item.field}对应的索引内容`); @@ -1422,16 +1567,16 @@ export function ReviewPointsList({ ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors flex flex-col`} onClick={(e) => { e.stopPropagation(); - console.log('点击了短链1左', resolveCharPositions(chain[0].data), chain[0].data) + console.log('点击了短链1左', chain[0].data.char_positions, chain[0].data) if (chain[0].data.page) { - // console.log('点击了短链1左', resolveCharPositions(chain[0].data), chain[0].data) + // console.log('点击了短链1左', chain[0].data.char_positions, chain[0].data) const reviewPointId = reviewPoint.id as string; if (reviewPointId && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPointId, chain[0].data.page, resolveCharPositions(chain[0].data), chain[0].data.value); + onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value); } } else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), resolveCharPositions(chain[0].data),chain[0].data.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions,chain[0].data.value); } else{ toastService.error(`没有找到${chain[0].field}对应的索引内容`); @@ -1451,14 +1596,14 @@ export function ReviewPointsList({ onClick={(e) => { e.stopPropagation(); if (chain[1].data.page) { - console.log('点击了短链2右', resolveCharPositions(chain[1].data), chain[1].data) + console.log('点击了短链2右', chain[1].data.char_positions, chain[1].data) const reviewPointId = reviewPoint.id as string; if (reviewPointId && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPointId, chain[1].data.page, resolveCharPositions(chain[1].data), chain[1].data.value); + onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value); } } else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), resolveCharPositions(chain[1].data), chain[1].data.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value); } else{ toastService.error(`没有找到${chain[1].field}对应的索引内容`); @@ -1595,10 +1740,10 @@ export function ReviewPointsList({ e.stopPropagation(); if (mainTypeValue.page && typeof onReviewPointSelect === 'function') { console.log("点击了其他评查点", mainTypeValue) - onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value); - // onReviewPointSelect(reviewPoint.id, undefined, resolveCharPositions(mainTypeValue), mainTypeValue.value); + onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value); + // onReviewPointSelect(reviewPoint.id, undefined, mainTypeValue.char_positions, mainTypeValue.value); }else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){ - onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value); + onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value); }else{ toastService.error(`没有找到${fieldKey}对应的索引内容`); } @@ -1607,7 +1752,7 @@ export function ReviewPointsList({ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (mainTypeValue.page && typeof onReviewPointSelect === 'function') { - onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value); + onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value); }else{ toastService.error(`没有找到${fieldKey}对应的索引内容`); } @@ -1675,7 +1820,6 @@ export function ReviewPointsList({ fields?: Record; @@ -1736,15 +1880,15 @@ export function ReviewPointsList({
- {/* {res ? ( */} - { value.res ? ( + {res ? ( ) : ( @@ -1805,7 +1948,7 @@ export function ReviewPointsList({
大模型判断:
- { value.res ? '通过' : '不通过'} + {res ? '通过' : '不通过'}
@@ -1902,7 +2045,6 @@ export function ReviewPointsList({ if (!isReplaceDisabled && onAiSuggestionReplace && config.fields) { // 从 config.fields[key] 中获取对应的字段信息 const fieldData = config.fields[key]; - console.log("替换原始数据", config, key) if (fieldData) { // 调用回调函数,传递搜索文本(原文)、替换文本(AI建议)和页码 onAiSuggestionReplace( @@ -2603,11 +2745,13 @@ export function ReviewPointsList({
{/*
*/}
+ {reviewPoint.pointCode ? ( + {reviewPoint.pointCode} + ) : ( + #{reviewPoint.pointId || reviewPoint.id} + )}
{reviewPoint.pointName}
- { reviewPoint.pointName === '签署乙方详细信息校验' && (() => { - const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined; - const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined; - return ( + { reviewPoint.pointName === '签署乙方详细信息校验' && ( - ); - })()} - { reviewPoint.pointName === '签署甲方详细信息校验' && (() => { - const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined; - const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined; - return ( + )} + { reviewPoint.pointName === '签署甲方详细信息校验' && ( - ); - })()} + )}
{/*
diff --git a/app/components/reviews/previewComponents/PdfPreview.tsx b/app/components/reviews/previewComponents/PdfPreview.tsx index 9b6833e..3bfbaf2 100644 --- a/app/components/reviews/previewComponents/PdfPreview.tsx +++ b/app/components/reviews/previewComponents/PdfPreview.tsx @@ -38,7 +38,6 @@ interface PdfPreviewProps { filePath: string; // PDF 文件路径 targetPage?: number; // 目标页码 charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(用于高亮显示) - textBbox?: { x_min: number; y_min: number; x_max: number; y_max: number }; // GraphRAG段落级坐标 isStructuredView?: boolean; // 是否结构化视图 activeReviewPointResultId?: string | null; // 激活的评查点结果ID pageOffset?: number; // 页码偏移量(用于调整 OCR 结果的页码) @@ -50,7 +49,6 @@ export function PdfPreview({ filePath, targetPage, charPositions, - textBbox, isStructuredView = false, activeReviewPointResultId, pageOffset = 0, @@ -229,18 +227,6 @@ export function PdfPreview({ // ============ 处理字符位置数据,转换为高亮矩形 ============ const processCharPositionsToHighlights = () => { - // GraphRAG fallback: charPositions 为空但有 textBbox 时,用段落级坐标画高亮 - if ((!charPositions || charPositions.length === 0) && textBbox && targetPage) { - const scale = zoomLevel / 100; - return { - x: textBbox.x_min * coordinateScale * scale, - y: textBbox.y_min * coordinateScale * scale, - width: (textBbox.x_max - textBbox.x_min) * coordinateScale * scale, - height: (textBbox.y_max - textBbox.y_min) * coordinateScale * scale, - text: '' - }; - } - if (!charPositions || charPositions.length === 0 || !targetPage) { return null; } diff --git a/app/components/rules/new/ExtractionSettings.tsx b/app/components/rules/new/ExtractionSettings.tsx index ee89d5c..6c1a48f 100644 --- a/app/components/rules/new/ExtractionSettings.tsx +++ b/app/components/rules/new/ExtractionSettings.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, } from "react"; -import type { EvaluationPoint } from "~/models/evaluation_points"; +import type { EvaluationPoint, LLMFieldType } from "~/models/evaluation_points"; import { EVALUATION_OPTIONS, VLMFieldType } from "~/models/evaluation_points"; /** @@ -35,6 +35,14 @@ import { EVALUATION_OPTIONS, VLMFieldType } from "~/models/evaluation_points"; * - 'or': 规则内任一条件满足即可 */ +/** 获取 LLM 字段名称 */ +const getLLMFieldName = (field: LLMFieldType): string => + typeof field === 'string' ? field : field.name; + +/** 获取 LLM 字段的 multi_entity 状态 */ +const isLLMFieldMultiEntity = (field: LLMFieldType): boolean => + typeof field === 'string' ? false : !!field.multi_entity; + interface ExtractionSettingsProps { onChange: (data: Record) => void; initialData: EvaluationPoint; @@ -196,10 +204,16 @@ export function ExtractionSettings({ const inputs = inputValue[type].split(/[,,\s]+/).filter(Boolean); if (type === 'llm') { - const newFields = [...fields.llm]; + const newFields = [...fields.llm] as LLMFieldType[]; inputs.forEach(input => { - if (!newFields.includes(input)) { - newFields.push(input); + const exists = newFields.some(f => getLLMFieldName(f) === input); + if (!exists) { + if (multiEntityEnabled) { + // 多实体模式:新字段默认 multi_entity=true + newFields.push({ name: input, multi_entity: true }); + } else { + newFields.push(input); + } } }); setFields({ ...fields, llm: newFields }); @@ -218,12 +232,14 @@ export function ExtractionSettings({ newFields.push({ name: input, type: selectedVlmFieldType as VLMFieldType, - template: customVlmPrompt + template: customVlmPrompt, + multi_entity: false, }); } else { newFields.push({ name: input, - type: selectedVlmFieldType as VLMFieldType + type: selectedVlmFieldType as VLMFieldType, + multi_entity: false, }); } } @@ -263,6 +279,30 @@ export function ExtractionSettings({ setHasPendingChanges(true); }; + // 切换 LLM 字段的多实体状态 + const toggleLLMFieldMultiEntity = (index: number) => { + if (!multiEntityEnabled) return; // 多实体未开启时不允许切换 + const newFields = [...fields.llm] as LLMFieldType[]; + const field = newFields[index]; + const name = getLLMFieldName(field); + const currentMulti = isLLMFieldMultiEntity(field); + newFields[index] = { name, multi_entity: !currentMulti }; + setFields({ ...fields, llm: newFields }); + setHasPendingChanges(true); + }; + + // 切换 VLM 字段的多实体状态 + const toggleVLMFieldMultiEntity = (index: number) => { + if (!multiEntityEnabled) return; // 多实体未开启时不允许切换 + const newFields = [...fields.vlm]; + const field = newFields[index]; + if (typeof field === 'object') { + newFields[index] = { ...field, multi_entity: !field.multi_entity }; + setFields({ ...fields, vlm: newFields }); + setHasPendingChanges(true); + } + }; + // 获取VLM字段信息 const getFieldInfo = (field: string | { name: string, type: string, template?: string }) => { let fieldName, fieldType, typeName, badgeClass; @@ -523,7 +563,7 @@ export function ExtractionSettings({ // 验证字段唯一性 const allFieldNames = [ - ...fields.llm, + ...fields.llm.map(f => getLLMFieldName(f)), ...fields.vlm.map(f => typeof f === 'string' ? f : f.name), ...validRegexFields.map(f => f.field) ]; @@ -579,6 +619,19 @@ export function ExtractionSettings({ const handleMultiEntityToggle = () => { const newValue = !multiEntityEnabled; setMultiEntityEnabled(newValue); + + if (newValue) { + // 开启:将所有字符串字段转为 dict(默认 multi_entity=true) + const converted = fields.llm.map(f => + typeof f === 'string' ? { name: f, multi_entity: true } : f + ); + setFields({ ...fields, llm: converted }); + } else { + // 关闭:将所有字段转回字符串 + const simplified = fields.llm.map(f => getLLMFieldName(f)); + setFields({ ...fields, llm: simplified }); + } + setHasPendingChanges(true); }; @@ -595,7 +648,7 @@ export function ExtractionSettings({
多实体抽取 - 启用后,系统将按实体展开字段进行抽取(AI感知模式) + 启用后,点击字段可切换是否按实体展开抽取(绿色=展开)
@@ -677,25 +730,31 @@ export function ExtractionSettings({
支持一次输入多个字段,用逗号、空格或顿号分隔
-
- {fields.llm.map((field, index) => ( -
- {field} - removeField("llm", index)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") - removeField("llm", index); - }} - role="button" - tabIndex={0} - aria-label={`删除字段 ${field}`} +
+ {fields.llm.map((field, index) => { + const name = getLLMFieldName(field); + const isMulti = isLLMFieldMultiEntity(field); + return ( +
- ))} + {name} + { e.stopPropagation(); removeField("llm", index); }} + role="button" + tabIndex={0} + > + × + + + ); + })}
@@ -840,18 +899,27 @@ export function ExtractionSettings({
{fields.vlm.map((field, index) => { const { fieldName, fieldType, typeName, badgeClass } = getFieldInfo(field); + const isMulti = typeof field === 'object' && field.multi_entity === true; return ( -
+
toggleVLMFieldMultiEntity(index)} + role={multiEntityEnabled ? 'button' : undefined} + title={multiEntityEnabled ? (isMulti ? '点击关闭多实体展开' : '点击开启多实体展开') : fieldName} + > {fieldName} {typeName} removeField("vlm", index)} + onClick={(e) => { e.stopPropagation(); removeField("vlm", index); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") removeField("vlm", index); @@ -859,6 +927,7 @@ export function ExtractionSettings({ role="button" tabIndex={0} aria-label={`删除字段 ${fieldName}`} + style={multiEntityEnabled && isMulti ? { color: 'rgba(255,255,255,0.8)' } : undefined} > × diff --git a/app/components/rules/new/ReviewSettings.tsx b/app/components/rules/new/ReviewSettings.tsx index dd4dc3c..08ab865 100644 --- a/app/components/rules/new/ReviewSettings.tsx +++ b/app/components/rules/new/ReviewSettings.tsx @@ -387,11 +387,12 @@ export function ReviewSettings({ // 去重 const uniqueFields = [...new Set(processedFields)]; - // 检查是否有字段被删除 - const deletedFields = availableFields.filter(field => !uniqueFields.includes(field)); - + // 检查是否有字段被删除(防御性:确保availableFields是数组) + const safeAvailableFields: string[] = Array.isArray(availableFields) ? availableFields : []; + const deletedFields = safeAvailableFields.filter(field => !uniqueFields.includes(field)); + // 处理新增的字段 - const newFields = uniqueFields.filter((field: string) => !availableFields.includes(field)); + const newFields = uniqueFields.filter((field: string) => !safeAvailableFields.includes(field)); if (newFields.length > 0 || deletedFields.length > 0) { // console.log('Updating fields in checkAndUpdateFields - deleted:', deletedFields, 'new:', newFields); @@ -538,13 +539,15 @@ export function ReviewSettings({ const updatedConfig = { ...rule.config }; // 对所有规则类型都更新availableFields字段 - // 处理字段,只保留字段名,去掉类型后缀 - const processedFields = newFields.map(field => { - if (field.includes('_')) { - return field.split('_')[0]; // 只保留类型前面的字段名 - } - return field; - }); + // 处理字段,只保留字段名,去掉类型后缀(防御性:确保field是字符串) + const processedFields = newFields + .filter(field => typeof field === 'string') + .map(field => { + if (field.includes('_')) { + return field.split('_')[0]; + } + return field; + }); // 去重 const uniqueFields = [...new Set(processedFields)]; @@ -792,21 +795,22 @@ export function ReviewSettings({ // 渲染字段标签,确保已选择的字段即使在新的字段列表中不存在也会显示 const renderFieldTags = (ruleId: string, config: Record) => { - // 获取规则的当前已选字段 - // 修复:对于exists类型规则,应该使用fields而不是selectedFields - const selectedFields = Array.isArray(config.fields) ? - config.fields as string[] : - (Array.isArray(config.selectedFields) ? config.selectedFields as string[] : []); - + // 获取规则的当前已选字段(防御性:确保总是数组) + const rawSelected = Array.isArray(config.fields) + ? config.fields + : (Array.isArray(config.selectedFields) ? config.selectedFields : []); + const selectedFields: string[] = Array.isArray(rawSelected) ? rawSelected : []; + // 优先使用配置中存储的可用字段,如果没有则使用当前可用字段 - const fieldsToRender = Array.isArray(config.availableFields) ? - config.availableFields as string[] : - availableFields; - + const rawFieldsToRender = Array.isArray(config.availableFields) + ? config.availableFields + : availableFields; + const fieldsToRender: string[] = Array.isArray(rawFieldsToRender) ? rawFieldsToRender : []; + return (
{fieldsToRender.map((field, index) => { - // 使用includes方法检查选中状态 + // 使用includes方法检查选中状态(selectedFields 总是数组,不会抛错) const isSelected = selectedFields.includes(field); return ( @@ -871,10 +875,10 @@ export function ReviewSettings({ const { id, type, config } = rule; // 如果规则中的availableFields不是最新的,则更新它 - if (type && config && (!config.availableFields || - (Array.isArray(config.availableFields) && - !availableFields.every((field) => (config.availableFields as string[]).includes(field)) || - !(config.availableFields as string[]).every((field) => availableFields.includes(field))))) { + const cfgAvailableFields = Array.isArray(config.availableFields) ? config.availableFields as string[] : null; + if (type && config && (!cfgAvailableFields || + !availableFields.every((field) => cfgAvailableFields.includes(field)) || + !cfgAvailableFields.every((field) => availableFields.includes(field)))) { // 延迟更新以避免在渲染过程中修改状态 setTimeout(() => { // console.log('Updating rule config with new available fields:', availableFields); diff --git a/app/config/api-config.ts b/app/config/api-config.ts index b49b288..bbb7bb3 100644 --- a/app/config/api-config.ts +++ b/app/config/api-config.ts @@ -47,11 +47,11 @@ export const portConfigs: Record> = { // 梅州 '51703': { - // baseUrl: 'http://nas.7bm.co:8073', - // documentUrl: 'http://nas.7bm.co:8073/docauditai/', - // uploadUrl: 'http://nas.7bm.co:8073/api/v2/documents', - // collaboraUrl: 'http://nas.7bm.co:9980', - // appUrl: 'http://nas.7bm.co:51703', + baseUrl: 'http://nas.7bm.co:8073', + documentUrl: 'http://nas.7bm.co:8073/docauditai/', + uploadUrl: 'http://nas.7bm.co:8073/api/v2/documents', + collaboraUrl: 'http://nas.7bm.co:9980', + appUrl: 'http://nas.7bm.co:51703', // baseUrl: 'http://172.16.0.56:8073', @@ -60,11 +60,11 @@ export const portConfigs: Record> = { // collaboraUrl: 'http://172.16.0.81:9980', // appUrl: 'http://172.16.0.34:51703', - baseUrl: 'http://10.79.97.17:8000', - documentUrl: 'http://10.79.97.17:8000/docauditai/', - uploadUrl: 'http://10.79.97.17:8000/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://10.79.97.17:51703', + // baseUrl: 'http://10.79.97.17:8000', + // documentUrl: 'http://10.79.97.17:8000/docauditai/', + // uploadUrl: 'http://10.79.97.17:8000/api/v2/documents', + // collaboraUrl: 'http://10.79.97.17:9980', + // appUrl: 'http://10.79.97.17:51703', oauth: { redirectUri: 'http://10.79.97.17:51703/callback', diff --git a/app/models/evaluation_points.ts b/app/models/evaluation_points.ts index 24d8131..9810068 100644 --- a/app/models/evaluation_points.ts +++ b/app/models/evaluation_points.ts @@ -55,19 +55,33 @@ interface MultiEntityConfig { */ type MultiEntityExpandMode = 'awareness'; // AI感知模式 +/** + * LLM 字段配置(支持字段级多实体标记) + */ +interface LLMFieldConfig { + name: string; // 字段名称(完整路径) + multi_entity?: boolean; // 是否对此字段做多实体展开(默认 false) +} + +/** + * LLM 字段类型:支持旧格式字符串和新格式对象 + */ +type LLMFieldType = string | LLMFieldConfig; + /** * 抽取配置类型定义 */ interface ExtactionConfigType { - multi_entity?: MultiEntityConfig; // 多实体抽取配置(控制抽取阶段是否按实体展开字段) + multi_entity?: MultiEntityConfig; // 多实体抽取配置(EP级开关,作为旧格式str字段的fallback) llm: { - fields: string[]; + fields: LLMFieldType[]; // 支持 "字段名" 或 {"name": "字段名", "multi_entity": true} prompt_setting: LLMPromptSetting; }; vlm: { fields: Array<{ name: string; type: VLMFieldType; // 多模态字段类型 默认、货币、打印、印章、骑缝章、英文、数字、手写、自定义 + multi_entity?: boolean; // 是否对此字段做多实体展开 template?: string; // 自定义类型的提示词模板(仅当 type 为 custom 时使用) }>; prompt_setting: VLMPromptSetting; @@ -329,6 +343,8 @@ export type { ExtactionConfigType, MultiEntityConfig, MultiEntityExpandMode, + LLMFieldConfig, + LLMFieldType, VLMPromptSetting, VLMFieldType, LLMPromptSetting, diff --git a/app/routes/entry-modules._index.tsx b/app/routes/entry-modules._index.tsx index 61798ec..cb695d0 100644 --- a/app/routes/entry-modules._index.tsx +++ b/app/routes/entry-modules._index.tsx @@ -100,23 +100,6 @@ const AREA_OPTIONS = [ { value: "云浮", label: "云浮" }, { value: "揭阳", label: "揭阳" }, { value: "潮州", label: "潮州" }, - { value: "湛江", label: "湛江" }, - // { value: "广州", label: "广州" }, - // { value: "深圳", label: "深圳" }, - // { value: "珠海", label: "珠海" }, - // { value: "佛山", label: "佛山" }, - // { value: "惠州", label: "惠州" }, - // { value: "江门", label: "江门" }, - // { value: "茂名", label: "茂名" }, - // { value: "汕尾", label: "汕尾" }, - // { value: "汕头", label: "汕头" }, - // { value: "河源", label: "河源" }, - // { value: "阳江", label: "阳江" }, - // { value: "清远", label: "清远" }, - // { value: "东莞", label: "东莞" }, - // { value: "中山", label: "中山" }, - // { value: "肇庆", label: "肇庆" }, - // { value: "韶关", label: "韶关" }, { value: "省局", label: "省局" } ]; diff --git a/app/routes/entry-modules.new.tsx b/app/routes/entry-modules.new.tsx index f594ef4..e7efb0e 100644 --- a/app/routes/entry-modules.new.tsx +++ b/app/routes/entry-modules.new.tsx @@ -76,23 +76,6 @@ const AREA_OPTIONS = [ { value: "云浮", label: "云浮" }, { value: "揭阳", label: "揭阳" }, { value: "潮州", label: "潮州" }, - { value: "湛江", label: "湛江" }, - // { value: "广州", label: "广州" }, - // { value: "深圳", label: "深圳" }, - // { value: "珠海", label: "珠海" }, - // { value: "佛山", label: "佛山" }, - // { value: "惠州", label: "惠州" }, - // { value: "江门", label: "江门" }, - // { value: "茂名", label: "茂名" }, - // { value: "汕尾", label: "汕尾" }, - // { value: "汕头", label: "汕头" }, - // { value: "河源", label: "河源" }, - // { value: "阳江", label: "阳江" }, - // { value: "清远", label: "清远" }, - // { value: "东莞", label: "东莞" }, - // { value: "中山", label: "中山" }, - // { value: "肇庆", label: "肇庆" }, - // { value: "韶关", label: "韶关" }, { value: "省局", label: "省局" } ]; diff --git a/app/routes/reviews.tsx b/app/routes/reviews.tsx index a04ba7d..a3b1541 100644 --- a/app/routes/reviews.tsx +++ b/app/routes/reviews.tsx @@ -31,6 +31,7 @@ import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react"; import type { FilePreviewHandle } from "~/components/reviews/FilePreview"; import reviewsStyles from "~/styles/reviews.css?url"; import { getReviewPoints, getReviewPoints_fromApi, getUnifiedEvaluationResults, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews"; +import { postgrestGet } from "~/api/postgrest-client"; import { toastService } from "~/components/ui/Toast"; // 导入评查详情页面组件 @@ -190,6 +191,27 @@ export async function loader({ request }: LoaderFunctionArgs) { return Response.json({ result: false, message: '文件ID不能为空' }); } + // 补充 pointCode 到 reviewPoints(直接查 DB,不受 Vite tree-shake 影响) + async function patchPointCodes(points: any[], jwt: string) { + try { + const pointIds = points.map((p: any) => p.pointId).filter(Boolean); + if (pointIds.length === 0) return; + const resp = await postgrestGet('/api/postgrest/proxy/evaluation_points', { + select: 'id,code', + filter: { id: `in.(${[...new Set(pointIds)].join(',')})` }, + token: jwt, + }); + // resp.data 可能是 {code:200, data:[...]} 或直接 [...] + const raw = resp.data; + const epList = Array.isArray(raw) ? raw : (raw?.data && Array.isArray(raw.data) ? raw.data : []); + const codeMap: Record = {}; + epList.forEach((ep: any) => { if (ep.code) codeMap[String(ep.id)] = ep.code; }); + points.forEach((p: any) => { p.pointCode = codeMap[String(p.pointId)] || ''; }); + } catch (e) { + console.error('[Reviews Loader] patchPointCodes error:', e); + } + } + // 获取用户会话信息 const { getUserSession } = await import("~/api/login/auth.server"); const { userInfo, frontendJWT } = await getUserSession(request); @@ -209,6 +231,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) { + await patchPointCodes(reviewData.data as any[], frontendJWT); return Response.json({ previousRoute: previousRoute, document: reviewData.document, @@ -243,6 +266,7 @@ export async function loader({ request }: LoaderFunctionArgs) { editAuditStatusMessage: '', title: '该评查点未涉及', pointName: r.name || '', + pointCode: r.code || '', groupName: '', status: 'notApplicable', content: {}, @@ -255,6 +279,7 @@ export async function loader({ request }: LoaderFunctionArgs) { postAction: '', })); const allReviewPoints = [...existingPoints, ...notApplicablePoints]; + await patchPointCodes(allReviewPoints, frontendJWT); return Response.json({ previousRoute: previousRoute, @@ -386,7 +411,6 @@ export default function ReviewDetails() { const [targetPage, setTargetPage] = useState(undefined); const [templateTargetPage, setTemplateTargetPage] = useState(undefined); const [charPositions, setCharPositions] = useState | undefined>(undefined); - const [textBbox, setTextBbox] = useState<{ x_min: number; y_min: number; x_max: number; y_max: number } | undefined>(undefined); const [highlightValue, setHighlightValue] = useState(undefined); const [pendingUpdate, setPendingUpdate] = useState<{ reviewPointResultId: string; @@ -552,19 +576,17 @@ export default function ReviewDetails() { setActiveTab(tabKey); }; - const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string, bbox?: { x_min: number; y_min: number; x_max: number; y_max: number }) => { + const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => { // 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发 if (reviewPointId === activeReviewPointResultId && page) { setTargetPage(undefined); setCharPositions(undefined); - setTextBbox(undefined); setHighlightValue(undefined); // 使用setTimeout确保状态更新后再设置新的targetPage、charPositions和highlightValue setTimeout(() => { setActiveReviewPointResultId(reviewPointId); setTargetPage(page); setCharPositions(charPos); - setTextBbox(bbox); setHighlightValue(value); }, 0); } else { @@ -572,7 +594,6 @@ export default function ReviewDetails() { setActiveReviewPointResultId(reviewPointId); setTargetPage(page); setCharPositions(charPos); - setTextBbox(bbox); setHighlightValue(value); } }; @@ -970,7 +991,6 @@ export default function ReviewDetails() { activeReviewPointResultId={activeReviewPointResultId} targetPage={targetPage} charPositions={charPositions} - textBbox={textBbox} highlightValue={highlightValue} userInfo={loaderData.userInfo} aiSuggestionReplace={aiSuggestionReplace} @@ -1024,7 +1044,6 @@ export default function ReviewDetails() { activeReviewPointResultId={activeReviewPointResultId} targetPage={targetPage} charPositions={charPositions} - textBbox={textBbox} />
diff --git a/app/routes/rules.new.tsx b/app/routes/rules.new.tsx index c41fccb..a7db764 100644 --- a/app/routes/rules.new.tsx +++ b/app/routes/rules.new.tsx @@ -855,10 +855,11 @@ export default function RuleNew() { } if (response.error) { - if (response.error.includes('evaluation_points_code_key')) { + const errorStr = typeof response.error === 'string' ? response.error : JSON.stringify(response.error); + if (errorStr.includes('evaluation_points_code_key')) { toastService.error('在基本信息中:评查点编码已存在,请修改后保存。'); } else { - toastService.error(`系统繁忙: ${response.error}`); + toastService.error(`系统繁忙: ${errorStr}`); } setIsLoading(false); } else if (response.data) { diff --git a/app/styles/pages/config-lists_new.css b/app/styles/pages/config-lists_new.css index b6171d6..fbdacb4 100644 --- a/app/styles/pages/config-lists_new.css +++ b/app/styles/pages/config-lists_new.css @@ -95,6 +95,17 @@ @apply bg-[rgba(0,104,74,0.15)] border-[#00684a] text-[#00684a]; } +.config-new-page .ant-btn.tag-button.tag-multi-entity { + background-color: #00684a !important; + border-color: #00684a !important; + color: #fff !important; +} +.config-new-page .ant-btn.tag-button.tag-multi-entity:hover { + background-color: #005a3f !important; + border-color: #005a3f !important; + color: #fff !important; +} + /* JSON编辑器 */ .config-new-page .json-editor { @apply w-full min-h-[400px] font-mono text-sm leading-relaxed diff --git a/app/utils.ts b/app/utils.ts index 51946ee..801e4a5 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -10,20 +10,22 @@ dayjs.extend(utc); * 处理字段名,去除类型后缀 * 例如: "字段名_类型" -> "字段名" */ -export function processFieldName(field: string): string { - if (field.includes('_')) { - return field.split('_')[0]; // 只保留类型前面的字段名 +export function processFieldName(field: string | { name: string; [key: string]: unknown }): string { + const name = typeof field === 'string' ? field : (field?.name || ''); + if (name.includes('_')) { + return name.split('_')[0]; // 只保留类型前面的字段名 } - return field; + return name; } /** * 处理字段数组,去除类型后缀并去重 + * 支持字符串和 {name, multi_entity} 对象格式 */ -export function processFieldNames(fields: string[]): string[] { +export function processFieldNames(fields: (string | { name: string; [key: string]: unknown })[]): string[] { // 处理字段,去掉类型后缀 - const processedFields = fields.map(processFieldName); - + const processedFields = fields.map(processFieldName).filter(Boolean); + // 去重并返回 return [...new Set(processedFields)]; } diff --git a/docs/attribute_type_frontend_integration.md b/docs/attribute_type_frontend_integration.md new file mode 100644 index 0000000..4725840 --- /dev/null +++ b/docs/attribute_type_frontend_integration.md @@ -0,0 +1,332 @@ +# 用户自定义合同类型 - 前后端对接文档 + +## 概述 + +为了优化 GraphRAG 抽取管道的规则加载,后端新增了 `attribute_type`(合同专属类型)参数支持。当指定该参数时,系统只加载 **通用 + 指定类型** 的评查点规则,大幅减少无关字段(816 → ~197),提升检索精度和抽取稳定性。 + +## 后端修改内容 + +### 1. 文档类型自动检测(`contract_server.py`) + +**文件**: `services/documents/v2/contract_server.py` + +**新增方法**: `_detect_attribute_type()` + +```python +@staticmethod +def _detect_attribute_type(filename: str) -> Optional[str]: + """ + 从文件名关键词检测合同专属子类型。 + + 关键词表与 executor._CONTRACT_TYPE_KEYWORDS 保持一致。 + 返回: "技术"/"租赁"/"买卖"/... 或 None + """ + _KEYWORDS = [ + ("买卖", ["采购", "买卖", "购销", "供货", "购买"]), + ("租赁", ["租赁", "租用", "出租"]), + ("服务", ["服务", "咨询", "顾问"]), + ("委托", ["委托", "代理"]), + ("建设工程", ["建设工程", "施工", "装修", "装饰"]), + ("培训", ["培训", "教育", "教学"]), + ("技术", ["技术", "开发", "软件", "系统集成", "信息化"]), + ("赠与", ["赠与", "捐赠"]), + ("运输", ["运输", "物流", "配送", "搬运"]), + ("仓储", ["仓储", "存储", "保管"]), + ("合作", ["合作", "联营", "合资"]), + ("承揽", ["承揽", "加工", "定作"]), + ] + if not filename: + return None + for type_code, keywords in _KEYWORDS: + for kw in keywords: + if kw in filename: + return type_code + return None +``` + +**调用位置**: `extract()` 方法(约 L455) + +```python +# 确定合同专属子类型(用于精准加载规则) +_attribute_type = self._detect_attribute_type(self.filename) +if _attribute_type: + log.document.info( + f"[GraphRAG] doc={self.document_id} 合同子类型: {_attribute_type}" + ) + +rag_result = await rag_service.extract( + ... + attribute_type=_attribute_type, +) +``` + +### 2. GraphRAG 抽取服务支持(`rag_extraction_service.py`) + +**文件**: `services/graph_rag/rag_extraction_service.py` + +**修改点 1**: `extract()` 方法签名新增 `attribute_type` 参数(约 L175) + +```python +async def extract( + self, + ... + attribute_type: Optional[str] = None, # 新增参数 +) -> Dict[str, Any]: + """ + Args: + ... + attribute_type: 合同专属子类型(如"技术""租赁"),由上游指定, + 用于只加载 通用+该类型 的评查点规则,减少无关字段 + """ +``` + +**修改点 2**: `_load_extraction_rules()` 方法新增类型过滤逻辑(约 L901) + +```python +# 指定了合同子类型时,只加载 通用+该类型(大幅减少无关字段) +if attribute_type: + _ep_filters["document_attribute_type"] = f"in.(通用,{attribute_type})" +``` + +### 3. 支持的合同类型 + +| 类型代码 | 类型名称 | 关键词(文件名检测) | +|---------|---------|---------------------| +| `通用` | 通用类型 | 默认类型 | +| `买卖` | 买卖合同 | 采购、买卖、购销、供货、购买 | +| `租赁` | 租赁合同 | 租赁、租用、出租 | +| `服务` | 服务合同 | 服务、咨询、顾问 | +| `委托` | 委托合同 | 委托、代理 | +| `建设工程` | 建设工程合同 | 建设工程、施工、装修、装饰 | +| `培训` | 培训合同 | 培训、教育、教学 | +| `技术` | 技术合同 | 技术、开发、软件、系统集成、信息化 | +| `赠与` | 赠与合同 | 赠与、捐赠 | +| `运输` | 运输合同 | 运输、物流、配送、搬运 | +| `仓储` | 仓储合同 | 仓储、存储、保管 | +| `合作` | 合作合同 | 合作、联营、合资 | +| `承揽` | 承揽合同 | 承揽、加工、定作 | + +## 前端对接指南 + +### 当前自动检测机制 + +**现状**: 后端已实现从**文件名**自动检测合同类型的逻辑。 + +例如: +- `技术合同(去空格).docx` → 自动识别为 `技术` 类型 +- `房屋租赁合同.pdf` → 自动识别为 `租赁` 类型 +- `货物采购合同.docx` → 自动识别为 `买卖` 类型 + +### 前端手动指定合同类型(需后端额外支持) + +**注意**: 当前后端尚未支持从 `upload_info` 中读取 `attribute_type` 参数。如需前端手动指定,需要进行以下额外修改: + +#### 方案 A:修改上传接口传递 attribute_type + +**1. 前端上传时传递参数** + +```javascript +// 示例:使用 FormData 上传 +const formData = new FormData(); +formData.append('file', fileBlob); + +// 在 upload_info 中指定合同类型 +const uploadInfo = { + type_id: 1, // 文档类型ID(1=合同) + document_number: "HT2024001", // 合同编号 + attribute_type: "技术", // 👈 手动指定合同类型 + evaluation_level: "普通", + remark: "测试合同" +}; +formData.append('upload_info', JSON.stringify(uploadInfo)); + +// 调用上传接口 +const response = await fetch('/api/v2/documents/upload', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData +}); +``` + +**2. 后端需要额外修改的位置** + +##### 2.1 修改 `contract_server.py` 的 `process_contract()` 方法 + +在约 L455 处,优先使用用户指定的类型: + +```python +# 确定合同专属子类型(用于精准加载规则) +# 优先级:用户指定 > 文件名检测 +_attribute_type = None + +# 1. 尝试从 upload_info 获取用户指定类型(需要传入) +# if hasattr(self, 'upload_info') and self.upload_info: +# _attribute_type = self.upload_info.get('attribute_type') + +# 2. 文件名自动检测(当前已实现) +if not _attribute_type: + _attribute_type = self._detect_attribute_type(self.filename) + +if _attribute_type: + log.document.info( + f"[GraphRAG] doc={self.document_id} 合同子类型: {_attribute_type} " + f"({'用户指定' if hasattr(self, 'upload_info') and self.upload_info and self.upload_info.get('attribute_type') == _attribute_type else '文件名检测'})" + ) +``` + +##### 2.2 修改 `tasks.py` 的 `process_contract_markdown()` 任务 + +在约 L1092 处,将 `attribute_type` 传递给 ContractMarkdownServer: + +```python +# 获取用户指定的合同类型(如果有的话) +attribute_type = upload_info.get("attribute_type") + +# 创建处理服务 +contract_server = ContractMarkdownServer( + document_id=document_id, + file_path=temp_path, + filename=filename, + template_id=template_id, + user_area=user_area, + user_role=user_role, + attribute_type=attribute_type, # 👈 新增参数 +) +``` + +##### 2.3 修改 `ContractMarkdownServer` 类 + +**文件**: `services/documents/v2/contract_markdown_server.py` + +```python +class ContractMarkdownServer: + def __init__( + self, + ... + attribute_type: Optional[str] = None, # 新增参数 + ): + ... + self.attribute_type = attribute_type + + async def process_contract(self): + ... + # 在调用 ContractServer.extract() 时传递 attribute_type + # (具体实现取决于 ContractMarkdownServer 如何调用 ContractServer) +``` + +### API 接口说明 + +#### 上传文档接口 + +**URL**: `POST /api/v2/documents/upload` + +**Content-Type**: `multipart/form-data` + +**参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| `file` | File | 是 | 上传的文件(PDF/Word) | +| `upload_info` | String(JSON) | 否 | 上传信息 JSON 字符串 | +| `attachments` | File[] | 否 | 附件文件列表 | + +**upload_info 结构**: + +```json +{ + "type_id": 1, // 文档类型ID:1=合同,2=案卷 + "document_number": "HT2024001", // 合同编号(可选) + "attribute_type": "技术", // 合同专属类型(可选,待后端支持) + "evaluation_level": "普通", // 评查级别(可选) + "is_test_document": true, // 是否测试文档(可选) + "remark": "备注信息" // 备注(可选) +} +``` + +**响应示例**: + +```json +{ + "success": true, + "result": { + "document_id": 2751, + "document_number": "HT2024001", + "file_name": "技术合同(去空格).docx", + "file_url": "documents/.../xxx.docx", + "status": "Queued", + "type_id": 1, + "doc_type": "HT", + "doc_type_description": "合同", + "api_version": "v2", + "is_test_document": true, + "remark": "", + "evaluation_level": "普通" + } +} +``` + +### 可用的合同类型值(attribute_type) + +前端下拉选项建议: + +```javascript +const CONTRACT_TYPES = [ + { value: "通用", label: "通用合同" }, + { value: "技术", label: "技术合同" }, + { value: "租赁", label: "租赁合同" }, + { value: "买卖", label: "买卖合同" }, + { value: "服务", label: "服务合同" }, + { value: "委托", label: "委托合同" }, + { value: "建设工程", label: "建设工程合同" }, + { value: "培训", label: "培训合同" }, + { value: "赠与", label: "赠与合同" }, + { value: "运输", label: "运输合同" }, + { value: "仓储", label: "仓储合同" }, + { value: "合作", label: "合作合同" }, + { value: "承揽", label: "承揽合同" } +]; +``` + +## 验证方式 + +### 1. 查看日志确认类型检测 + +上传文档后,查看日志确认合同类型识别: + +``` +[DOCUMENT] [GraphRAG] doc=2751 合同子类型: 技术 +``` + +### 2. 查看规则加载情况 + +``` +[EXTRACTION] [RAG Rules] GROUP路径加载 197 个评查点 (过滤: 通用+技术), 子类型分布: {...} +``` + +- **未指定类型**: 加载 816 个评查点(所有类型) +- **指定类型**: 加载 ~197 个评查点(通用+指定类型) + +### 3. 评查点过滤验证 + +某些专属评查点会根据类型过滤: + +| 评查点 | 类型 | 行为 | +|--------|------|------| +| EP-089 技术内容-技术方案 | 技术 | 仅技术合同加载 | +| EP-091 技术内容-技术指标 | 技术 | 仅技术合同加载 | +| EP-020 违约责任条款完整性 | 通用 | 所有合同都加载 | + +## 待实现功能(需后端额外开发) + +| 功能 | 状态 | 说明 | +|------|------|------| +| 文件名自动检测 | ✅ 已实现 | 后端从文件名关键词自动识别 | +| 前端手动指定 | ⚠️ 待实现 | 需修改 `contract_server.py` 和 `tasks.py` | +| 用户历史记录 | ❌ 未规划 | 记录用户常用合同类型,自动填充 | +| AI 智能推荐 | ❌ 未规划 | 根据文档内容推荐合同类型 | + +## 联系方式 + +如有疑问,请联系后端开发团队。 diff --git a/docs/开发规范手册.md b/docs/开发规范手册.md new file mode 100644 index 0000000..c3d0e66 --- /dev/null +++ b/docs/开发规范手册.md @@ -0,0 +1,1442 @@ +# 中国烟草AI合同及卷宗审核系统 - 开发规范手册 + +## 目录 + +1. [项目概述](#1-项目概述) +2. [技术栈规范](#2-技术栈规范) +3. [项目结构规范](#3-项目结构规范) +4. [TypeScript代码规范](#4-typescript代码规范) +5. [React组件规范](#5-react组件规范) +6. [样式规范](#6-样式规范) +7. [API调用规范](#7-api调用规范) +8. [路由开发规范](#8-路由开发规范) +9. [安全规范](#9-安全规范) +10. [Git提交规范](#10-git提交规范) +11. [环境变量规范](#11-环境变量规范) +12. [注释规范](#12-注释规范) +13. [错误处理规范](#13-错误处理规范) +14. [命名规范速查表](#14-命名规范速查表) + +--- + +## 1. 项目概述 + +### 1.1 项目简介 + +本项目是中国烟草AI合同及卷宗审核系统,采用 Remix (React) + TypeScript 构建,提供智能文档审查、风险评估和合规检查功能。 + +### 1.2 核心命令 + +```bash +# 开发 +npm run dev # 启动开发服务器 (端口 5173) +npm run typecheck # TypeScript 类型检查 +npm run lint # ESLint 检查 + +# 构建 +npm run build # 生产构建 +npm run build:production:multi # 多实例生产构建 + +# 部署 +npm start # 单实例生产启动 +npm run start:pm2:multi # PM2 多实例启动 +``` + +--- + +## 2. 技术栈规范 + +### 2.1 核心技术 + +| 技术 | 版本 | 用途 | +|------|------|------| +| Remix | ^2.16.2 | React 全栈框架 | +| React | ^18.2.0 | UI 库 | +| TypeScript | ^5.x | 类型系统 | +| Vite | ^5.x | 构建工具 | +| Tailwind CSS | ^3.4 | 样式框架 | +| Ant Design | ^6.0 | UI 组件库 | +| Axios | ^1.9 | HTTP 客户端 | +| Remixicon | 本地化 | 图标库 | + +### 2.2 路径别名 + +```json +// tsconfig.json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + } + } +} +``` + +**导入示例**: +```typescript +import { Button } from '~/components/ui/Button'; // ✅ 正确 +import { Button } from '../components/ui/Button'; // ❌ 不推荐 +``` + +--- + +## 3. 项目结构规范 + +### 3.1 目录结构 + +``` +app/ +├── api/ # API 层 (服务端调用封装) +│ ├── axios-client.ts # Axios 核心客户端 +│ ├── postgrest-client.ts # PostgREST API 封装 +│ ├── login/ # 登录认证 API +│ │ ├── auth.server.ts # 会话管理 +│ │ ├── oauth-client.ts # OAuth2.0 客户端 +│ │ └── token-manager.server.ts # Token 管理 +│ ├── contracts/ # 合同相关 API +│ ├── cross-checking/ # 交叉评查 API +│ └── [feature]/ # 其他功能 API +│ +├── components/ # 组件目录 +│ ├── ui/ # 通用 UI 组件 (自包含设计) +│ │ ├── Button/ +│ │ │ ├── Button.tsx +│ │ │ ├── Button.css +│ │ │ └── index.ts +│ │ ├── Card/ +│ │ └── index.ts # 统一导出 +│ ├── layout/ # 布局组件 +│ │ ├── Layout.tsx +│ │ ├── Sidebar.tsx +│ │ └── Header.tsx +│ ├── reviews/ # 评查功能组件 +│ └── [feature]/ # 功能特定组件 +│ +├── routes/ # Remix 路由 (76个) +│ ├── _index.tsx # 首页 +│ ├── login.tsx # 登录页 +│ ├── callback.tsx # OAuth 回调 +│ ├── documents.tsx # 文档管理 +│ ├── cross-checking.tsx # 交叉评查 +│ └── api.*.tsx # API 路由 +│ +├── config/ # 配置文件 +│ └── api-config.ts # API 端口配置 +│ +├── styles/ # 样式文件 +│ ├── main.css # 主样式 +│ └── components/ # 组件样式 +│ ├── card.css +│ ├── sidebar.css +│ └── button.css +│ +├── types/ # 类型定义 +│ ├── document.ts # 文档相关类型 +│ ├── user.ts # 用户相关类型 +│ └── api.ts # API 相关类型 +│ +├── hooks/ # 自定义 Hooks +├── contexts/ # React Context +├── utils/ # 工具函数 +└── root.tsx # 应用根组件 +``` + +### 3.2 文件组织原则 + +| 目录 | 组织方式 | 说明 | +|------|----------|------| +| `api/` | 按功能模块 | 每个功能模块独立目录 | +| `components/` | 按组件类型 | UI 组件自包含 (tsx + css + index.ts) | +| `routes/` | 按路由 | 一个文件一个路由 | +| `types/` | 按领域 | 按业务领域分类 | +| `styles/` | 按组件 | 与组件对应 | + +--- + +## 4. TypeScript代码规范 + +### 4.1 类型定义规范 + +```typescript +// ✅ 1. 接口命名 - PascalCase +interface DocumentInfo { + id: string; + name: string; + path: string; + status: ProcessingStatus; + createdAt: string; +} + +// ✅ 2. Props 接口命名 - ComponentNameProps +interface ButtonProps { + children: React.ReactNode; + type?: 'primary' | 'default' | 'danger'; + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; + className?: string; + onClick?: (e: React.MouseEvent) => void; +} + +// ✅ 3. 状态类型 - 字符串字面量联合 +type ProcessingStatus = + | 'Waiting' + | 'Cutting' + | 'Extractioning' + | 'Evaluationing' + | 'Processed'; + +type UserRole = 'common' | 'developer' | 'admin'; + +// ✅ 4. 函数类型 +type ApiResponse = { + data: T; + status: number; + message?: string; +}; + +type ErrorResponse = { + error: string; + status: number; +}; + +// ✅ 5. 枚举定义 (仅在需要组合值时使用) +enum FileType { + CONTRACT = 'contract', + LICENSE = 'license', + OTHER = 'other' +} +``` + +### 4.2 类型使用原则 + +```typescript +// ✅ 优先使用 interface +interface UserInfo { + name: string; + email: string; +} + +// ✅ 需要合并时使用 type +type UserWithRole = UserInfo & { role: UserRole }; + +// ✅ 避免使用 any,使用 unknown +function handleData(data: unknown): void { + if (typeof data === 'string') { + console.log(data.toUpperCase()); + } +} + +// ✅ 使用 as const 冻结对象 +const STATUS_CONFIG = { + WAITING: { label: '等待中', color: 'gray' }, + SUCCESS: { label: '成功', color: 'green' }, +} as const; + +// ✅ 泛型约束 +function getProperty(obj: T, key: K): T[K] { + return obj[key]; +} +``` + +### 4.3 导入类型 + +```typescript +// ✅ 使用 type 导入仅类型的依赖 +import type { DocumentInfo } from '~/types/document'; +import type { UserRole } from '~/types/user'; + +// ✅ 混合导入 +import { Button } from '~/components/ui/Button'; // 值导入 +import type { ButtonProps } from '~/components/ui/Button'; // 类型导入 + +// ✅ 从同一模块导入值和类型 +import { useState, useEffect } from 'react'; // 运行时 +import type { Dispatch, SetStateAction } from 'react'; // 仅类型 +``` + +--- + +## 5. React组件规范 + +### 5.1 组件定义 + +```typescript +// ✅ 正确 - 使用函数声明 +export function Card({ + children, + title, + icon, + extra, + className = '' +}: CardProps) { + return ( +
+ {title && ( +
+
+ {icon && } + {title} +
+ {extra &&
{extra}
} +
+ )} +
{children}
+
+ ); +} + +// ❌ 错误 - 使用箭头函数 +export const Card = ({ children, title }: CardProps) => { ... }; +``` + +### 5.2 Hooks 使用顺序 + +```typescript +export function DocumentList({ documents }: DocumentListProps) { + // 1. State hooks (按依赖关系排序) + const [selectedIds, setSelectedIds] = useState([]); + const [filter, setFilter] = useState('all'); + const [isLoading, setIsLoading] = useState(false); + + // 2. Ref hooks + const containerRef = useRef(null); + const inputRef = useRef(null); + + // 3. Effect hooks (按依赖关系分组) + useEffect(() => { + // 数据获取 + fetchDocuments(); + }, []); + + useEffect(() => { + // 订阅/事件监听 + const handler = () => { ... }; + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); + }, []); + + // 4. 计算属性 (useMemo/useCallback) + const filteredDocuments = useMemo(() => { + return documents.filter(doc => { + if (filter === 'all') return true; + return doc.status === filter; + }); + }, [documents, filter]); + + const handleSelectAll = useCallback(() => { + setSelectedIds(documents.map(d => d.id)); + }, [documents]); + + // 5. 事件处理函数 + const handleSelect = (id: string) => { + setSelectedIds(prev => + prev.includes(id) + ? prev.filter(i => i !== id) + : [...prev, id] + ); + }; + + const handleDelete = async (id: string) => { + setIsLoading(true); + try { + await deleteDocument(id); + } finally { + setIsLoading(false); + } + }; + + // 6. Render + return ( +
+ {/* ... */} +
+ ); +} +``` + +### 5.3 组件 Props 规范 + +```typescript +// ✅ Props 接口定义 +interface ButtonProps { + children: React.ReactNode; + type?: ButtonType; + size?: ButtonSize; + disabled?: boolean; + loading?: boolean; + icon?: string; + className?: string; + onClick?: (e: React.MouseEvent) => void; +} + +// ✅ 带默认值的 props +interface CardProps { + title?: React.ReactNode; + icon?: string; + className?: string; + bodyClassName?: string; + children: React.ReactNode; +} + +// ✅ 提取子组件 Props +interface TableColumn { + key: keyof T | string; + title: string; + render?: (value: T[keyof T], record: T) => React.ReactNode; + width?: number | string; +} +``` + +### 5.4 自包含组件设计 + +每个 UI 组件应包含: + +``` +components/ +└── Button/ + ├── Button.tsx # 组件实现 + ├── Button.css # 组件样式 + └── index.ts # 导出 +``` + +```typescript +// Button/index.ts +export { Button } from './Button'; +export type { ButtonProps } from './Button'; +``` + +--- + +## 6. 样式规范 + +### 6.1 样式架构 + +项目采用 **Tailwind CSS + 自定义 CSS** 混合模式: + +```css +/* 1. Tailwind 工具类 (优先使用) */ +
+ +/* 2. 设计系统变量 */ +
+ +/* 3. 自定义 BEM 类 */ +
+
+
+
+``` + +### 6.2 设计系统变量 + +```css +/* app/root.tsx 或 main.css */ +:root { + /* 主色调 */ + --color-primary: #00684a; + --color-primary-hover: #005a3f; + --color-primary-light: rgba(0, 104, 74, 0.1); + + /* 状态色 */ + --color-success: #52c41a; + --color-warning: #faad14; + --color-error: #f5222d; + + /* 中性色 */ + --color-gray-50: #f8f9fa; + --color-gray-100: #f1f3f5; + --color-gray-200: #e9ecef; + --color-gray-300: #dee2e6; + --color-gray-400: #ced4da; + --color-gray-500: #adb5bd; + --color-gray-600: #868e96; + --color-gray-700: #495057; + --color-gray-800: #343a40; + --color-gray-900: #212529; +} +``` + +### 6.3 BEM 命名规范 + +```css +/* 组件块 */ +.card { } +.sidebar { } +.modal { } + +/* 元素 */ +.card-header { } +.card-title { } +.card-body { } +.card-footer { } +.sidebar-menu-item { } +.sidebar-menu-item.active { } + +/* 修饰符 */ +.card--compact { } +.button--primary { } +.button--disabled { } +``` + +### 6.4 Tailwind 常用配置 + +```css +/* 间距 */ +p-4 = 16px, p-5 = 20px, p-6 = 24px +mb-4 = 16px, mb-6 = 24px + +/* 圆角 */ +rounded = 4px, rounded-md = 6px, rounded-lg = 8px + +/* 阴影 */ +shadow-sm, shadow-md, shadow-lg + +/* 过渡 */ +transition-all duration-200 ease-in-out +``` + +### 6.5 RemixIcon 图标使用 + +```tsx +// 基本使用 + + + + +// 尺寸控制 + + + +// 结合样式 + + + +// 在按钮中使用 + +``` + +**⚠️ 重要 - CSS 隔离时的图标兼容**: + +```css +/* 当使用 CSS 隔离时,必须添加图标例外规则 */ +.my-isolated-container * { + font-family: inherit !important; +} +.my-isolated-container [class^="ri-"], +.my-isolated-container [class*=" ri-"], +.my-isolated-container i[class^="ri-"], +.my-isolated-container i[class*=" ri-"] { + font-family: 'remixicon' !important; + font-style: normal !important; + font-weight: normal !important; + line-height: 1 !important; +} +``` + +--- + +## 7. API调用规范 + +### 7.1 API 分层架构 + +``` +┌─────────────────────────────────────────────┐ +│ 路由层 (routes/*.tsx) │ +│ loader / action 函数 │ +└────────────────────┬────────────────────────┘ + │ +┌────────────────────▼────────────────────────┐ +│ 业务 API 层 (app/api/[feature]/) │ +│ 封装业务逻辑的 API 函数 │ +└────────────────────┬────────────────────────┘ + │ +┌────────────────────▼────────────────────────┐ +│ PostgREST 客户端 (postgrest-client.ts) │ +│ 处理 PostgREST 特定参数 │ +└────────────────────┬────────────────────────┘ + │ +┌────────────────────▼────────────────────────┐ +│ Axios 核心 (axios-client.ts) │ +│ 请求拦截、响应拦截、JWT 处理 │ +└─────────────────────────────────────────────┘ +``` + +### 7.2 Axios 客户端使用 + +```typescript +// app/api/axios-client.ts - 已配置拦截器 + +import { get, post, put, del } from '~/api/axios-client'; + +// GET 请求 +const data = await get('/admin/users/1'); + +// POST 请求 +const result = await post('/admin/documents', { + name: '合同.pdf', + type: 'contract' +}); + +// PUT 请求 +const updated = await put('/admin/documents/123', { + name: '新名称.pdf' +}); + +// DELETE 请求 +await del('/admin/documents/123'); +``` + +### 7.3 PostgREST 客户端使用 + +```typescript +// app/api/postgrest-client.ts + +import { + postgrestGet, + postgrestPost, + postgrestPatch, + postgrestDelete +} from '~/api/postgrest-client'; + +// 查询参数 +const result = await postgrestGet('/documents', { + select: 'id,name,status', + eq: { status: 'Processed' }, + order: 'created_at.desc', + limit: 10 +}); + +// 插入数据 +await postgrestPost('/documents', { + name: '新文档', + status: 'Waiting' +}); + +// 更新数据 +await postgrestPatch('/documents', { + id: 123, + name: '更新后的名称' +}); + +// 删除数据 +await postgrestDelete('/documents', 123); +``` + +### 7.4 业务 API 封装模式 + +```typescript +// app/api/contracts/documents.ts + +import { postgrestGet, postgrestPost } from '~/api/postgrest-client'; +import type { Document, DocumentFilters } from '~/types/document'; + +/** + * 获取文档列表 + */ +export async function getDocuments( + filters?: DocumentFilters +): Promise { + const params = { + select: 'id,name,path,status,created_at', + order: 'created_at.desc', + limit: filters?.limit ?? 20, + offset: filters?.offset ?? 0, + ...(filters?.status && { eq: { status: filters.status } }) + }; + + const result = await postgrestGet('/documents', params); + + if ('error' in result) { + throw new Error(result.error); + } + + return result.data; +} + +/** + * 创建文档 + */ +export async function createDocument( + data: Omit +): Promise { + const result = await postgrestPost('/documents', data); + + if ('error' in result) { + throw new Error(result.error); + } + + return result.data; +} +``` + +### 7.5 Loader 中使用 API + +```typescript +// routes/documents.tsx + +import { type LoaderFunctionArgs } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { getDocuments } from "~/api/contracts/documents"; +import { getUserSession } from "~/api/login/auth.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + // 1. 获取用户会话 + const { userInfo, frontendJWT } = await getUserSession(request); + + // 2. 解析查询参数 + const url = new URL(request.url); + const status = url.searchParams.get('status') ?? undefined; + const page = parseInt(url.searchParams.get('page') ?? '1', 10); + + // 3. 获取数据 + const documents = await getDocuments({ + status, + limit: 20, + offset: (page - 1) * 20 + }); + + // 4. 返回数据 + return Response.json({ + documents, + userInfo, + pagination: { + page, + total: documents.length + } + }); +} + +export default function DocumentsPage() { + const { documents, userInfo } = useLoaderData(); + + return ( + +
+ {/* ... */} +
+
+ ); +} +``` + +--- + +## 8. 路由开发规范 + +### 8.1 路由文件命名 + +| 类型 | 命名格式 | 示例 | +|------|----------|------| +| 页面路由 | kebab-case | `documents.tsx`, `user-profile.tsx` | +| 嵌套路由 | `_` 前缀 | `documents_.list.tsx`, `documents_.detail.$id.tsx` | +| 动态路由 | `$param` | `reviews.$id.tsx`, `contract-template.detail.$id.tsx` | +| API 路由 | `api.` 前缀 | `api.documents.tsx`, `api.users.$id.tsx` | + +### 8.2 标准路由结构 + +```typescript +// routes/documents.tsx + +import { + type MetaFunction, + type LoaderFunctionArgs, + type ActionFunctionArgs +} from "@remix-run/node"; +import { + useLoaderData, + useFetcher, + useNavigation +} from "@remix-run/react"; + +// ============ 1. Meta 配置 ============ +export const meta: MetaFunction = () => [ + { title: "文档管理 - 合同审核系统" }, + { name: "description", content: "文档管理和评查功能" } +]; + +// ============ 2. Links 配置 ============ +import styles from '~/styles/pages/documents.css?url'; + +export function links() { + return [ + { rel: "stylesheet", href: styles } + ]; +} + +// ============ 3. Handle 配置 ============ +export const handle = { + hideBreadcrumb: false, // 显示面包屑 + title: "文档管理" +}; + +// ============ 4. Loader 函数 ============ +export async function loader({ request }: LoaderFunctionArgs) { + // 获取用户会话 + const { getUserSession } = await import("~/api/login/auth.server"); + const { userInfo, frontendJWT } = await getUserSession(request); + + // 获取数据 + const documents = await getDocuments(request); + + return Response.json({ + documents, + userInfo, + frontendJWT + }); +} + +// ============ 5. Action 函数 ============ +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent"); + + switch (intent) { + case "delete": + const id = formData.get("id") as string; + await deleteDocument(id); + return Response.json({ success: true, action: "delete" }); + + case "batch-delete": + const ids = formData.getAll("ids") as string[]; + await batchDeleteDocuments(ids); + return Response.json({ success: true, action: "batch-delete" }); + + default: + return Response.json({ error: "未知操作" }, { status: 400 }); + } +} + +// ============ 6. 组件实现 ============ +export default function DocumentsPage() { + const { documents, userInfo } = useLoaderData(); + const navigation = useNavigation(); + const fetcher = useFetcher(); + + const isSubmitting = navigation.state === "submitting"; + + return ( + +
+ 新建} + /> + + +
+
+ ); +} + +// ============ 7. ErrorBoundary ============ +import { useRouteError, isRouteErrorResponse } from "@remix-run/react"; + +export function ErrorBoundary() { + const error = useRouteError(); + + if (isRouteErrorResponse(error)) { + return ( +
+

{error.status} - {error.statusText}

+

{error.data}

+
+ ); + } + + return ( +
+

发生了错误

+

{error instanceof Error ? error.message : "未知错误"}

+
+ ); +} +``` + +### 8.3 嵌套路由布局 + +``` +routes/ +├── documents.tsx # 父路由 - 布局 +├── documents.list.tsx # 列表页面 (使用 documents.tsx 布局) +├── documents.create.tsx # 创建页面 (使用 documents.tsx 布局) +└── documents_.detail.$id.tsx # 详情页面 (不使用父布局,下划线跳过) +``` + +```typescript +// routes/documents.tsx - 布局组件 +export default function DocumentsLayout() { + const { userInfo } = useLoaderData(); + + return ( + +
+ +
+ {/* 子路由内容 */} +
+
+
+ ); +} +``` + +### 8.4 API 路由 + +```typescript +// routes/api.documents.tsx + +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { getDocuments, createDocument } from "~/api/contracts/documents"; + +// GET - 获取列表 +export async function loader({ request }: LoaderFunctionArgs) { + const documents = await getDocuments(); + return Response.json({ data: documents }); +} + +// POST - 创建 +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return Response.json({ error: "方法不允许" }, { status: 405 }); + } + + const data = await request.json(); + const document = await createDocument(data); + + return Response.json({ data: document }, { status: 201 }); +} +``` + +--- + +## 9. 安全规范 + +### 9.1 敏感信息处理 + +| 信息类型 | 处理方式 | 错误做法 | +|----------|----------|----------| +| `JWT_SECRET` | 环境变量,绝对不提交 | 硬编码在代码中 | +| `OAUTH_CLIENT_SECRET` | 服务端环境变量 | 使用 `NEXT_PUBLIC_` 前缀 | +| API 密钥 | 服务端配置 | 暴露到客户端 | +| 用户密码 | 不存储,MD5 传输 | 明文传输或存储 | + +### 9.2 环境变量命名 + +```bash +# ✅ 客户端安全变量 (可被客户端代码访问) +NEXT_PUBLIC_API_BASE_URL=http://10.79.97.17:8000 +NEXT_PUBLIC_DOCUMENT_URL=http://10.76.244.156:9000/docauditai/ + +# ❌ 服务端专用变量 (不可被客户端访问) +JWT_SECRET=your-secret-key-here # ❌ NEXT_PUBLIC_JWT_SECRET +OAUTH_CLIENT_SECRET=your-client-secret # ❌ NEXT_PUBLIC_OAUTH_CLIENT_SECRET +``` + +### 9.3 认证白名单 + +```typescript +// app/api/login/auth.server.ts + +// 不需要认证的路径 +const PUBLIC_PATHS = [ + '/login', + '/callback', + '/oauth/authorize' +]; + +// 401 错误容忍的路径 (不触发登出) +const ERROR_TOLERANT_PATHS = [ + '/admin/statistics/top-error-points', + '/admin/statistics/top-risk-users' +]; +``` + +### 9.4 Cookie 安全配置 + +```typescript +export const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__lgsession", + httpOnly: true, // 防止 XSS 攻击 + path: "/", + sameSite: "lax", // CSRF 保护 + secrets: [process.env.JWT_SECRET ?? "default-secret"], + maxAge: 60 * 60 * 8, // 8 小时 + secure: process.env.NODE_ENV === "production" + } +}); +``` + +### 9.5 文件路径安全 + +```typescript +// 防止路径遍历攻击 +import path from "path"; + +function safeFilePath(userPath: string): string { + const normalized = path.normalize(userPath); + const baseDir = "/safe/uploads/directory"; + + // 确保路径在安全目录内 + if (!normalized.startsWith(baseDir)) { + throw new Error("非法文件路径"); + } + + return normalized; +} +``` + +--- + +## 10. Git提交规范 + +### 10.1 提交信息格式 + +``` +(): + + + +