fix: restore reviews detail layout and leaudit data wiring

This commit is contained in:
wren
2026-05-06 17:31:48 +08:00
parent 63bf3f56ce
commit 796ce90e32
8 changed files with 1652 additions and 607 deletions
@@ -1,8 +1,8 @@
/**
* 右栏 · 详情面板
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)+ 底部操作栏
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)底部操作栏
*/
import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
import type { ReviewPoint, CharPosition, PdfBboxHighlight } from '../ReviewPointsList';
import { ReviewPointDetailCard } from './ReviewPointDetailCard';
import { FileInfoPanel } from './FileInfoPanel';
@@ -32,10 +32,11 @@ interface DetailPanelProps {
onTabChange: (tab: TabKey) => void;
activeReviewPoint: ReviewPoint | null;
reviewPoints: ReviewPoint[];
detailMode?: 'legacy' | 'leaudit';
fileInfo: FileInfoData;
reviewInfo: ReviewInfoData;
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void;
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
onConfirmResults: () => void;
onDownload: () => void;
auditStatus?: number;
@@ -45,63 +46,191 @@ interface DetailPanelProps {
showComparisonButton?: boolean;
}
type ExtractedFieldValue = {
value?: unknown;
page?: number | string;
};
function isValidQuad(value: unknown): value is [number, number, number, number] {
return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item));
}
function hasNonZeroQuad(value: [number, number, number, number]): boolean {
return value.some(item => item !== 0);
}
function getFieldRawValue(value: ReviewPoint['content'][string]): unknown {
if (value == null) return null;
if (typeof value === 'object' && 'value' in value) {
return (value as { value?: unknown }).value ?? null;
}
return value;
}
function getFieldDisplayText(rawValue: unknown): string {
if (rawValue == null) return '缺失';
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') return String(rawValue);
try {
return JSON.stringify(rawValue);
} catch {
return String(rawValue);
}
}
function getFieldHighlightText(rawValue: unknown): string | undefined {
if (rawValue == null) return undefined;
if (typeof rawValue !== 'string' && typeof rawValue !== 'number' && typeof rawValue !== 'boolean') return undefined;
const text = String(rawValue).trim();
return text ? String(rawValue) : undefined;
}
function getFieldPage(point: ReviewPoint, key: string, value: ReviewPoint['content'][string]): number | undefined {
const contentPage = point.contentPage?.[key];
const parsedContentPage = Number(contentPage);
if (Number.isFinite(parsedContentPage) && parsedContentPage > 0) return parsedContentPage;
const inlinePage = typeof value === 'object' && value && 'page' in value
? Number((value as { page?: unknown }).page)
: NaN;
if (Number.isFinite(inlinePage) && inlinePage > 0) return inlinePage;
const pageNum = point.fieldPositions?.[key]?.page_num;
if (typeof pageNum === 'number' && Number.isFinite(pageNum) && pageNum >= 0) return pageNum + 1;
return undefined;
}
function getFieldConfidence(point: ReviewPoint, key: string): number | undefined {
const confidence = point.fieldPositions?.[key]?.confidence;
if (typeof confidence !== 'number' || !Number.isFinite(confidence)) return undefined;
return confidence;
}
function getFieldBboxHighlight(point: ReviewPoint, key: string, page?: number): PdfBboxHighlight | undefined {
const fieldPosition = point.fieldPositions?.[key];
if (!fieldPosition || !isValidQuad(fieldPosition.bbox) || !isValidQuad(fieldPosition.page_box)) return undefined;
if (!hasNonZeroQuad(fieldPosition.bbox) || !hasNonZeroQuad(fieldPosition.page_box)) return undefined;
return {
fieldKey: key,
bbox: [...fieldPosition.bbox],
pageBox: [...fieldPosition.page_box],
pageNum: fieldPosition.page_num,
page,
confidence: fieldPosition.confidence,
matchMethod: fieldPosition.match_method,
};
}
function ExtractedFieldsPanel({
reviewPoints,
onFieldClick,
}: {
reviewPoints: ReviewPoint[];
onFieldClick: (pointId: string | number, page: number) => void;
onFieldClick: (pointId: string | number, page?: number, value?: string, bboxHighlight?: PdfBboxHighlight) => void;
}) {
const fields: Array<{ key: string; value: string; page?: number; pointName: string; pointId: string | number }> = [];
const handleFieldNavigate = (pointId: string, page?: number, value?: string, bboxHighlight?: PdfBboxHighlight) => {
if (!page) return;
const selectedText = typeof window !== 'undefined' ? window.getSelection?.()?.toString().trim() : '';
if (selectedText) return;
onFieldClick(pointId, page, value, bboxHighlight);
};
const fields: Array<{
key: string;
displayValue: string;
highlightValue?: string;
isMissing: boolean;
confidence?: number;
page?: number;
pointId: string | number;
bboxHighlight?: PdfBboxHighlight;
}> = [];
reviewPoints.forEach((p) => {
if (p.content) {
Object.entries(p.content).forEach(([key, data]) => {
const fieldData = (data && typeof data === 'object' ? data : {}) as ExtractedFieldValue & { text?: string };
const val = fieldData.value;
const page = fieldData.page;
const text = typeof val === 'object' && val !== null
? ('text' in (val as Record<string, unknown>) ? String((val as Record<string, unknown>).text || '') : JSON.stringify(val))
: String(val || '');
Object.entries(p.content).forEach(([key, rawValue]) => {
const fieldRawValue = getFieldRawValue(rawValue);
const displayValue = getFieldDisplayText(fieldRawValue);
const highlightValue = getFieldHighlightText(fieldRawValue);
const page = getFieldPage(p, key, rawValue);
const confidence = getFieldConfidence(p, key);
const bboxHighlight = getFieldBboxHighlight(p, key, page);
fields.push({
key,
value: text,
page: page ? Number(page) : undefined,
pointName: p.pointName,
displayValue,
highlightValue,
isMissing: fieldRawValue == null,
confidence,
page,
pointId: p.id,
bboxHighlight,
});
});
}
});
return (
<div className="p-3">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
<span className="font-mono normal-case text-[10.5px]">{fields.length}</span>
<div className="h-full flex flex-col min-h-0">
<div className="shrink-0 px-4 py-3 border-b border-slate-100">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-[14px] font-semibold text-slate-900">
<span className="font-mono text-[11px] text-slate-400">{fields.length}</span>
</h3>
<div className="text-[11px] text-slate-400"> · </div>
</div>
</div>
{fields.length === 0 ? (
<div className="text-center py-6 text-[12px] text-slate-400"></div>
<div className="text-center py-10 text-[12px] text-slate-400"></div>
) : (
<div className="space-y-2">
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim">
{fields.map((f, i) => (
<button
<div
key={`${f.key}-${i}`}
type="button"
className={`w-full border border-slate-200 rounded-md text-left hover:bg-slate-50 transition p-2.5 ${f.page ? 'cursor-pointer' : 'cursor-default opacity-70'}`}
onClick={() => f.page && onFieldClick(f.pointId, f.page)}
role={f.page ? 'button' : undefined}
tabIndex={f.page ? 0 : undefined}
className={`w-full flex items-start gap-2 px-3 py-2 border-b border-slate-100 text-left transition ${f.page ? 'cursor-pointer hover:bg-slate-50' : 'cursor-default opacity-80'}`}
onClick={() => handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight)}
onKeyDown={(event) => {
if (!f.page) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight);
}
}}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-slate-500 truncate font-medium">{f.key}</span>
{f.page && <span className="text-[10.5px] text-slate-400 shrink-0">P{f.page}</span>}
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium text-slate-800 leading-5 break-words">{f.key}</div>
<div className="mt-0.5 select-text cursor-text">
{f.isMissing ? (
<span className="text-[11px] text-red-500"></span>
) : (
<span className="text-[11px] text-slate-500 leading-5 whitespace-pre-wrap break-words">{f.displayValue}</span>
)}
</div>
</div>
{f.value && <div className="text-[12px] text-slate-700 mt-1 leading-relaxed line-clamp-2">{f.value}</div>}
<div className="text-[10px] text-slate-400 mt-0.5">{f.pointName}</div>
</button>
<div className="shrink-0 text-right min-w-[56px] pt-0.5">
<div className={`font-mono text-[10.5px] ${f.confidence == null ? 'text-slate-400' : f.confidence < 0.8 ? 'text-orange-600' : 'text-slate-500'}`}>
{f.confidence == null ? '-' : `${Math.round(f.confidence * 100)}%`}
</div>
{f.page ? (
<button
type="button"
className="mt-0.5 text-[10px] text-[#00684a] hover:underline"
onClick={(event) => {
event.stopPropagation();
handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight);
}}
>
p.{f.page}
</button>
) : (
<div className="mt-0.5 text-[10px] text-slate-300">-</div>
)}
</div>
</div>
))}
</div>
)}
@@ -120,6 +249,7 @@ export function DetailPanel({
onTabChange,
activeReviewPoint,
reviewPoints,
detailMode = 'legacy',
fileInfo,
reviewInfo,
onReviewPointSelect,
@@ -186,6 +316,7 @@ export function DetailPanel({
onReviewPointSelect={onReviewPointSelect}
onStatusChange={onStatusChange}
fileFormat={fileFormat}
detailMode={detailMode}
/>
</div>
) : (
@@ -199,8 +330,8 @@ export function DetailPanel({
{activeTab === 'fields' && (
<ExtractedFieldsPanel
reviewPoints={reviewPoints}
onFieldClick={(pointId, page) => {
onReviewPointSelect(pointId, page);
onFieldClick={(pointId, page, value, bboxHighlight) => {
onReviewPointSelect(pointId, page, undefined, value, bboxHighlight);
}}
/>
)}
@@ -5,13 +5,17 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { toastService } from '~/components/ui/Toast';
import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
import type { ReviewPoint, CharPosition, PdfBboxHighlight } from '../ReviewPointsList';
import { CorporateInfoModal } from '../../corporate-information';
import type { BusinessInfoResult, DishonestyResult } from '../../corporate-information';
import { queryCompanyInfo } from '~/api/corporate-information/qichacha';
interface ReviewPointDetailCardProps {
reviewPoint: ReviewPoint;
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void;
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
fileFormat?: string;
detailMode?: 'legacy' | 'leaudit';
}
// ── 比较方法映射 ──
@@ -33,6 +37,58 @@ const getRuleTypeText = (type?: string): string => {
return ruleTypeMap[type] || type;
};
function normalizeActionContent(actionContent?: string | string[]): string {
if (typeof actionContent === 'string') return actionContent;
if (Array.isArray(actionContent)) {
return actionContent
.map(item => typeof item === 'string' ? item : JSON.stringify(item))
.filter(Boolean)
.join('\n');
}
return '';
}
function getLeauditNote(reviewPoint: ReviewPoint): string {
return reviewPoint.editAuditStatusMessage || normalizeActionContent(reviewPoint.actionContent) || reviewPoint.suggestion || '';
}
function getLeauditRawFieldValue(value: ReviewPoint['content'][string]): unknown {
if (value == null) return undefined;
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
if (typeof value === 'object' && 'value' in value) return value.value;
return value;
}
function isValidQuad(value: unknown): value is [number, number, number, number] {
return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item));
}
function getLeauditTargetPage(reviewPoint: ReviewPoint, fieldKey: string): number | undefined {
const contentPage = reviewPoint.contentPage?.[fieldKey];
const parsedContentPage = Number(contentPage);
if (Number.isFinite(parsedContentPage) && parsedContentPage > 0) return parsedContentPage;
const pageNum = reviewPoint.fieldPositions?.[fieldKey]?.page_num;
if (typeof pageNum === 'number' && Number.isFinite(pageNum) && pageNum >= 0) return pageNum + 1;
return undefined;
}
function getLeauditBboxHighlight(reviewPoint: ReviewPoint, fieldKey: string, page?: number): PdfBboxHighlight | undefined {
const fieldPosition = reviewPoint.fieldPositions?.[fieldKey];
if (!fieldPosition || !isValidQuad(fieldPosition.bbox) || !isValidQuad(fieldPosition.page_box)) return undefined;
return {
fieldKey,
bbox: [...fieldPosition.bbox] as [number, number, number, number],
pageBox: [...fieldPosition.page_box] as [number, number, number, number],
pageNum: fieldPosition.page_num,
page,
confidence: fieldPosition.confidence,
matchMethod: fieldPosition.match_method,
};
}
// ── Tooltip 系统 ──
let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false };
function TooltipPortal() {
@@ -223,7 +279,7 @@ function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] {
}
// ── renderOtherRule ──
function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void }) {
function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void }) {
const fieldKey = rule.fieldKey;
const fieldValue = rule.fieldValue;
const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false);
@@ -273,7 +329,7 @@ function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Mer
// ── renderConsistencyRule ──
type ChainItem = { field: string; data: { key: string; page: number; value: string; char_positions?: CharPosition[] }; res: boolean; compareMethod?: string };
function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void }) {
function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void }) {
if (reviewPoint.result !== (rule.res as boolean)) return null;
const config = rule.config as { logic?: string; pairs?: Array<{ sourceField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; res: boolean; compareMethod?: string }>; selectedFields?: string[] } | undefined;
if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) return null;
@@ -389,7 +445,7 @@ function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rul
}
// ── renderModelRule ──
function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) {
function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void; fileFormat?: string }) {
const config = rule.config as { model?: string; fields?: Record<string, { page: number | string; value: string; char_positions?: CharPosition[]; res?: boolean }>; ai_suggestion?: { summary?: string; analysis?: { failure_reason?: string; solution_approach?: string; rule_understanding?: string }; suggestions?: Record<string, { reason: string; source: { page: number | null; type: string; field: string | null }; priority: string; confidence: number; suggested_value: string | null }>; generated_at?: string }; message?: string; res?: boolean } | undefined;
if (config?.res !== reviewPoint.result) return null;
@@ -434,167 +490,685 @@ function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }:
return <>{fieldElements}</>;
}
function RenderGenericRule({
rule,
reviewPoint,
onReviewPointSelect,
}: {
rule: Record<string, unknown>;
// ── Main Component ──
function stringifyUnknown(value: unknown): string {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (value == null) return '';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function getLeauditFieldText(value: ReviewPoint['content'][string]): string {
if (value == null) return '未填写';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (typeof value === 'object' && value && 'value' in value) {
return stringifyUnknown(value.value);
}
return stringifyUnknown(value);
}
function parseMissingArrayString(rawText: string): string[] {
const match = rawText.match(/missing[\w-]*\s*:\s*\[([\s\S]*?)\]/i) || rawText.match(/\[([\s\S]*?)\]/);
if (!match) return [];
const innerText = match[1].trim();
if (!innerText) return [];
const quotedItems = Array.from(innerText.matchAll(/['"]([^'"]+)['"]/g))
.map(item => item[1].trim())
.filter(Boolean);
if (quotedItems.length > 0) {
return quotedItems;
}
return innerText
.split(',')
.map(item => item.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean);
}
function getLeauditMissingItems(reviewPoint: ReviewPoint): string[] {
const textCandidates = [
reviewPoint.skipReason,
typeof reviewPoint.evaluatedPointResultsLog?.skip_reason === 'string' ? reviewPoint.evaluatedPointResultsLog.skip_reason : '',
reviewPoint.suggestion,
].filter(Boolean) as string[];
for (const text of textCandidates) {
if (!/missing[\w-]*\s*:/i.test(text)) continue;
const items = parseMissingArrayString(text);
if (items.length > 0) return items;
}
return [];
}
function normalizeAiResponseItems(value: unknown, options?: { hideNone?: boolean }): string[] {
const hideNone = options?.hideNone === true;
if (Array.isArray(value)) {
return value
.map(item => String(item).trim())
.filter(item => item && (!hideNone || item !== '无'));
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return [];
if (hideNone && trimmed === '无') return [];
return [trimmed];
}
return [];
}
function LeauditReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange }: {
reviewPoint: ReviewPoint;
onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void;
onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void;
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
}) {
const config = (rule.config && typeof rule.config === 'object' ? rule.config : {}) as Record<string, unknown>;
const detail = (config.detail && typeof config.detail === 'object' ? config.detail : {}) as Record<string, unknown>;
const fieldNames = Array.isArray(detail.fields)
? detail.fields.map((field) => String(field))
: Array.isArray((config as any).fields)
? (config as any).fields.map((field: unknown) => String(field))
: [];
const passed = typeof rule.res === 'boolean' ? rule.res : reviewPoint.result === true;
const reasonCandidates = passed
? [config.reason, detail.reason, reviewPoint.passMessage]
: [config.reason, detail.reason, reviewPoint.failMessage, reviewPoint.suggestion];
const reason = reasonCandidates.find((item) => typeof item === 'string' && item.trim()) as string | undefined;
const checkType = typeof config.check_type === 'string' ? config.check_type : '';
const primitiveType = typeof config.primitive_type === 'string' ? config.primitive_type : '';
const badgeText = checkType || primitiveType || '规则检查';
const [manualNote, setManualNote] = useState(() => getLeauditNote(reviewPoint));
const [corporateModalVisible, setCorporateModalVisible] = useState(false);
const [corporateCompanyName, setCorporateCompanyName] = useState('');
const [corporateLoading, setCorporateLoading] = useState(false);
const [corporateBusinessInfo, setCorporateBusinessInfo] = useState<BusinessInfoResult | null>(null);
const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState<DishonestyResult | null>(null);
const [corporateError, setCorporateError] = useState<string | null>(null);
const [corporateUpdatedAt, setCorporateUpdatedAt] = useState<string | null>(null);
const getFieldLocatorState = (fieldName: string) => {
const fieldData = reviewPoint.content?.[fieldName];
const page = fieldData?.page || reviewPoint.contentPage?.[fieldName];
const normalizedPage = page ? Number(page) : undefined;
const hasPage = !!(normalizedPage && Number.isFinite(normalizedPage));
const rawValue = fieldData?.value;
const normalizedValue =
typeof rawValue === 'string'
? rawValue.trim()
: rawValue == null
? ''
: String(rawValue);
useEffect(() => {
setManualNote(getLeauditNote(reviewPoint));
}, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]);
return {
fieldData,
normalizedPage: hasPage ? normalizedPage : undefined,
normalizedValue,
canLocate: hasPage || normalizedValue.length > 0,
};
const stages = Array.isArray(reviewPoint.evaluatedPointResultsLog?.stages)
? (reviewPoint.evaluatedPointResultsLog.stages as Array<Record<string, unknown>>)
: [];
const missingItems = getLeauditMissingItems(reviewPoint);
const legalBasisList = Array.isArray(reviewPoint.legalBasis)
? reviewPoint.legalBasis
: reviewPoint.legalBasis?.articles?.map(item => typeof item === 'string' ? item : (item.name || item.content || stringifyUnknown(item))) || [];
const riskLabelMap: Record<string, { cls: string; label: string }> = {
high: { cls: 'bg-red-50 text-red-700 border-red-200', label: '高风险' },
medium: { cls: 'bg-amber-50 text-amber-700 border-amber-200', label: '中风险' },
low: { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: '低风险' },
};
const riskMeta = riskLabelMap[reviewPoint.riskLevel || ''] || {
cls: 'bg-slate-100 text-slate-600 border-slate-200',
label: '未知风险',
};
const configConfidence = reviewPoint.evaluationConfig && typeof reviewPoint.evaluationConfig === 'object'
? reviewPoint.evaluationConfig.confidence
: undefined;
const confidencePct = typeof reviewPoint.confidence === 'number'
? `${Math.round(reviewPoint.confidence * 100)}%`
: typeof configConfidence === 'number'
? `${Math.round(configConfidence * 100)}%`
: null;
const isPass = reviewPoint.status === 'success' && reviewPoint.result === true;
const isWarning = reviewPoint.status === 'warning' || (reviewPoint.ruleStatus || '').startsWith('skipped_');
const statusChip = isPass
? { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', icon: 'ri-checkbox-circle-fill', label: '通过' }
: isWarning
? { cls: 'bg-amber-50 text-amber-700 border-amber-200', icon: 'ri-error-warning-fill', label: '提醒' }
: { cls: 'bg-red-50 text-red-700 border-red-200', icon: 'ri-close-circle-fill', label: '不通过' };
const summaryText = isPass
? (reviewPoint.passMessage || reviewPoint.suggestion || '校验通过')
: isWarning
? (reviewPoint.skipReason || reviewPoint.suggestion || '当前规则未执行或需人工关注')
: (reviewPoint.failMessage || reviewPoint.suggestion || '发现问题,请处理');
const partyANameRaw = getLeauditRawFieldValue(reviewPoint.content?.['甲方名称']);
const partyBNameRaw = getLeauditRawFieldValue(reviewPoint.content?.['乙方名称']);
const partyAName = typeof partyANameRaw === 'string' ? partyANameRaw.trim() : String(partyANameRaw || '').trim();
const partyBName = typeof partyBNameRaw === 'string' ? partyBNameRaw.trim() : String(partyBNameRaw || '').trim();
const shouldShowEnterpriseButtons = reviewPoint.groupName?.trim() === '合同主体';
const handleCorporateInfoClick = async (companyName: string, forceRefresh = false) => {
if (!companyName) {
toastService.warning('企业名称为空,无法查询');
return;
}
setCorporateModalVisible(true);
setCorporateCompanyName(companyName);
setCorporateLoading(true);
setCorporateError(null);
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateUpdatedAt(null);
try {
const response = await queryCompanyInfo({ keyword: companyName, force_refresh: forceRefresh });
if (response.success && response.data) {
setCorporateBusinessInfo(response.data.enterprise);
setCorporateUpdatedAt(response.data.updated_at);
if (response.data.dishonesty) {
setCorporateDishonestyInfo({
VerifyResult: response.data.dishonesty.VerifyResult,
Data: response.data.dishonesty.Data || [],
});
}
} else {
setCorporateError(response.message || '查询失败');
}
} catch (error) {
console.error('查询企业信息失败:', error);
setCorporateError(error instanceof Error ? error.message : '查询失败');
} finally {
setCorporateLoading(false);
}
};
const jumpToField = (fieldName: string) => {
const { fieldData, normalizedPage, normalizedValue } = getFieldLocatorState(fieldName);
if (normalizedPage) {
onReviewPointSelect(
reviewPoint.id,
normalizedPage,
fieldData?.char_positions,
normalizedValue || undefined,
const handleCorporateForceRefresh = async () => {
if (corporateCompanyName) {
await handleCorporateInfoClick(corporateCompanyName, true);
}
};
const handleCloseCorporateModal = () => {
setCorporateModalVisible(false);
setCorporateCompanyName('');
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateError(null);
setCorporateUpdatedAt(null);
};
const renderFieldCard = (fieldKey: string, fieldValue: string) => {
const page = getLeauditTargetPage(reviewPoint, fieldKey);
const bboxHighlight = getLeauditBboxHighlight(reviewPoint, fieldKey, page);
const enterpriseButton =
shouldShowEnterpriseButtons && fieldKey === '甲方名称' && partyAName
? renderEnterpriseInfoButton('甲方企业信息', partyAName)
: shouldShowEnterpriseButtons && fieldKey === '乙方名称' && partyBName
? renderEnterpriseInfoButton('乙方企业信息', partyBName)
: null;
return (
<button
key={fieldKey}
type="button"
className={`w-full border rounded-md text-left transition ${page ? 'hover:bg-[#f6ffed] hover:border-[#b7eb8f]' : 'opacity-90'} border-slate-200 bg-slate-50 field-btn`}
onClick={() => {
if (page) onReviewPointSelect(reviewPoint.id, page, undefined, fieldValue, bboxHighlight);
}}
>
<div className="p-2.5 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="inline-flex items-center text-[11px] text-slate-500 truncate font-medium">{fieldKey}</div>
{enterpriseButton && enterpriseButton}
</div>
<div className="flex items-center gap-2 shrink-0">
{page && <span className="text-[10.5px] text-slate-400 shrink-0">P{page}</span>}
</div>
</div>
<div
className="text-[12px] text-slate-700 mt-1 leading-relaxed whitespace-pre-wrap break-words select-text cursor-text"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{fieldValue}
</div>
</div>
{/* <div className="w-8 shrink-0 flex items-center justify-center border-l border-slate-200">
<i className="ri-focus-3-line text-[#00684a] text-[16px]" />
</div> */}
</button>
);
};
const renderEnterpriseInfoButton = (label: string, companyName: string) => (
<button
type="button"
className={`inline-flex items-center gap-1 h-5 px-1.5 rounded border text-[10.5px] transition-colors flex-shrink-0 ${
companyName
? 'bg-[#00684a] text-white border-[#00684a] hover:bg-[#005a3f] hover:border-[#005a3f]'
: 'bg-slate-100 text-slate-400 border-slate-200 cursor-not-allowed'
}`}
disabled={!companyName}
onClick={(e) => {
e.stopPropagation();
if (companyName) {
void handleCorporateInfoClick(companyName);
}
}}
>
<i className="ri-building-4-line text-[11px]" />
{label}
</button>
);
const renderStageContent = (stage: Record<string, unknown>, index: number) => {
const detail = (stage.detail || {}) as Record<string, unknown>;
const checkType = String(stage.check_type || 'unknown');
const passed = stage.passed === true;
const hasPassedState = typeof stage.passed === 'boolean';
const stageCardClass = passed ? 'border-emerald-200 bg-emerald-50/60' : 'border-slate-200 bg-slate-50/60';
const stageBadgeClass = passed ? 'text-emerald-700' : 'text-slate-600';
const stageLabelMap: Record<string, string> = {
required: '字段必填',
match: '一致性比对',
ai: 'AI 评查',
contains: '包含校验',
compare: '比较校验',
};
const stageDisplayName = typeof stage.check_type_chinese === 'string' && stage.check_type_chinese.trim()
? stage.check_type_chinese.trim()
: (stageLabelMap[checkType] || checkType);
const stageReason = typeof stage.reason === 'string' ? stage.reason.trim() : '';
const getStageDisplayValue = (value: unknown) => {
if (value == null || value === '') return '—';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return stringifyUnknown(value);
};
const renderStageInfoRow = (
label: string,
value: unknown,
options?: { valueClassName?: string; mono?: boolean },
) => (
<div className="px-2.5 py-2 flex items-start justify-between gap-3 border-t border-slate-100">
<div className="text-[11px] text-slate-500 shrink-0">{label}</div>
<div className={`text-[11px] text-slate-700 text-left break-words whitespace-pre-wrap max-w-[72%] ml-auto ${options?.mono ? 'font-mono' : ''} ${options?.valueClassName || ''}`}>
{getStageDisplayValue(value)}
</div>
</div>
);
if (checkType === 'required') {
const fields = Array.isArray(detail.fields) ? detail.fields.map(item => String(item)) : [];
const missing = Array.isArray(detail.missing) ? detail.missing.map(item => String(item)) : [];
return (
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">{stageDisplayName}</div>
<span className={`text-[10.5px] inline-flex items-center gap-1 ${stageBadgeClass}`}>
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-error-warning-fill'} />{passed ? '通过' : '缺失'}
</span>
</div>
{fields.length > 0 && <div className="text-[12px] text-slate-700">{`命中字段:${fields.join('、')}`}</div>}
{missing.length > 0 && <div className="text-[12px] text-amber-700 mt-1">{`缺失字段:${missing.join('、')}`}</div>}
</div>
);
return;
}
if (normalizedValue) {
onReviewPointSelect(
reviewPoint.id,
undefined,
fieldData?.char_positions,
normalizedValue,
if (checkType === 'match') {
const failures = Array.isArray(detail.failures)
? detail.failures.map(item => item as Record<string, unknown>)
: [];
return (
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">{stageDisplayName}</div>
<span className={`text-[10.5px] inline-flex items-center gap-1 ${stageBadgeClass}`}>
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-error-warning-fill'} />{passed ? '通过' : '不一致'}
</span>
</div>
{failures.length > 0 ? (
<div className="space-y-2 mt-2">
{failures.map((failure, failureIndex) => {
const leftField = String(failure.a || '左侧字段');
const rightField = String(failure.b || '右侧字段');
const leftValue = failure.a_value == null ? '—' : String(failure.a_value);
const rightValue = failure.b_value == null ? '—' : String(failure.b_value);
return (
<div key={`failure-${index}-${failureIndex}`} className="border border-red-200 rounded-md bg-white/90 overflow-hidden">
<div className="px-3 py-1 flex items-center justify-between gap-2 border-b border-red-100 bg-red-50/70">
<div className="text-[11px] text-slate-500">{`差异项 ${failureIndex + 1}`}</div>
</div>
<div className="py-1">
<div className="px-2.5 py-1.5 flex items-start justify-between gap-3">
<div className="text-[11px] text-slate-500 max-w-[46%] basis-[46%] break-words whitespace-normal">{leftField}</div>
{failure.a_value == null ? (
<span className="text-[11px] text-red-600 inline-flex items-center justify-start gap-0.5 font-medium max-w-[46%] basis-[46%] whitespace-normal break-words text-left ml-auto">
<i className="ri-prohibited-line" />
{'未填写'}
</span>
) : (
<div className={`text-[11px] text-slate-800 max-w-[46%] basis-[46%] text-left break-words whitespace-normal ml-auto ${/^\d/.test(leftValue) ? 'font-mono' : ''}`}>
{leftValue}
</div>
)}
</div>
<div className="px-2.5 py-1.5 flex items-start justify-between gap-3 border-t border-slate-100">
<div className="text-[11px] text-slate-500 max-w-[46%] basis-[46%] break-words whitespace-normal">{rightField}</div>
{failure.b_value == null ? (
<span className="text-[11px] text-red-600 inline-flex items-center justify-start gap-0.5 font-medium max-w-[46%] basis-[46%] whitespace-normal break-words text-left ml-auto">
<i className="ri-prohibited-line" />
{'未填写'}
</span>
) : (
<div className={`text-[11px] text-slate-800 max-w-[46%] basis-[46%] text-left break-words whitespace-normal ml-auto ${/^\d/.test(rightValue) ? 'font-mono' : ''}`}>
{rightValue}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-[12px] text-slate-600">{'未发现不一致项'}</div>
)}
</div>
);
toastService.info(`${fieldName} 当前没有页码,已改为按文本定位`);
return;
}
toastService.info(`${fieldName} 当前既没有页码,也没有可定位文本`);
if (checkType === 'compare') {
const opMap: Record<string, string> = {
'>=': '≥', '<=': '≤', '!=': '≠', '<>': '≠', '==': '=', '>': '>', '<': '<', '=': '=',
};
const displayOp = opMap[String(detail.op)] || String(detail.op);
const fmtNum = (v: unknown) => {
if (v == null || String(v).trim() === '') return getStageDisplayValue(v);
const n = Number(v);
return !isNaN(n) ? n.toLocaleString('zh-CN') : getStageDisplayValue(v);
};
const buildOperand = (field: unknown, value: unknown) => {
const fieldStr = getStageDisplayValue(field);
if (value == null || value === '') return fieldStr;
return `${fieldStr}(${fmtNum(value)})`;
};
const leftOperand = buildOperand(detail.left, detail.left_value);
const rightDisplay = typeof detail.right === 'number'
? fmtNum(detail.right)
: buildOperand(detail.right, detail.right_value);
return (
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">{stageDisplayName}</div>
<span className={`text-[10.5px] inline-flex items-center gap-1 ${stageBadgeClass}`}>
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-close-circle-fill'} />
{passed ? '通过' : '未通过'}
</span>
</div>
<div className={`text-[12px] font-mono ${passed ? 'text-slate-800' : 'text-red-800'}`}>
{leftOperand} <span className="mx-1 font-bold">{displayOp}</span> {rightDisplay}
</div>
{stageReason && (
<div className="text-[11px] text-slate-500 mt-1">{stageReason}</div>
)}
</div>
);
}
if (checkType === 'ai') {
const response = (detail.response || {}) as Record<string, unknown>;
const reasonText = typeof response.reason === 'string' ? response.reason.trim() : '';
const strengthItems = normalizeAiResponseItems(response.strengths);
const suggestionItems = normalizeAiResponseItems(response.suggestion, { hideNone: true });
const dividerClass = passed ? 'border-emerald-200/70' : 'border-fuchsia-200/70';
return (
<section key={`stage-${index}`} className="px-0 pt-0">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
<i className="ri-sparkling-2-fill text-fuchsia-500 text-[12px]" />
{'AI 评查意见'}
</div>
<div className={`p-3 border rounded-md space-y-3 ${passed ? 'bg-emerald-50/70 border-emerald-200' : 'bg-fuchsia-50/60 border-fuchsia-200'}`}>
{reasonText && (
<div className="flex gap-2 text-[12.5px] text-slate-700 leading-relaxed">
<i className={`${passed ? 'ri-checkbox-circle-line text-emerald-500' : 'ri-error-warning-line text-fuchsia-500'} shrink-0 mt-0.5`} />
<div className="whitespace-pre-wrap break-words">{reasonText}</div>
</div>
)}
{strengthItems.length > 0 && (
<div className={`pt-2 border-t ${dividerClass}`}>
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-700 mb-1.5">
<i className="ri-medal-line" />
{'亮点'}
<span className="font-mono text-[10.5px] text-emerald-500">{strengthItems.length}</span>
</div>
<ul className="space-y-1 text-[12px] text-slate-700">
{strengthItems.map((item, itemIndex) => (
<li key={`strength-${index}-${itemIndex}`} className="flex gap-1.5">
<i className="ri-check-line text-emerald-500 mt-[1px] shrink-0" />
<span className="break-words">{item}</span>
</li>
))}
</ul>
</div>
)}
{suggestionItems.length > 0 && (
<div className={`pt-2 border-t ${dividerClass}`}>
<div className="flex items-center gap-1 text-[11px] font-medium text-fuchsia-700 mb-1.5">
<i className="ri-edit-2-line" />
{'修改建议'}
<span className="font-mono text-[10.5px] text-fuchsia-500">{suggestionItems.length}</span>
</div>
<ul className="space-y-1 text-[12px] text-slate-700">
{suggestionItems.map((item, itemIndex) => (
<li key={`suggestion-${index}-${itemIndex}`} className="flex gap-1.5">
<span className="text-fuchsia-400 shrink-0">{'•'}</span>
<span className="break-words">{item}</span>
</li>
))}
</ul>
</div>
)}
</div>
</section>
);
}
return (
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">
{stageDisplayName}
</div>
{hasPassedState && (
<span className={`inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${
passed
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: 'bg-red-50 text-red-700 border-red-200'
}`}>
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-close-circle-fill'} />
{passed ? '通过' : '未通过'}
</span>
)}
</div>
<div className="rounded-md border border-slate-200 bg-white/80 overflow-hidden">
{/* {renderStageInfoRow('阶段类型', stageDisplayName)} */}
{/* {hasPassedState && renderStageInfoRow('结果', passed ? '通过' : '未通过', { valueClassName: passed ? 'text-emerald-700' : 'text-red-700' })} */}
{stageReason && renderStageInfoRow('原因', stageReason)}
</div>
</div>
);
};
return (
<div className={`mb-3 rounded-md border ${passed ? 'border-emerald-200 bg-emerald-50/60' : 'border-amber-200 bg-amber-50/70'} p-3`}>
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] font-medium text-slate-600">{badgeText}</div>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] ${passed ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
<i className={passed ? 'ri-checkbox-circle-line' : 'ri-error-warning-line'} />
{passed ? '通过' : '未通过'}
</span>
</div>
{reason && (
<div className="mt-2 text-[12px] leading-5 text-slate-700">
{reason}
<>
<article className="bg-white border border-slate-200 rounded-lg shadow-sm overflow-hidden">
<header className="px-4 pt-4 pb-3 border-b border-slate-100">
<div className="flex items-center gap-2 flex-wrap">
{reviewPoint.pointId && (
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600 shrink-0">
{reviewPoint.pointId}
</span>
)}
<h2 className="text-[14.5px] font-semibold text-slate-900 break-all leading-snug">{reviewPoint.pointName}</h2>
</div>
<div className="mt-2 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${statusChip.cls}`}>
<i className={statusChip.icon} />{statusChip.label}
</span>
<span className={`inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${riskMeta.cls}`}>
<i className="ri-focus-3-line" />{riskMeta.label}
</span>
{reviewPoint.postAction === 'manual' && (
<span className="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] bg-slate-50 text-slate-600 border border-slate-200">
<i className="ri-user-line" />
</span>
)}
</div>
<div className="flex items-center gap-3 text-[11px] text-slate-500">
{reviewPoint.score != null && <span> <span className="font-mono text-slate-700">{reviewPoint.finalScore ?? reviewPoint.machineScore ?? reviewPoint.score}/{reviewPoint.score}</span></span>}
{confidencePct && <span> <span className="font-mono text-slate-700">{confidencePct}</span></span>}
</div>
</div>
</header>
{Object.keys(reviewPoint.content || {}).length > 0 && (
<section className="px-4 py-2">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
<span className="font-mono normal-case text-[10.5px]">{Object.keys(reviewPoint.content).length}</span>
</div>
<div className="space-y-2">
{Object.entries(reviewPoint.content).map(([key, value]) => renderFieldCard(key, getLeauditFieldText(value)))}
</div>
</section>
)}
{fieldNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{fieldNames.map((fieldName) => {
const { fieldData, normalizedPage, normalizedValue, canLocate } = getFieldLocatorState(fieldName);
const fieldValue = fieldData?.value;
const displayValue =
typeof fieldValue === 'string'
? fieldValue
: fieldValue == null
? '未抽取到值'
: JSON.stringify(fieldValue);
return (
<button
key={fieldName}
type="button"
className={`min-w-0 flex-1 rounded border px-2.5 py-2 text-left ${
canLocate
? 'border-slate-200 bg-white hover:border-[#00684a] hover:bg-[#f6fffb]'
: 'border-slate-200 bg-slate-50 text-slate-400 cursor-not-allowed'
}`}
onClick={() => canLocate && jumpToField(fieldName)}
disabled={!canLocate}
>
<div className="flex items-center justify-between gap-2 text-[11px] font-medium">
<span className={canLocate ? 'text-slate-500' : 'text-slate-400'}>{fieldName}</span>
{normalizedPage ? (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-700">
{normalizedPage}
</span>
) : normalizedValue ? (
<span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">
</span>
) : (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">
</span>
)}
{missingItems.length > 0 && (
<section className="px-4 py-2">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
<span className="font-mono normal-case text-[10.5px]">{missingItems.length}</span>
</div>
<div className="space-y-2">
{missingItems.map(item => (
<div key={item} className="w-full border border-red-200 rounded-md bg-red-50/60">
<div className="p-2.5 flex items-start justify-between gap-2">
<div className="min-w-0 text-[12px] text-slate-700 leading-relaxed break-words">{item}</div>
<span className="text-[10.5px] text-red-600 shrink-0"></span>
</div>
<div className="mt-1 text-[12px] leading-5 text-slate-700 break-all">{displayValue}</div>
</button>
);
})}
</div>
</div>
))}
</div>
</section>
)}
</div>
{stages.length > 0 && (
<section className="px-4 py-2">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
<span className="font-mono normal-case text-[10.5px]">{stages.length}</span>
</div>
<div className="space-y-2">
{stages.map((stage, index) => renderStageContent(stage, index))}
</div>
</section>
)}
{!isWarning && (
<section className="px-4 py-2">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
<i className={isPass ? 'ri-shield-check-line text-emerald-500 text-[12px]' : 'ri-close-circle-line text-red-500 text-[12px]'} />
{isPass ? '校验结果' : '问题说明'}
</div>
<div className={`p-3 rounded-md border text-[12.5px] leading-relaxed ${isPass ? 'bg-emerald-50/60 border-emerald-200 text-slate-700' : 'bg-red-50/60 border-red-200 text-slate-700'}`}>
{summaryText}
</div>
</section>
)}
{legalBasisList.length > 0 && (
<section className="px-4 py-2">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2"></div>
<div className="flex flex-wrap gap-2">
{legalBasisList.map((item, index) => (
<span key={`${item}-${index}`} className="bg-[#e6f4ff] border border-[#91caff] rounded-full px-2 py-0.5 text-xs text-[#0958d9]">
{item}
</span>
))}
</div>
</section>
)}
{reviewPoint.postAction === 'manual' && (
<section className="px-4 py-2">
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2"></div>
<textarea
rows={2}
placeholder="请输入审核意见..."
className={`w-full p-2 border border-slate-200 rounded-md text-[12.5px] min-h-[56px] focus:outline-none focus:border-[#00684a] focus:ring-2 focus:ring-[#00684a]/15 resize-none placeholder:text-slate-400 ${reviewPoint.editAuditStatus !== 0 ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
value={manualNote}
onChange={(e) => setManualNote(e.target.value)}
disabled={reviewPoint.editAuditStatus !== 0}
/>
</section>
)}
{reviewPoint.postAction === 'manual' && reviewPoint.editAuditStatus === 0 && (
<footer className="mt-3 px-4 py-3 flex items-center justify-end gap-2 border-t border-slate-100 bg-slate-50/60">
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-[#1890ff] text-white hover:bg-blue-600 flex items-center gap-1 font-medium" onClick={() => {
if (manualNote.trim() === '') { toastService.error('请输入审核意见'); return; }
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'true', manualNote);
}}>
<i className="ri-check-line" />
</button>
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-[#f5222d] text-white hover:bg-red-600 flex items-center gap-1 font-medium shadow-sm" onClick={() => {
if (manualNote.trim() === '') { toastService.error('请输入审核意见'); return; }
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'false', manualNote);
}}>
<i className="ri-close-line" />
</button>
</footer>
)}
{reviewPoint.postAction === 'manual' && reviewPoint.editAuditStatus !== 0 && (
<footer className="mt-3 px-4 py-3 flex items-center justify-end border-t border-slate-100 bg-slate-50/60">
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-purple-600 text-white hover:bg-purple-700 flex items-center gap-1 font-medium" onClick={() => {
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'review', '');
}}>
<i className="ri-refresh-line" />
</button>
</footer>
)}
{isPass && reviewPoint.postAction !== 'manual' && (
<footer className="px-4 py-3 flex items-center gap-2 border-t border-slate-100 bg-slate-50/60">
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
<i className="ri-verified-badge-fill" />
</span>
</footer>
)}
</article>
<CorporateInfoModal
visible={corporateModalVisible}
onClose={handleCloseCorporateModal}
companyName={corporateCompanyName}
businessInfo={corporateBusinessInfo}
dishonestyInfo={corporateDishonestyInfo}
businessLoading={corporateLoading}
dishonestyLoading={corporateLoading}
businessError={corporateError}
dishonestyError={corporateError}
updatedAt={corporateUpdatedAt}
onForceRefresh={handleCorporateForceRefresh}
/>
</>
);
}
// ── Main Component ──
export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) {
const resolveManualNote = () => {
if (reviewPoint.editAuditStatusMessage) {
return reviewPoint.editAuditStatusMessage;
}
if (typeof reviewPoint.actionContent === 'string') {
return reviewPoint.actionContent;
}
if (reviewPoint.suggestion) {
return reviewPoint.suggestion;
}
return '';
};
const [manualNote, setManualNote] = useState(resolveManualNote);
function LegacyReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) {
const [manualNote, setManualNote] = useState(
() => reviewPoint.editAuditStatusMessage || normalizeActionContent(reviewPoint.actionContent) || reviewPoint.suggestion || ''
);
// reviewPoint 切换时重置默认值
useEffect(() => {
setManualNote(resolveManualNote());
setManualNote(reviewPoint.editAuditStatusMessage || normalizeActionContent(reviewPoint.actionContent) || reviewPoint.suggestion || '');
}, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]);
const otherRules = filterOtherRule(reviewPoint);
@@ -657,7 +1231,7 @@ export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
if (rule.type === 'ai') {
return <div key={`rule-${i}`}>{otherRules.length > 0 && <div className="bg-gray-50 rounded border border-gray-200 text-xs mb-3" />}<RenderModelRule rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} fileFormat={fileFormat} /></div>;
}
return <RenderGenericRule key={`rule-${i}`} rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} />;
return null;
})}
</section>
@@ -727,3 +1301,17 @@ export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
</article>
);
}
export function ReviewPointDetailCard(props: ReviewPointDetailCardProps) {
if (props.detailMode === 'leaudit') {
return (
<LeauditReviewPointDetailCard
reviewPoint={props.reviewPoint}
onReviewPointSelect={props.onReviewPointSelect}
onStatusChange={props.onStatusChange}
/>
);
}
return <LegacyReviewPointDetailCard {...props} />;
}