Compare commits

...

10 Commits

Author SHA1 Message Date
wren c00e5feff0 feat: sync rule management and review ui fixes 2026-05-07 17:27:42 +08:00
wren 87e82d1caa fix: improve rule detail dependency sync 2026-05-07 11:20:55 +08:00
wren d6ce62457a fix: stabilize rule detail config persistence 2026-05-07 10:58:42 +08:00
wren 71476fc919 fix: stabilize rules detail editor flow 2026-05-07 09:59:01 +08:00
wren e7bac9a33f fix: tighten route permission guards 2026-05-06 20:06:41 +08:00
wren 8fcd79b608 fix: unify review detail page badges 2026-05-06 18:34:03 +08:00
wren 61bbf6907b feat: wire real upload progress and subtype mapping 2026-05-06 18:33:53 +08:00
wren 57c744eb17 fix: redirect expired sessions to login 2026-05-06 18:33:43 +08:00
wren 2d8bab2c91 fix: align case-file rule filters with business types 2026-05-06 18:18:49 +08:00
wren c7bbe59882 fix: replace page shorthand labels in review detail 2026-05-06 18:05:51 +08:00
24 changed files with 2554 additions and 674 deletions
+11 -37
View File
@@ -1024,14 +1024,24 @@ function buildFallbackRoutes(roleKey: string): {
const mappedRoleKey = mapUserRoleToRoleKey(roleKey); const mappedRoleKey = mapUserRoleToRoleKey(roleKey);
const fallbackMenus = FALLBACK_MENU_DATA[mappedRoleKey] || FALLBACK_MENU_DATA.common; const fallbackMenus = FALLBACK_MENU_DATA[mappedRoleKey] || FALLBACK_MENU_DATA.common;
const permissionMap: Record<string, string[]> = {}; const permissionMap: Record<string, string[]> = {};
const safeFallbackMenus = stripDisallowedFallbackRoutes(fallbackMenus);
return { return {
success: true, success: true,
data: normalizeMenuStructure(fallbackMenus.filter(item => isMinimalMenuPath(item.path))), data: normalizeMenuStructure(safeFallbackMenus.filter(item => isMinimalMenuPath(item.path))),
permissionMap, permissionMap,
}; };
} }
function stripDisallowedFallbackRoutes(menuItems: MenuItem[]): MenuItem[] {
return menuItems
.filter((item) => item.path !== '/rule-groups')
.map((item) => ({
...item,
children: item.children ? stripDisallowedFallbackRoutes(item.children) : undefined,
}));
}
function isLegacyRuleSetsMenu(path: string | undefined): boolean { function isLegacyRuleSetsMenu(path: string | undefined): boolean {
return path === '/rules/sets'; return path === '/rules/sets';
} }
@@ -1059,41 +1069,5 @@ function normalizeMenuStructure(menuItems: MenuItem[]): MenuItem[] {
const dedupedTopLevelItems = clonedMenuItems.filter(item => !nestedPathSet.has(item.path)); const dedupedTopLevelItems = clonedMenuItems.filter(item => !nestedPathSet.has(item.path));
const ruleManagement = dedupedTopLevelItems.find(item => item.path === '/rules');
const systemSettings = dedupedTopLevelItems.find(item => item.path === '/settings');
const syntheticRuleGroupsMenu: MenuItem = {
id: 'rule-groups',
title: '规则组导航',
path: '/rule-groups',
icon: 'ri-folder-open-line',
order: 1,
};
let ruleGroupsMenu: MenuItem = syntheticRuleGroupsMenu;
if (ruleManagement?.children?.length) {
const ruleGroupIndex = ruleManagement.children.findIndex(child => child.path === '/rule-groups');
if (ruleGroupIndex !== -1) {
const [existingRuleGroupsMenu] = ruleManagement.children.splice(ruleGroupIndex, 1);
ruleGroupsMenu = existingRuleGroupsMenu;
ruleManagement.children = ruleManagement.children
.map((child, index) => ({ ...child, order: index + 1 }))
.sort((a, b) => a.order - b.order);
}
}
if (!systemSettings) {
return dedupedTopLevelItems;
}
const settingsChildren = systemSettings.children ? [...systemSettings.children] : [];
if (!settingsChildren.some(child => child.path === '/rule-groups')) {
settingsChildren.unshift({ ...ruleGroupsMenu, order: 1 });
}
systemSettings.children = settingsChildren
.map((child, index) => ({ ...child, order: index + 1 }))
.sort((a, b) => a.order - b.order);
return dedupedTopLevelItems; return dedupedTopLevelItems;
} }
+14 -10
View File
@@ -219,10 +219,12 @@ function getFileExtension(filename: string): string {
function mapProcessingStatusToFileStatus(status?: string | null): string { function mapProcessingStatusToFileStatus(status?: string | null): string {
const normalized = (status || '').toLowerCase(); const normalized = (status || '').toLowerCase();
if (normalized === 'completed') return 'Processed'; if (normalized === 'completed' || normalized === 'processed') return 'Processed';
if (normalized === 'failed') return 'Failed'; if (normalized === 'failed') return 'Failed';
if (normalized === 'running' || normalized === 'queued' || normalized === 'dispatch') return 'Evaluationing'; if (normalized === 'cutting') return 'Cutting';
if (normalized === 'waiting' || normalized === 'pending') return 'Waiting'; if (normalized === 'extractioning') return 'Extractioning';
if (normalized === 'evaluationing' || normalized === 'running' || normalized === 'dispatch') return 'Evaluationing';
if (normalized === 'waiting' || normalized === 'pending' || normalized === 'queued') return 'Waiting';
return 'Waiting'; return 'Waiting';
} }
@@ -827,13 +829,15 @@ export async function getDocumentsListFromAPI(searchParams: {
if (name) params.keyword = name; if (name) params.keyword = name;
if (fileStatus) { if (fileStatus) {
const normalizedFileStatus = fileStatus.toLowerCase(); const normalizedFileStatus = fileStatus.toLowerCase();
if (normalizedFileStatus === 'processed') { const processingStatusMap: Record<string, string> = {
params.processingStatus = 'completed'; waiting: 'waiting',
} else if (normalizedFileStatus === 'failed') { cutting: 'Cutting',
params.processingStatus = 'failed'; extractioning: 'Extractioning',
} else { evaluationing: 'Evaluationing',
params.processingStatus = 'running'; processed: 'completed',
} failed: 'failed',
};
params.processingStatus = processingStatusMap[normalizedFileStatus] || fileStatus;
} }
if (documentTypeIds && documentTypeIds.length > 0) { if (documentTypeIds && documentTypeIds.length > 0) {
+85 -20
View File
@@ -147,8 +147,10 @@ export interface DocumentType {
name: string; name: string;
code?: string; code?: string;
entryModuleId?: number; entryModuleId?: number;
entryModuleName?: string | null;
isEnabled?: boolean; isEnabled?: boolean;
ruleSetIds?: number[]; ruleSetIds?: number[];
childDocumentTypeIds?: number[];
} }
export interface DocumentSubtypeGroup { export interface DocumentSubtypeGroup {
@@ -233,6 +235,12 @@ export interface UploadResult {
error?: string; error?: string;
} }
export interface UploadProgressInfo {
loaded: number;
total: number;
percent: number;
}
// 旧接口上传响应(uploadContractTemplate / appendContractAttachments 仍在使用) // 旧接口上传响应(uploadContractTemplate / appendContractAttachments 仍在使用)
interface LegacyUploadResponse { interface LegacyUploadResponse {
success: boolean; success: boolean;
@@ -684,6 +692,7 @@ export async function uploadDocumentToServer(
autoRun: boolean = true, autoRun: boolean = true,
speed: string = "normal", speed: string = "normal",
jwtToken?: string, jwtToken?: string,
onProgress?: (progress: UploadProgressInfo) => void,
): Promise<{ data: UploadResult } | { error: string; status?: number; payload?: UploadErrorPayload }> { ): Promise<{ data: UploadResult } | { error: string; status?: number; payload?: UploadErrorPayload }> {
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -711,7 +720,23 @@ export async function uploadDocumentToServer(
headers["Authorization"] = `Bearer ${jwtToken}`; headers["Authorization"] = `Bearer ${jwtToken}`;
} }
const response = await axios.post(`${API_BASE_URL}/api/upload`, formData, { headers }); const response = await axios.post(`${API_BASE_URL}/api/upload`, formData, {
headers,
onUploadProgress: (event) => {
const fileBlob = formData.get("file");
const fallbackTotal = fileBlob instanceof Blob ? fileBlob.size : binaryData.byteLength;
const total = Number(event.total || fallbackTotal);
const loaded = Number(event.loaded || 0);
if (!total || !onProgress) {
return;
}
onProgress({
loaded,
total,
percent: Math.min(100, Math.max(0, Number(((loaded / total) * 100).toFixed(2)))),
});
},
});
const body = response.data; const body = response.data;
// Result<DocumentUploadVO> envelope // Result<DocumentUploadVO> envelope
@@ -826,8 +851,6 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (selectedModuleId) { if (selectedModuleId) {
params.entry_module_id = String(selectedModuleId); params.entry_module_id = String(selectedModuleId);
} else if (documentTypeIds && documentTypeIds.length > 0) {
params.ids = documentTypeIds.join(",");
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -835,18 +858,52 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const response = await axios.get(`${API_BASE_URL}/api/document-types`, { params, headers }); const [response, groupRoots] = await Promise.all([
axios.get(`${API_BASE_URL}/api/v3/document-type-roots`, { params, headers }),
fetchAllEvaluationPointGroupRoots(token),
]);
const body = response.data; const body = response.data;
if (body?.data && Array.isArray(body.data)) { if (body?.data && Array.isArray(body.data)) {
const types: DocumentType[] = body.data.map((item: { id: number; name: string; code?: string; entryModuleId?: number; isEnabled?: boolean; ruleSetIds?: number[] }) => ({ let types: DocumentType[] = body.data.map((item: {
id: item.id, id: number;
name: item.name, name: string;
code: item.code, code?: string;
entryModuleId: item.entryModuleId, entryModuleId?: number | null;
isEnabled: item.isEnabled, entryModuleName?: string | null;
ruleSetIds: item.ruleSetIds, isEnabled?: boolean;
})); ruleSetIds?: number[];
}) => {
const matchedRoot = groupRoots.find((root: any) => Number(root?.id || 0) === Number(item.id));
const childDocumentTypeIds = Array.isArray(matchedRoot?.children)
? Array.from(
new Set(
matchedRoot.children
.map((child: any) => Number(child?.document_type_id || 0))
.filter((childId: number) => childId > 0),
),
)
: [];
return {
id: item.id,
name: item.name,
code: item.code,
entryModuleId: item.entryModuleId ?? null,
entryModuleName: item.entryModuleName ?? null,
isEnabled: item.isEnabled,
ruleSetIds: item.ruleSetIds,
childDocumentTypeIds,
};
});
if (!selectedModuleId && documentTypeIds && documentTypeIds.length > 0) {
types = types.filter((item) =>
documentTypeIds.includes(item.id) ||
(item.childDocumentTypeIds || []).some((childId) => documentTypeIds.includes(childId)),
);
}
return { data: types }; return { data: types };
} }
return { error: body?.message || "获取文档类型失败", status: response.status }; return { error: body?.message || "获取文档类型失败", status: response.status };
@@ -919,18 +976,26 @@ async function fetchAllEvaluationPointGroupRoots(token?: string): Promise<any[]>
function collectSubtypeGroupsFromRoots( function collectSubtypeGroupsFromRoots(
roots: any[], roots: any[],
documentTypeId: number, rootOrDocumentTypeId: number,
entryModuleId?: number | null, entryModuleId?: number | null,
): DocumentSubtypeGroup[] { ): DocumentSubtypeGroup[] {
return dedupeSubtypeGroups( const scopedRoots = roots.filter((root: any) => {
roots.flatMap((root: any) => { if (entryModuleId && Number(root?.entry_module_id || 0) !== Number(entryModuleId)) {
if (!Array.isArray(root?.children)) return []; return false;
if (entryModuleId && Number(root?.entry_module_id || 0) !== Number(entryModuleId)) { }
return []; return true;
} });
const matchedRoot = scopedRoots.find((root: any) => Number(root?.id || 0) === Number(rootOrDocumentTypeId));
if (matchedRoot && Array.isArray(matchedRoot.children)) {
return dedupeSubtypeGroups(matchedRoot.children.map((child: any) => mapSubtypeChild(child, matchedRoot)));
}
return dedupeSubtypeGroups(
scopedRoots.flatMap((root: any) => {
if (!Array.isArray(root?.children)) return [];
return root.children return root.children
.filter((child: any) => Number(child?.document_type_id || 0) === Number(documentTypeId)) .filter((child: any) => Number(child?.document_type_id || 0) === Number(rootOrDocumentTypeId))
.map((child: any) => mapSubtypeChild(child, root)); .map((child: any) => mapSubtypeChild(child, root));
}), }),
); );
+15 -2
View File
@@ -31,6 +31,17 @@ export function ClientAuthGuard({ isPublicPath, frontendJWT, userInfo }: ClientA
return; return;
} }
// 服务端如果已经拿不到有效 session,就不要再信任本地残留 token。
// 否则页面会出现“模块全空了”,但又没有跳回登录页的假登录状态。
if (!frontendJWT && !userInfo) {
console.warn('⚠️ [Auth Guard] 服务端会话已失效,清理本地登录态并跳转登录页');
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
const redirectTo = `${location.pathname}${location.search}` || '/';
navigate(`/login?expired=true&redirect=${encodeURIComponent(redirectTo)}`, { replace: true });
return;
}
// 优先用服务端 session 回传的数据同步 localStorage。 // 优先用服务端 session 回传的数据同步 localStorage。
// 不能只在本地没有 token 时才回填,否则本地残留旧 token 会导致: // 不能只在本地没有 token 时才回填,否则本地残留旧 token 会导致:
// - SSR 页面可打开(服务端 session 是新的) // - SSR 页面可打开(服务端 session 是新的)
@@ -55,14 +66,16 @@ export function ClientAuthGuard({ isPublicPath, frontendJWT, userInfo }: ClientA
console.log('🔒 [Auth Guard] 未认证,重定向到登录页'); console.log('🔒 [Auth Guard] 未认证,重定向到登录页');
// 保存当前路径,登录后可以跳转回来 // 保存当前路径,登录后可以跳转回来
const redirectTo = location.pathname !== '/login' ? location.pathname : '/'; const redirectTo = location.pathname !== '/login'
? `${location.pathname}${location.search}`
: '/';
// 跳转到登录页,并传递重定向目标 // 跳转到登录页,并传递重定向目标
navigate(`/login?redirect=${encodeURIComponent(redirectTo)}`, { replace: true }); navigate(`/login?redirect=${encodeURIComponent(redirectTo)}`, { replace: true });
} else { } else {
// console.log('✅ [Auth Guard] 已认证,允许访问'); // console.log('✅ [Auth Guard] 已认证,允许访问');
} }
}, [isPublicPath, navigate, location.pathname, frontendJWT, userInfo]); }, [isPublicPath, navigate, location.pathname, location.search, frontendJWT, userInfo]);
// 这个组件不渲染任何内容 // 这个组件不渲染任何内容
return null; return null;
+9 -6
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import type { MenuItem } from '~/api/auth/user-routes';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
// import { Header } from './Header'; // import { Header } from './Header';
import { Breadcrumb } from './Breadcrumb'; import { Breadcrumb } from './Breadcrumb';
@@ -10,6 +11,7 @@ interface LayoutProps {
userRole?: UserRole; userRole?: UserRole;
frontendJWT?: string; frontendJWT?: string;
isMobile?: boolean; // 是否为移动端设备(服务端通过 User-Agent 检测) isMobile?: boolean; // 是否为移动端设备(服务端通过 User-Agent 检测)
menuItems?: MenuItem[];
} }
// 添加一个接口表示路由handle可能包含的属性 // 添加一个接口表示路由handle可能包含的属性
@@ -30,13 +32,14 @@ type RulesTestDetailData = {
pack?: { pack?: {
documentType?: string; documentType?: string;
mainType?: string; mainType?: string;
businessType?: string;
fields?: unknown[]; fields?: unknown[];
subDocuments?: unknown[]; subDocuments?: unknown[];
visualElements?: unknown[]; visualElements?: unknown[];
}; };
}; };
export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '', isMobile = false }: LayoutProps) { export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '', isMobile = false, menuItems = [] }: LayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [effectiveUserRole, setEffectiveUserRole] = useState<UserRole>(userRole); const [effectiveUserRole, setEffectiveUserRole] = useState<UserRole>(userRole);
const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState<string>(frontendJWT); const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState<string>(frontendJWT);
@@ -136,13 +139,12 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
const isRulesTestTopbarPage = isRulesTestDetail; const isRulesTestTopbarPage = isRulesTestDetail;
const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined; const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined;
const detailPack = rulesTestDetailData?.pack; const detailPack = rulesTestDetailData?.pack;
const isContractDetail = !!detailPack?.documentType?.includes('合同'); const detailPackFilterMainType = detailPack?.businessType || detailPack?.mainType || '';
const isCaseFileDetail = !!detailPack?.documentType?.includes('案卷'); const showFieldNav = (detailPack?.fields?.length || 0) > 0;
const showFieldNav = isContractDetail && (detailPack?.fields?.length || 0) > 0; const showSubDocumentNav = (detailPack?.subDocuments?.length || 0) > 0;
const showSubDocumentNav = isCaseFileDetail && (detailPack?.subDocuments?.length || 0) > 0;
const showVisualNav = (detailPack?.visualElements?.length || 0) > 0; const showVisualNav = (detailPack?.visualElements?.length || 0) > 0;
const rulesListHref = detailPack?.documentType const rulesListHref = detailPack?.documentType
? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPack.mainType ? `&mainType=${encodeURIComponent(detailPack.mainType)}` : ''}` ? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPackFilterMainType ? `&mainType=${encodeURIComponent(detailPackFilterMainType)}` : ''}`
: '/rulesTest/list'; : '/rulesTest/list';
return ( return (
@@ -153,6 +155,7 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
onToggle={toggleSidebar} onToggle={toggleSidebar}
userRole={effectiveUserRole} userRole={effectiveUserRole}
frontendJWT={effectiveFrontendJWT} frontendJWT={effectiveFrontendJWT}
menuItems={menuItems}
/> />
{/* 规则详情页顶部栏 */} {/* 规则详情页顶部栏 */}
+12 -31
View File
@@ -10,13 +10,14 @@ interface SidebarProps {
collapsed: boolean; collapsed: boolean;
userRole: UserRole; userRole: UserRole;
frontendJWT?: string; frontendJWT?: string;
menuItems?: MenuItem[];
} }
export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: SidebarProps) { export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', menuItems: initialMenuItems = [] }: SidebarProps) {
const location = useLocation(); const location = useLocation();
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({}); const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [menuItems, setMenuItems] = useState<MenuItem[]>([]); // 动态菜单项 const [menuItems, setMenuItems] = useState<MenuItem[]>(initialMenuItems); // 动态菜单项
const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(true); // 路由加载状态 const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(initialMenuItems.length === 0); // 路由加载状态
const [isMobile, setIsMobile] = useState<boolean>(false); // 移动端检测 const [isMobile, setIsMobile] = useState<boolean>(false); // 移动端检测
const [selectedModuleName, setSelectedModuleName] = useState<string>(''); // 当前选中的模块名称 const [selectedModuleName, setSelectedModuleName] = useState<string>(''); // 当前选中的模块名称
const [selectedModulePicPath, setSelectedModulePicPath] = useState<string>(''); // 当前选中的模块图片路径 const [selectedModulePicPath, setSelectedModulePicPath] = useState<string>(''); // 当前选中的模块图片路径
@@ -39,12 +40,15 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
// 获取用户路由权限 // 获取用户路由权限
useEffect(() => { useEffect(() => {
// console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限'); if (initialMenuItems.length > 0) {
setMenuItems(initialMenuItems);
setIsLoadingRoutes(false);
return;
}
const fetchUserRoutes = async () => { const fetchUserRoutes = async () => {
setIsLoadingRoutes(true); setIsLoadingRoutes(true);
try { try {
// 优先使用传入的 frontendJWT,否则从 localStorage 读取
let jwt = frontendJWT; let jwt = frontendJWT;
if (!jwt && typeof window !== 'undefined') { if (!jwt && typeof window !== 'undefined') {
@@ -59,29 +63,20 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
return; return;
} }
// console.log('🔍 [Sidebar] 当前用户角色:', userRole, 'JWT前20字符:', jwt.substring(0, 20));
// console.log('🔍 [Sidebar] 映射后的角色key:', roleKey);
const result = await getUserRoutesByRole(userRole, jwt); const result = await getUserRoutesByRole(userRole, jwt);
if (result.success && result.data) { if (result.success && result.data) {
setMenuItems(result.data); setMenuItems(result.data);
// console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data);
} else { } else {
console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error); console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error);
// 如果需要重定向到首页
if (result.shouldRedirectToHome) { if (result.shouldRedirectToHome) {
// console.log('🔄 [Sidebar] 重定向到首页');
navigate('/'); navigate('/');
return; return;
} }
// 其他错误情况,使用空数组
setMenuItems([]); setMenuItems([]);
} }
} catch (error) { } catch (error) {
console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error); console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error);
// 发生异常时也重定向到首页
navigate('/'); navigate('/');
return; return;
} finally { } finally {
@@ -90,7 +85,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
}; };
fetchUserRoutes(); fetchUserRoutes();
}, [userRole, frontendJWT, navigate]); }, [userRole, frontendJWT, navigate, initialMenuItems]);
// 🔑 检查是否处于系统设置模式或交叉评查模式 // 🔑 检查是否处于系统设置模式或交叉评查模式
const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false); const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false);
@@ -344,22 +339,8 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
if (isCaseFileModule) { if (isCaseFileModule) {
return { return {
...item, ...item,
children: [ path: buildRulesTestListPath(),
{ children: undefined
id: 'rules-admin-penalty',
title: '行政处罚',
path: buildRulesTestListPath('行政处罚'),
icon: 'ri-list-check-3',
order: 1
},
{
id: 'rules-admin-license',
title: '行政许可',
path: buildRulesTestListPath('行政许可'),
icon: 'ri-list-check-3',
order: 2
}
]
}; };
} }
+22 -11
View File
@@ -27,12 +27,13 @@ interface ReviewTabsProps {
comparisonId?: number; comparisonId?: number;
}; };
onConfirmResults: () => void; onConfirmResults: () => void;
onExportReport?: () => void;
jwtToken?: string | null; jwtToken?: string | null;
/** 下载前保存文档的回调,返回 true 表示保存成功可以继续下载 */ /** 下载前保存文档的回调,返回 true 表示保存成功可以继续下载 */
onSaveBeforeDownload?: () => Promise<boolean>; onSaveBeforeDownload?: () => Promise<boolean>;
} }
export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfirmResults, jwtToken, onSaveBeforeDownload }: ReviewTabsProps) { export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfirmResults, onExportReport, jwtToken, onSaveBeforeDownload }: ReviewTabsProps) {
const [isNavigating, setIsNavigating] = useState(false); const [isNavigating, setIsNavigating] = useState(false);
const [isReuploadModalOpen, setIsReuploadModalOpen] = useState(false); const [isReuploadModalOpen, setIsReuploadModalOpen] = useState(false);
const [selectedTemplateFiles, setSelectedTemplateFiles] = useState<File[]>([]); const [selectedTemplateFiles, setSelectedTemplateFiles] = useState<File[]>([]);
@@ -58,14 +59,21 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
: previousRoute === 'filesUpload' : previousRoute === 'filesUpload'
? "/files/upload" ? "/files/upload"
: "/rules-files"; : "/rules-files";
// 立即导航返回 navigate(returnTo);
navigate(returnTo); setTimeout(() => {
// 触发上级页面数据重新加载
revalidator.revalidate(); revalidator.revalidate();
setIsNavigating(false);
loadingBarService.hide();
}, 0);
}; };
// 下载原文件 // 下载原文件
const handleDownloadFile = async () => { const handleDownloadFile = async () => {
if (!fileInfo.path) {
toastService.warning('当前文档暂无可下载原文件');
return;
}
try { try {
// 如果有保存回调,先执行保存(仅对 DOCX 文件有效) // 如果有保存回调,先执行保存(仅对 DOCX 文件有效)
if (onSaveBeforeDownload) { if (onSaveBeforeDownload) {
@@ -311,12 +319,15 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
</> </>
)} )}
</button> </button>
{/* <button {onExportReport && (
className="ant-btn ant-btn-default flex items-center" <button
onClick={handleExportReport} className="ant-btn ant-btn-default inline-flex items-center my-2"
> onClick={onExportReport}
<i className="ri-file-copy-line mr-1"></i> 导出评查报告 disabled={isNavigating}
</button> */} >
<i className="ri-file-copy-line mr-1"></i>
</button>
)}
<button <button
className={`ant-btn ant-btn-primary my-2 flex items-center ${fileInfo.auditStatus === 1 ? 'hidden' : ''}`} className={`ant-btn ant-btn-primary my-2 flex items-center ${fileInfo.auditStatus === 1 ? 'hidden' : ''}`}
onClick={onConfirmResults} onClick={onConfirmResults}
@@ -437,4 +448,4 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
</Modal> </Modal>
</div> </div>
); );
} }
@@ -121,6 +121,17 @@ function getFieldBboxHighlight(point: ReviewPoint, key: string, page?: number):
}; };
} }
function formatPageLabel(page?: number): string {
if (!page || !Number.isFinite(page) || page <= 0) return '未定位';
return `${page}`;
}
function getPageBadgeClass(page?: number): string {
return page && Number.isFinite(page) && page > 0
? 'mt-0.5 inline-flex items-center rounded border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[10px] text-[#00684a] hover:bg-emerald-100'
: 'mt-0.5 inline-flex items-center rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-400';
}
function ExtractedFieldsPanel({ function ExtractedFieldsPanel({
reviewPoints, reviewPoints,
onFieldClick, onFieldClick,
@@ -218,16 +229,16 @@ function ExtractedFieldsPanel({
{f.page ? ( {f.page ? (
<button <button
type="button" type="button"
className="mt-0.5 text-[10px] text-[#00684a] hover:underline" className={getPageBadgeClass(f.page)}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight); handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight);
}} }}
> >
p.{f.page} {formatPageLabel(f.page)}
</button> </button>
) : ( ) : (
<div className="mt-0.5 text-[10px] text-slate-300">-</div> <div className={getPageBadgeClass()}></div>
)} )}
</div> </div>
</div> </div>
@@ -89,6 +89,17 @@ function getLeauditBboxHighlight(reviewPoint: ReviewPoint, fieldKey: string, pag
}; };
} }
function formatPageLabel(page?: number): string {
if (!page || !Number.isFinite(page) || page <= 0) return '未定位';
return `${page}`;
}
function getPageBadgeClass(page?: number): string {
return page && Number.isFinite(page) && page > 0
? 'inline-flex items-center rounded border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[10.5px] text-[#00684a]'
: 'inline-flex items-center rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10.5px] text-slate-400';
}
// ── Tooltip 系统 ── // ── Tooltip 系统 ──
let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false }; let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false };
function TooltipPortal() { function TooltipPortal() {
@@ -708,7 +719,9 @@ function LeauditReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
{enterpriseButton && enterpriseButton} {enterpriseButton && enterpriseButton}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{page && <span className="text-[10.5px] text-slate-400 shrink-0">P{page}</span>} <span className={`${getPageBadgeClass(page)} shrink-0`}>
{formatPageLabel(page)}
</span>
</div> </div>
</div> </div>
<div <div
+27 -5
View File
@@ -69,9 +69,21 @@ export type { UserRole };
// 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由) // 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由)
interface MenuItem { interface MenuItem {
path: string; path: string;
title?: string;
hideBreadcrumb?: boolean;
children?: MenuItem[]; children?: MenuItem[];
} }
function filterVisibleMenuItems(menuItems: MenuItem[]): MenuItem[] {
return menuItems
.filter((item) => !item.hideBreadcrumb)
.map((item) => ({
...item,
children: item.children ? filterVisibleMenuItems(item.children) : undefined,
}));
}
function extractAllPaths(menuItems: MenuItem[]): string[] { function extractAllPaths(menuItems: MenuItem[]): string[] {
const paths: string[] = []; const paths: string[] = [];
@@ -208,6 +220,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
let userInfo: any = null; let userInfo: any = null;
let allowedPaths: string[] = []; // 用户允许访问的路由列表 let allowedPaths: string[] = []; // 用户允许访问的路由列表
let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表 let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表
let menuItems: MenuItem[] = [];
if (!isPublicPath) { if (!isPublicPath) {
try { try {
@@ -252,6 +265,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
allowedPaths = extractAllPaths(routesResult.data); allowedPaths = extractAllPaths(routesResult.data);
// console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); // console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths);
menuItems = filterVisibleMenuItems(routesResult.data as MenuItem[]);
// ✅ 保存权限映射表 // ✅ 保存权限映射表
if (routesResult.permissionMap) { if (routesResult.permissionMap) {
permissionMap = routesResult.permissionMap; permissionMap = routesResult.permissionMap;
@@ -300,12 +315,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (error instanceof Error && error.name === 'AuthenticationError') { if (error instanceof Error && error.name === 'AuthenticationError') {
console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页"); console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页");
// 保存当前路径,登录后可以跳转回来 // 保存当前路径,登录后可以跳转回来
const redirectTo = pathname !== '/login' ? pathname : '/'; const redirectTo = pathname !== '/login' ? `${pathname}${url.search}` : '/';
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`); return redirect(`/login?expired=true&redirect=${encodeURIComponent(redirectTo)}`);
} }
console.warn("⚠️ [Root Loader] 获取用户会话失败:", error); console.warn("⚠️ [Root Loader] 获取用户会话失败:", error);
// 保持默认值 'common'
// 非公共页只要服务端会话初始化失败,就直接回登录页,
// 避免落入“角色=common + 菜单全空”的假登录状态。
if (!isPublicPath) {
const redirectTo = pathname !== '/login' ? `${pathname}${url.search}` : '/';
return redirect(`/login?expired=true&redirect=${encodeURIComponent(redirectTo)}`);
}
} }
// 注意:认证检查和重定向已在 getUserSession() 中统一处理 // 注意:认证检查和重定向已在 getUserSession() 中统一处理
@@ -360,6 +381,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
isPublicPath, // 传递给客户端,用于判断是否需要认证 isPublicPath, // 传递给客户端,用于判断是否需要认证
isMobile, // 🔒 传递移动端标识 isMobile, // 🔒 传递移动端标识
permissionMap, // ✅ 传递权限映射表 permissionMap, // ✅ 传递权限映射表
menuItems,
ENV: { ENV: {
// 客户端不再需要直接调用 Dify API // 客户端不再需要直接调用 Dify API
}, },
@@ -395,7 +417,7 @@ export function links() {
} }
export default function App() { export default function App() {
const { userRole, ENV, frontendJWT, userInfo, isPublicPath, isMobile } = useLoaderData<typeof loader>(); const { userRole, ENV, frontendJWT, userInfo, isPublicPath, isMobile, menuItems } = useLoaderData<typeof loader>();
return ( return (
@@ -435,7 +457,7 @@ export default function App() {
{isPublicPath ? ( {isPublicPath ? (
<Outlet /> <Outlet />
) : ( ) : (
<Layout userRole={userRole} frontendJWT={frontendJWT} isMobile={isMobile}> <Layout userRole={userRole} frontendJWT={frontendJWT} isMobile={isMobile} menuItems={menuItems}>
<Outlet /> <Outlet />
</Layout> </Layout>
)} )}
+4 -1
View File
@@ -26,7 +26,10 @@ interface LoaderData {
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
try { try {
const { getUserSession } = await import("~/api/login/auth.server"); const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request); const { frontendJWT, userInfo } = await getUserSession(request);
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission("/document-types", userInfo?.role || "", frontendJWT || undefined);
const rootsRes = await getDocumentTypeRoots({}, frontendJWT); const rootsRes = await getDocumentTypeRoots({}, frontendJWT);
return { return {
+3 -1
View File
@@ -33,7 +33,9 @@ interface LoaderData {
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const { getUserSession } = await import("~/api/login/auth.server"); const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request); const { frontendJWT, userInfo } = await getUserSession(request);
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission("/document-types/new", userInfo?.role || "", frontendJWT || undefined);
const url = new URL(request.url); const url = new URL(request.url);
const editId = url.searchParams.get("id"); const editId = url.searchParams.get("id");
+68 -17
View File
@@ -82,6 +82,8 @@ interface DocumentListScope {
documentTypeIds: number[]; documentTypeIds: number[];
} }
const LIST_SCOPE_STORAGE_KEY = 'documents.listScope';
// 审核状态筛选选项 // 审核状态筛选选项
const auditStatusOptions = [ const auditStatusOptions = [
// { value: "", label: "全部" }, // { value: "", label: "全部" },
@@ -257,6 +259,10 @@ export default function DocumentsIndex() {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString()); sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString());
} }
}; };
const persistListScope = useCallback((scope: DocumentListScope) => {
if (typeof window === 'undefined') return;
localStorage.setItem(LIST_SCOPE_STORAGE_KEY, JSON.stringify(scope));
}, []);
// 首次进入且 URL 无任何查询参数时,尝试从 sessionStorage 恢复 // 首次进入且 URL 无任何查询参数时,尝试从 sessionStorage 恢复
useEffect(() => { useEffect(() => {
@@ -311,7 +317,38 @@ export default function DocumentsIndex() {
} }
} }
return { selectedModuleId, documentTypeIds }; if (selectedModuleId || documentTypeIds.length > 0) {
const scope = { selectedModuleId, documentTypeIds };
persistListScope(scope);
return scope;
}
const storedScope = localStorage.getItem(LIST_SCOPE_STORAGE_KEY);
if (storedScope) {
try {
const parsedScope = JSON.parse(storedScope) as Partial<DocumentListScope>;
return {
selectedModuleId: Number.isFinite(Number(parsedScope.selectedModuleId)) && Number(parsedScope.selectedModuleId) > 0
? Number(parsedScope.selectedModuleId)
: null,
documentTypeIds: Array.isArray(parsedScope.documentTypeIds)
? parsedScope.documentTypeIds.map((item) => Number(item)).filter((item) => Number.isFinite(item) && item > 0)
: [],
};
} catch (error) {
console.error('解析 localStorage 文档列表作用域失败:', error);
}
}
return { selectedModuleId: null, documentTypeIds: [] };
}, [persistListScope]);
const isDeletableFileStatus = useCallback((status?: string | null) => {
return status === 'Processed' || status === 'Failed';
}, []);
const hasHistoryResultStats = useCallback((doc: DocumentVersionUI) => {
return [doc.pass_count, doc.warning_count, doc.error_count, doc.manual_count].some((value) => value !== null && value !== undefined);
}, []); }, []);
// 客户端数据请求 // 客户端数据请求
@@ -412,6 +449,7 @@ export default function DocumentsIndex() {
try { try {
const nextScope = readListScopeFromSession(); const nextScope = readListScopeFromSession();
setListScope(nextScope); setListScope(nextScope);
persistListScope(nextScope);
setScopeReady(true); setScopeReady(true);
} catch (error) { } catch (error) {
console.error('❌ [useEffect] 初始化文档列表作用域失败:', error); console.error('❌ [useEffect] 初始化文档列表作用域失败:', error);
@@ -421,7 +459,7 @@ export default function DocumentsIndex() {
setScopeReady(true); setScopeReady(true);
loadingBarService.hide(); loadingBarService.hide();
} }
}, [readListScopeFromSession]); }, [persistListScope, readListScopeFromSession]);
// 监听 URL 参数变化,重新获取数据 // 监听 URL 参数变化,重新获取数据
useEffect(() => { useEffect(() => {
@@ -628,6 +666,11 @@ export default function DocumentsIndex() {
// 下载文档 // 下载文档
const handleDownload = (path: string) => { const handleDownload = (path: string) => {
if (!path) {
toastService.warning('当前版本暂无可下载原文件');
return;
}
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证 // 使用 PDF 代理路由获取文件,自动添加 JWT 认证
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(path)}`; const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(path)}`;
@@ -650,7 +693,7 @@ export default function DocumentsIndex() {
// 删除文档 // 删除文档
const handleDelete = (id: string, name: string, fileStatus: string) => { const handleDelete = (id: string, name: string, fileStatus: string) => {
// 禁止删除处理中的文件 // 禁止删除处理中的文件
if (fileStatus !== "Processed" && fileStatus !== "Failed") { if (!isDeletableFileStatus(fileStatus)) {
toastService.warning("文件正在处理中,无法删除"); toastService.warning("文件正在处理中,无法删除");
return; return;
} }
@@ -684,7 +727,7 @@ export default function DocumentsIndex() {
// 检查是否有正在处理中的文件 // 检查是否有正在处理中的文件
const hasProcessingFiles = documents.some((doc: DocumentUI) => const hasProcessingFiles = documents.some((doc: DocumentUI) =>
selectedRowKeys.includes(doc.id.toString()) && doc.fileStatus !== 'Processed' selectedRowKeys.includes(doc.id.toString()) && !isDeletableFileStatus(doc.fileStatus)
); );
if (hasProcessingFiles) { if (hasProcessingFiles) {
@@ -1149,6 +1192,10 @@ export default function DocumentsIndex() {
// 渲染历史版本行的辅助函数 // 渲染历史版本行的辅助函数
const renderHistoryRow = (historyDoc: DocumentVersionUI, parentDoc: DocumentUI) => { const renderHistoryRow = (historyDoc: DocumentVersionUI, parentDoc: DocumentUI) => {
const canDownloadHistory = Boolean(historyDoc.path);
const canShowHistoryStats = hasHistoryResultStats(historyDoc);
const canAppendHistoryAssets = parentDoc.type === '1' && historyDoc.fileStatus === 'Processed' && canDownloadHistory;
return ( return (
<tr key={`history-${historyDoc.id}`} className="history-row"> <tr key={`history-${historyDoc.id}`} className="history-row">
<td className="align-middle px-4 py-3" style={{ width: '50px' }}> <td className="align-middle px-4 py-3" style={{ width: '50px' }}>
@@ -1194,16 +1241,20 @@ export default function DocumentsIndex() {
})()} })()}
</td> </td>
<td className="px-4 py-3" style={{ width: '15%' }}> <td className="px-4 py-3" style={{ width: '15%' }}>
<ResultStats {canShowHistoryStats ? (
passCount={historyDoc.pass_count} <ResultStats
warningCount={historyDoc.warning_count} passCount={historyDoc.pass_count}
errorCount={historyDoc.error_count} warningCount={historyDoc.warning_count}
manualCount={historyDoc.manual_count} errorCount={historyDoc.error_count}
previousPassCount={historyDoc.previous_pass_count} manualCount={historyDoc.manual_count}
previousWarningCount={historyDoc.previous_warning_count} previousPassCount={historyDoc.previous_pass_count}
previousErrorCount={historyDoc.previous_error_count} previousWarningCount={historyDoc.previous_warning_count}
previousManualCount={historyDoc.previous_manual_count} previousErrorCount={historyDoc.previous_error_count}
/> previousManualCount={historyDoc.previous_manual_count}
/>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td> </td>
<td className="text-xs text-gray-600 px-4 py-3" style={{ width: '10%' }}>{historyDoc.uploadTime}</td> <td className="text-xs text-gray-600 px-4 py-3" style={{ width: '10%' }}>{historyDoc.uploadTime}</td>
<td className="px-4 py-3" style={{ width: '25%' }}> <td className="px-4 py-3" style={{ width: '25%' }}>
@@ -1230,7 +1281,7 @@ export default function DocumentsIndex() {
</Link> </Link>
)} )}
{/* 下载按钮 - 需要 document:document:view 权限 */} {/* 下载按钮 - 需要 document:document:view 权限 */}
{canView && ( {canView && canDownloadHistory && (
<button <button
type="button" type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700" className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
@@ -1241,7 +1292,7 @@ export default function DocumentsIndex() {
</button> </button>
)} )}
{/* 追加附件和上传模板按钮 - 需要 document:document:view 权限 */} {/* 追加附件和上传模板按钮 - 需要 document:document:view 权限 */}
{canView && parentDoc.type === '1' && historyDoc.fileStatus === 'Processed' && ( {canView && canAppendHistoryAssets && (
<> <>
<button <button
type="button" type="button"
@@ -1272,7 +1323,7 @@ export default function DocumentsIndex() {
</> </>
)} )}
{/* 删除按钮 - 需要 document:document:view 权限 */} {/* 删除按钮 - 需要 document:document:view 权限 */}
{canView && ( {canView && isDeletableFileStatus(historyDoc.fileStatus) && (
<button <button
type="button" type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700" className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
+180 -166
View File
@@ -25,6 +25,7 @@ import {
type DocumentType, type DocumentType,
type DocumentSubtypeGroup, type DocumentSubtypeGroup,
type UploadErrorDetails, type UploadErrorDetails,
type UploadProgressInfo,
type UploadResult, type UploadResult,
DocumentStatus DocumentStatus
} from "~/api/files/files-upload"; } from "~/api/files/files-upload";
@@ -139,6 +140,7 @@ async function handleFileUpload(
createdBy?: number, createdBy?: number,
attachments?: File[], attachments?: File[],
jwtToken?: string, jwtToken?: string,
onProgress?: (progress: UploadProgressInfo) => void,
): Promise<UploadResult> { ): Promise<UploadResult> {
const speed = priority === Priority.NORMAL ? "normal" : "urgent"; const speed = priority === Priority.NORMAL ? "normal" : "urgent";
@@ -154,6 +156,7 @@ async function handleFileUpload(
true, true,
speed, speed,
jwtToken, jwtToken,
onProgress,
); );
if ("error" in response || !response.data) { if ("error" in response || !response.data) {
@@ -172,6 +175,48 @@ async function handleFileUpload(
return response.data; return response.data;
} }
function mapProcessingStatusToProgress(status: DocumentStatus): number {
switch (status) {
case DocumentStatus.QUEUED:
case DocumentStatus.WAITING:
case DocumentStatus.waiting:
return 15;
case DocumentStatus.CUTTING:
return 35;
case DocumentStatus.EXTRACTIONING:
return 60;
case DocumentStatus.EVALUATIONING:
return 85;
case DocumentStatus.PROCESSED:
return 100;
case DocumentStatus.FAILED:
return 0;
default:
return 15;
}
}
function mapProcessingStatusToSpeed(status: DocumentStatus): string {
switch (status) {
case DocumentStatus.QUEUED:
case DocumentStatus.WAITING:
case DocumentStatus.waiting:
return "排队中";
case DocumentStatus.CUTTING:
return "切分中";
case DocumentStatus.EXTRACTIONING:
return "抽取中";
case DocumentStatus.EVALUATIONING:
return "评查中";
case DocumentStatus.PROCESSED:
return "已完成";
case DocumentStatus.FAILED:
return "处理失败";
default:
return "处理中";
}
}
// 定义action返回数据的类型 // 定义action返回数据的类型
type ActionData = { type ActionData = {
errors?: { errors?: {
@@ -199,7 +244,7 @@ export async function action({ request }: ActionFunctionArgs) {
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
if (!fileType) { if (!fileType) {
errors.fileType = "上传文件之前请选择文类型"; errors.fileType = "上传文件之前请选择文类型";
} }
if (!fileUpload) { if (!fileUpload) {
@@ -408,6 +453,9 @@ export default function FilesUpload() {
const hasMultipleSubtypeGroups = subtypeGroups.length > 1; const hasMultipleSubtypeGroups = subtypeGroups.length > 1;
const singleSubtypeGroup = subtypeGroups.length === 1 ? subtypeGroups[0] : null; const singleSubtypeGroup = subtypeGroups.length === 1 ? subtypeGroups[0] : null;
const isSingleDefaultSubtype = !!(singleSubtypeGroup && singleSubtypeGroup.isDefault); const isSingleDefaultSubtype = !!(singleSubtypeGroup && singleSubtypeGroup.isDefault);
const effectiveSubtypeGroup = selectedSubtypeGroup || singleSubtypeGroup;
const effectiveDocumentTypeId = effectiveSubtypeGroup?.documentTypeId || null;
const effectiveGroupId = effectiveSubtypeGroup?.id || null;
const selectedRootGroupName = selectedSubtypeGroup?.rootGroupName || singleSubtypeGroup?.rootGroupName || ""; const selectedRootGroupName = selectedSubtypeGroup?.rootGroupName || singleSubtypeGroup?.rootGroupName || "";
const selectedEntryModuleName = selectedSubtypeGroup?.entryModuleName || singleSubtypeGroup?.entryModuleName || ""; const selectedEntryModuleName = selectedSubtypeGroup?.entryModuleName || singleSubtypeGroup?.entryModuleName || "";
@@ -476,7 +524,11 @@ export default function FilesUpload() {
const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined); const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined);
if (!cancelled && !scopedTypesResponse.error && scopedTypesResponse.data) { if (!cancelled && !scopedTypesResponse.error && scopedTypesResponse.data) {
scopedTypes = scopedTypesResponse.data; scopedTypes = scopedTypesResponse.data;
effectiveTypeIds = scopedTypes.map(type => type.id); effectiveTypeIds = Array.from(
new Set(
scopedTypes.flatMap((type) => type.childDocumentTypeIds || []),
),
);
} }
} }
@@ -488,13 +540,11 @@ export default function FilesUpload() {
filterDocumentTypes(effectiveTypeIds, scopedTypes, normalizedModuleId); filterDocumentTypes(effectiveTypeIds, scopedTypes, normalizedModuleId);
await filterDocuments(effectiveTypeIds); await filterDocuments(effectiveTypeIds);
if (effectiveTypeIds && effectiveTypeIds.includes(1)) { if (scopedTypes.length === 1) {
setIsContractType(true); const onlyType = scopedTypes[0];
const contractType = scopedTypes.find(type => type.id === 1); setFileType(onlyType.id.toString());
if (contractType) { setIsContractType(onlyType.name.includes('合同'));
setFileType(contractType.id.toString()); setFileTypeError(null);
setFileTypeError(null);
}
} else { } else {
setIsContractType(false); setIsContractType(false);
} }
@@ -527,7 +577,10 @@ export default function FilesUpload() {
} }
// 根据 documentTypeIds 过滤文档类型 // 根据 documentTypeIds 过滤文档类型
const filteredTypes = types.filter(type => documentTypeIds.includes(type.id)); const filteredTypes = types.filter(type =>
documentTypeIds.includes(type.id) ||
(type.childDocumentTypeIds || []).some((childId) => documentTypeIds.includes(childId))
);
setDocumentTypesState(filteredTypes); setDocumentTypesState(filteredTypes);
}; };
@@ -590,7 +643,6 @@ export default function FilesUpload() {
const [completedFiles, setCompletedFiles] = useState<UploadedFile[]>([]); const [completedFiles, setCompletedFiles] = useState<UploadedFile[]>([]);
// 计时器引用 - 分离为三个独立的定时器 // 计时器引用 - 分离为三个独立的定时器
const uploadProgressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const processingStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); const processingStatusIntervalRef = useRef<NodeJS.Timeout | null>(null);
const queueStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); // 原 statusCheckIntervalRef const queueStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); // 原 statusCheckIntervalRef
@@ -828,7 +880,9 @@ export default function FilesUpload() {
setFileTypeError(null); setFileTypeError(null);
// 检查是否选择了合同类型 // 检查是否选择了合同类型
const selectedType = loaderData.documentTypes.find(t => t.id.toString() === value); const selectedType =
documentTypesState.find(t => t.id.toString() === value) ||
loaderData.documentTypes.find(t => t.id.toString() === value);
const isContract = !!(selectedType && selectedType.name.includes('合同')); const isContract = !!(selectedType && selectedType.name.includes('合同'));
// console.log('【调试-handleFileTypeChange】文件类型检查:', { // console.log('【调试-handleFileTypeChange】文件类型检查:', {
// selectedType, // selectedType,
@@ -859,7 +913,7 @@ export default function FilesUpload() {
setFileType(""); setFileType("");
setIsContractType(false); setIsContractType(false);
// 如果用户选择了空选项,显示错误信息 // 如果用户选择了空选项,显示错误信息
setFileTypeError("上传文件之前请选择文类型"); setFileTypeError("上传文件之前请选择文类型");
} }
}; };
@@ -1264,7 +1318,12 @@ export default function FilesUpload() {
const mainFile = mainFiles[0]; const mainFile = mainFiles[0];
// 检查文档名称是否重复 // 检查文档名称是否重复
const duplicateResult = await checkDocumentDuplicate(mainFile.name, Number(fileType)); if (!effectiveDocumentTypeId) {
toastService.error('当前子类型缺少可提交的文档类型绑定,请先检查评查点分组配置');
return;
}
const duplicateResult = await checkDocumentDuplicate(mainFile.name, effectiveDocumentTypeId);
if (duplicateResult.is_duplicate) { if (duplicateResult.is_duplicate) {
const confirmed = window.confirm( const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。' '存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
@@ -1291,31 +1350,8 @@ export default function FilesUpload() {
setProcessingSteps(updatedSteps); setProcessingSteps(updatedSteps);
} }
// 计算总大小并开启与旧逻辑一致的模拟进度(按时间推进到 95%)
const totalSize = filesForProgress.reduce((sum, f) => sum + (f?.size || 0), 0); const totalSize = filesForProgress.reduce((sum, f) => sum + (f?.size || 0), 0);
const startTime = Date.now(); setUploadSpeed("上传中");
let lastUpdateTime = startTime;
let lastRatio = 0;
const estimatedUploadTime = Math.max(
(totalSize / (1024 * 1024)) / 3 * 1000, // 3MB/s 估算
1000
);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
uploadProgressIntervalRef.current = setInterval(() => {
const now = Date.now();
const deltaSec = (now - lastUpdateTime) / 1000;
const ratio = Math.min((now - startTime) / estimatedUploadTime, 0.95);
// 计算瞬时速度(基于比例变化)
const deltaRatio = Math.max(ratio - lastRatio, 0);
const bytesPerSec = deltaSec > 0 ? (totalSize * deltaRatio) / deltaSec : 0;
lastRatio = ratio;
lastUpdateTime = now;
setUploadSpeed(`${formatFileSize(bytesPerSec)}/s`);
setUploadProgress(parseFloat((ratio * 100).toFixed(2)));
}, 200);
// 转二进制 // 转二进制
const binaryData = await uploadFileToBinary(mainFile); const binaryData = await uploadFileToBinary(mainFile);
@@ -1324,9 +1360,20 @@ export default function FilesUpload() {
const region = (loaderData.userInfo?.area as string) || "default"; const region = (loaderData.userInfo?.area as string) || "default";
const createdBy = loaderData.userInfo?.user_id as number | undefined; const createdBy = loaderData.userInfo?.user_id as number | undefined;
const uploadResp = await handleFileUpload( const uploadResp = await handleFileUpload(
binaryData, mainFile.name, mainFile.type, binaryData,
fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority, mainFile.name,
region, createdBy, attachmentFiles, loaderData.frontendJWT || undefined, mainFile.type,
String(effectiveDocumentTypeId),
effectiveGroupId,
priority,
region,
createdBy,
attachmentFiles,
loaderData.frontendJWT || undefined,
(progress) => {
setUploadProgress(progress.percent);
setUploadSpeed(`${formatFileSize(progress.loaded)}/${formatFileSize(progress.total)}`);
},
); );
if (!uploadResp.success) { if (!uploadResp.success) {
@@ -1350,10 +1397,6 @@ export default function FilesUpload() {
} }
// 完成:清理进度定时器并置满 // 完成:清理进度定时器并置满
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
setUploadProgress(100); setUploadProgress(100);
setUploadSpeed('完成'); setUploadSpeed('完成');
@@ -1378,11 +1421,6 @@ export default function FilesUpload() {
await filterDocuments(documentTypeIds); await filterDocuments(documentTypeIds);
} catch (error) { } catch (error) {
console.error('合同首传上传失败:', error); console.error('合同首传上传失败:', error);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
// 清空合同模板文件缓存 // 清空合同模板文件缓存
setContractTemplateFiles([]); setContractTemplateFiles([]);
console.log('【合同上传失败】已清空合同模板文件缓存'); console.log('【合同上传失败】已清空合同模板文件缓存');
@@ -1412,7 +1450,7 @@ export default function FilesUpload() {
// 检查是否选择了文件类型 // 检查是否选择了文件类型
if (!fileType) { if (!fileType) {
console.error('【调试-checkAndPrepareUpload】未选择文件类型'); console.error('【调试-checkAndPrepareUpload】未选择文件类型');
toastService.error('请先选择文类型'); toastService.error('请先选择文类型');
return; return;
} }
if (subtypeGroups.length > 1 && !selectedGroupId) { if (subtypeGroups.length > 1 && !selectedGroupId) {
@@ -1421,7 +1459,7 @@ export default function FilesUpload() {
} }
// 检查是否为合同类型 // 检查是否为合同类型
const selectedType = loaderData.documentTypes.find(t => t.id.toString() === fileType); const selectedType = getSelectedDocumentType();
const isContract = !!(selectedType && selectedType.name.includes('合同')); const isContract = !!(selectedType && selectedType.name.includes('合同'));
// console.log('【调试-checkAndPrepareUpload】文件类型检查', { // console.log('【调试-checkAndPrepareUpload】文件类型检查', {
@@ -1454,7 +1492,12 @@ export default function FilesUpload() {
// 检查主文件名称是否重复(在任何状态变化之前进行检查) // 检查主文件名称是否重复(在任何状态变化之前进行检查)
const mainFile = allFiles[0]; const mainFile = allFiles[0];
const duplicateResult = await checkDocumentDuplicate(mainFile.name, Number(fileType)); if (!effectiveDocumentTypeId) {
toastService.error('当前子类型缺少可提交的文档类型绑定,请先检查评查点分组配置');
return;
}
const duplicateResult = await checkDocumentDuplicate(mainFile.name, effectiveDocumentTypeId);
if (duplicateResult.is_duplicate) { if (duplicateResult.is_duplicate) {
const confirmed = window.confirm( const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。' '存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
@@ -1505,8 +1548,8 @@ export default function FilesUpload() {
} }
} }
} else { } else {
console.error('【调试-checkAndPrepareUpload】未选择文类型,无法上传'); console.error('【调试-checkAndPrepareUpload】未选择文类型,无法上传');
toastService.error('请选择文类型'); toastService.error('请选择文类型');
} }
} else { } else {
console.error('【调试-checkAndPrepareUpload】没有文件可上传'); console.error('【调试-checkAndPrepareUpload】没有文件可上传');
@@ -1551,13 +1594,12 @@ export default function FilesUpload() {
setUploadStage("uploading"); setUploadStage("uploading");
setUploadProgress(0); setUploadProgress(0);
setUploadSpeed("上传中");
// 计算总文件大小 // 计算总文件大小
const totalSize = files.reduce((sum, file) => sum + file.size, 0); const totalSize = files.reduce((sum, file) => sum + file.size, 0);
let uploadedSize = 0; let uploadedSize = 0;
// console.log('【调试-startUpload】总文件大小:', formatFileSize(totalSize));
// 更新步骤状态 // 更新步骤状态
const updatedSteps = [...processingSteps]; const updatedSteps = [...processingSteps];
updatedSteps[0].status = "active"; updatedSteps[0].status = "active";
@@ -1571,33 +1613,6 @@ export default function FilesUpload() {
return; return;
} }
// 转换文件为二进制格式
// console.log("【调试-startUpload】开始转换文件到二进制格式...");
// 模拟上传进度
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
const startTime = Date.now();
let lastUploadedSize = 0;
let lastUpdateTime = startTime;
uploadProgressIntervalRef.current = setInterval(() => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastUpdateTime) / 1000; // 使用最近一次更新的时间间隔
const currentSpeed = timeElapsed > 0 ? (uploadedSize - lastUploadedSize) / timeElapsed : 0; // 字节/秒
lastUploadedSize = uploadedSize;
lastUpdateTime = currentTime;
// 更新上传速度显示
setUploadSpeed(`${formatFileSize(currentSpeed)}/s`);
// 更新进度 - 保留2位小数
const progress = Math.min((uploadedSize / totalSize) * 100, 99.99);
setUploadProgress(parseFloat(progress.toFixed(2)));
}, 200); // 改为200ms更新一次,提供更准确的速度计算
// 上传所有文件 // 上传所有文件
const uploadedFiles: UploadedFile[] = []; const uploadedFiles: UploadedFile[] = [];
@@ -1629,44 +1644,35 @@ export default function FilesUpload() {
// console.log(`【调试-startUpload】准备上传文件 ${file.name} 到服务器`); // console.log(`【调试-startUpload】准备上传文件 ${file.name} 到服务器`);
// 创建基于时间的渐进式进度模拟
const startUploadTime = Date.now();
// 根据文件大小动态估算上传时间,考虑网络速度
const estimatedUploadTime = Math.max(
file.size / (1024 * 1024) / 3 * 1000, // 假设3MB/s的速度,1MB需要1/3秒
1000 // 最小1秒
);
let progressInterval: NodeJS.Timeout | null = null;
// 开始渐进式进度更新
const progressPromise = new Promise<void>((resolve) => {
progressInterval = setInterval(() => {
const elapsed = Date.now() - startUploadTime;
const progressRatio = Math.min(elapsed / estimatedUploadTime, 0.95); // 最大95%
// 计算当前文件的进度贡献
const fileProgress = progressRatio * file.size;
const previousFilesSize = files.slice(0, temp_n - 1).reduce((sum, f) => sum + f.size, 0);
uploadedSize = previousFilesSize + fileProgress;
// 如果接近完成,停止进度更新并resolve
if (progressRatio >= 0.95) {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
resolve();
}
}, 100); // 改为100ms更新一次,提供更流畅的进度
});
// 使用Promise.race添加超时处理 // 使用Promise.race添加超时处理
const region = (loaderData.userInfo?.area as string) || "default"; const region = (loaderData.userInfo?.area as string) || "default";
const createdBy = loaderData.userInfo?.user_id as number | undefined; const createdBy = loaderData.userInfo?.user_id as number | undefined;
if (!effectiveDocumentTypeId) {
throw new Error("当前子类型缺少可提交的文档类型绑定,请先检查评查点分组配置");
}
const uploadPromise = handleFileUpload( const uploadPromise = handleFileUpload(
binaryData, file.name, file.type, binaryData,
fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority, file.name,
region, createdBy, undefined, loaderData.frontendJWT || undefined, file.type,
String(effectiveDocumentTypeId),
effectiveGroupId,
priority,
region,
createdBy,
undefined,
loaderData.frontendJWT || undefined,
(progress) => {
const previousFilesSize = files
.slice(0, temp_n - 1)
.reduce((sum, currentFile) => sum + currentFile.size, 0);
const currentLoaded = Math.min(progress.loaded, file.size);
uploadedSize = previousFilesSize + currentLoaded;
const overallProgress = totalSize > 0 ? (uploadedSize / totalSize) * 100 : 0;
setUploadProgress(parseFloat(Math.min(overallProgress, 100).toFixed(2)));
setUploadSpeed(
`${formatFileSize(previousFilesSize + currentLoaded)}/${formatFileSize(totalSize)}`,
);
},
); );
const timeoutPromise = new Promise<UploadResult>((_, reject) => { const timeoutPromise = new Promise<UploadResult>((_, reject) => {
@@ -1675,16 +1681,7 @@ export default function FilesUpload() {
}, 600000); }, 600000);
}); });
// 并行执行上传和进度更新 const uploadResult = await Promise.race([uploadPromise, timeoutPromise]);
const [uploadResult] = await Promise.all([
Promise.race([uploadPromise, timeoutPromise]),
progressPromise
]);
// 清除进度定时器
if (progressInterval) {
clearInterval(progressInterval);
}
// 再次检查组件是否已卸载 // 再次检查组件是否已卸载
if (!isMountedRef.current) { if (!isMountedRef.current) {
@@ -1748,10 +1745,6 @@ export default function FilesUpload() {
} }
// 清除进度定时器 // 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
// 更新上传状态 // 更新上传状态
setUploadProgress(100); setUploadProgress(100);
setUploadSpeed("完成"); setUploadSpeed("完成");
@@ -1763,7 +1756,7 @@ export default function FilesUpload() {
return { return {
id, id,
name: file.name, name: file.name,
type_id: fileType ? parseInt(fileType) : 0, type_id: effectiveDocumentTypeId || (fileType ? parseInt(fileType) : 0),
file_size: file.size, file_size: file.size,
status: DocumentStatus.CUTTING, status: DocumentStatus.CUTTING,
created_at: new Date().toISOString() created_at: new Date().toISOString()
@@ -1789,11 +1782,6 @@ export default function FilesUpload() {
setProcessingSteps(errorSteps); setProcessingSteps(errorSteps);
// 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
showFriendlyUploadError(error, { titlePrefix: '文件上传失败' }); showFriendlyUploadError(error, { titlePrefix: '文件上传失败' });
resetUpload(); resetUpload();
@@ -1824,6 +1812,8 @@ export default function FilesUpload() {
updatedSteps[1].description = "文档正在排队等待处理..."; updatedSteps[1].description = "文档正在排队等待处理...";
setProcessingSteps(updatedSteps); setProcessingSteps(updatedSteps);
setUploadProgress(mapProcessingStatusToProgress(DocumentStatus.QUEUED));
setUploadSpeed(mapProcessingStatusToSpeed(DocumentStatus.QUEUED));
// 获取文件ID列表 // 获取文件ID列表
const fileIds = files.map(file => file.id).filter(id => id > 0); const fileIds = files.map(file => file.id).filter(id => id > 0);
@@ -1925,6 +1915,17 @@ export default function FilesUpload() {
// console.log('【调试-checkProcessingStatus】没有返回文件状态数据'); // console.log('【调试-checkProcessingStatus】没有返回文件状态数据');
return; return;
} }
const hasFailed = response.data.some(doc => doc.status === DocumentStatus.FAILED);
if (hasFailed) {
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null;
}
updateProcessingSteps(DocumentStatus.FAILED);
updateQueueFilesStatus(response.data);
return;
}
// 检查是否所有文件都已完成处理 // 检查是否所有文件都已完成处理
const allCompleted = response.data.every(doc => doc.status === DocumentStatus.PROCESSED); const allCompleted = response.data.every(doc => doc.status === DocumentStatus.PROCESSED);
@@ -1955,6 +1956,8 @@ export default function FilesUpload() {
completedSteps[5].description = "文档已准备就绪,可以查看"; completedSteps[5].description = "文档已准备就绪,可以查看";
setProcessingSteps(completedSteps); setProcessingSteps(completedSteps);
setUploadProgress(100);
setUploadSpeed(mapProcessingStatusToSpeed(DocumentStatus.PROCESSED));
setUploadStage("completed"); setUploadStage("completed");
} else { } else {
// 根据当前状态更新步骤 // 根据当前状态更新步骤
@@ -2036,9 +2039,18 @@ export default function FilesUpload() {
updatedSteps[5].status = "done"; updatedSteps[5].status = "done";
updatedSteps[5].description = "文档已准备就绪,可以查看"; updatedSteps[5].description = "文档已准备就绪,可以查看";
break; break;
case DocumentStatus.FAILED:
updatedSteps[1].status = "done";
updatedSteps[1].description = "已进入处理队列";
updatedSteps[2].status = "error";
updatedSteps[2].description = "文档处理失败,请检查上传文件或稍后重试";
break;
} }
setProcessingSteps(updatedSteps); setProcessingSteps(updatedSteps);
setUploadProgress(mapProcessingStatusToProgress(status));
setUploadSpeed(mapProcessingStatusToSpeed(status));
}; };
// 更新队列中文件的状态 // 更新队列中文件的状态
@@ -2063,12 +2075,6 @@ export default function FilesUpload() {
// 重置上传状态 - 不清除队列状态检查定时器 // 重置上传状态 - 不清除队列状态检查定时器
const resetUpload = () => { const resetUpload = () => {
// 清除上传和处理相关的定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
if (processingStatusIntervalRef.current) { if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current); clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null; processingStatusIntervalRef.current = null;
@@ -2136,7 +2142,11 @@ export default function FilesUpload() {
// 获取文档类型名称 // 获取文档类型名称
const getDocumentTypeName = (codeId: number) => { const getDocumentTypeName = (codeId: number) => {
const type = documentTypesState.find(t => t.id === codeId); const type = documentTypesState.find(t => t.id === codeId);
return type ? type.name : '未知类型'; if (type) {
return type.name;
}
const matchedRoot = documentTypesState.find((item) => (item.childDocumentTypeIds || []).includes(codeId));
return matchedRoot ? matchedRoot.name : '未知类型';
}; };
// 处理查看文件 // 处理查看文件
@@ -2390,10 +2400,10 @@ export default function FilesUpload() {
{/* 文件类型选择和上传表单 */} {/* 文件类型选择和上传表单 */}
<Form method="post" encType="multipart/form-data" ref={formRef}> <Form method="post" encType="multipart/form-data" ref={formRef}>
{/* 文件类型选择 */} {/* 文件类型选择 */}
<Card title={<h3></h3>} className="mb-4"> <Card title={<h3></h3>} className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="form-group"> <div className="form-group">
<label htmlFor="file-type-select" className="form-label"> <span className="text-red-500">*</span></label> <label htmlFor="file-type-select" className="form-label"> <span className="text-red-500">*</span></label>
<select <select
id="file-type-select" id="file-type-select"
name="fileType" name="fileType"
@@ -2402,7 +2412,7 @@ export default function FilesUpload() {
onChange={handleFileTypeChange} onChange={handleFileTypeChange}
disabled={uploadStage !== "idle"} disabled={uploadStage !== "idle"}
> >
<option value=""></option> <option value=""></option>
{documentTypesState.map(type => ( {documentTypesState.map(type => (
<option key={type.id} value={type.id}>{type.name}</option> <option key={type.id} value={type.id}>{type.name}</option>
))} ))}
@@ -2413,7 +2423,7 @@ export default function FilesUpload() {
<div className="text-red-500 text-sm mt-1">{fileTypeError}</div> <div className="text-red-500 text-sm mt-1">{fileTypeError}</div>
)} )}
<div className="form-tip"></div> <div className="form-tip"></div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="priority-select" className="form-label"></label> <label htmlFor="priority-select" className="form-label"></label>
@@ -2446,11 +2456,11 @@ export default function FilesUpload() {
required={subtypeGroups.length > 1} required={subtypeGroups.length > 1}
> >
{!fileType ? ( {!fileType ? (
<option value=""></option> <option value=""></option>
) : groupOptionsLoading ? ( ) : groupOptionsLoading ? (
<option value="">...</option> <option value="">...</option>
) : subtypeGroups.length === 0 ? ( ) : subtypeGroups.length === 0 ? (
<option value=""></option> <option value=""></option>
) : subtypeGroups.length === 1 ? ( ) : subtypeGroups.length === 1 ? (
<option value={String(subtypeGroups[0].id)}> <option value={String(subtypeGroups[0].id)}>
{getSubtypeDisplayName(subtypeGroups[0])} {getSubtypeDisplayName(subtypeGroups[0])}
@@ -2468,27 +2478,35 @@ export default function FilesUpload() {
</select> </select>
<div className="form-tip"> <div className="form-tip">
{!fileType {!fileType
? "请先选择文件类型,再确定本次上传实际命中的子类型。" ? "请先选择一级文档类型,再确定本次上传实际命中的子类型。"
: groupOptionsLoading : groupOptionsLoading
? "正在加载当前文档类型下可用的子类型配置。" ? "正在加载当前一级文档类型下可用的子类型配置。"
: subtypeGroups.length === 0 : subtypeGroups.length === 0
? "当前文档类型在当前入口下还没有可用子类型,请先到评查点分组管理补齐“一级分组 / 二级分组 / 规则集”绑定。" ? "当前一级文档类型在当前入口下还没有可用子类型,请先到评查点分组管理补齐“一级分组 / 二级分组 / 规则集”绑定。"
: hasMultipleSubtypeGroups : hasMultipleSubtypeGroups
? "同一文档类型在当前入口下已拆分多个子类型,请选择本次上传实际命中的子类型。" ? "当前一级文档类型在当前入口下已拆分多个子类型,请选择本次上传实际命中的子类型。"
: isSingleDefaultSubtype : isSingleDefaultSubtype
? "当前文档类型在当前入口下尚未拆分业务子类型,系统将按默认子类型处理;后续可在评查点分组管理中继续细分。" ? "当前一级文档类型在当前入口下尚未拆分业务子类型,系统将按默认子类型处理;后续可在评查点分组管理中继续细分。"
: "当前文档类型在当前入口下仅配置了个子类型,系统自动带出该子类型。"} : "当前一级文档类型在当前入口下仅配置了 1 个子类型,系统自动带出。"}
</div> </div>
{selectedRootGroupName ? ( {selectedRootGroupName ? (
<div className="form-tip"> <div className="form-tip">
{selectedRootGroupName} {selectedRootGroupName}
{selectedEntryModuleName ? ` · 入口模块:${selectedEntryModuleName}` : ""}
</div> </div>
) : null} ) : null}
{selectedSubtypeGroup ? ( {selectedEntryModuleName ? (
<div className="form-tip"> <div className="form-tip">
{getSubtypeDisplayName(selectedSubtypeGroup)} {selectedEntryModuleName}
{selectedSubtypeGroup.displayHint ? ` · ${selectedSubtypeGroup.displayHint}` : ""} </div>
) : null}
{effectiveSubtypeGroup ? (
<div className="form-tip">
{getSubtypeDisplayName(effectiveSubtypeGroup)}
</div>
) : null}
{effectiveSubtypeGroup?.code ? (
<div className="form-tip">
{effectiveSubtypeGroup.code}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -2802,7 +2820,7 @@ export default function FilesUpload() {
fileName={`${currentFiles.length}个文件`} fileName={`${currentFiles.length}个文件`}
fileSize={formatFileSize(currentFiles.reduce((sum, file) => sum + file.size, 0))} fileSize={formatFileSize(currentFiles.reduce((sum, file) => sum + file.size, 0))}
progress={uploadProgress} progress={uploadProgress}
speed={''} speed={uploadSpeed}
/> />
)} )}
@@ -2867,10 +2885,6 @@ export default function FilesUpload() {
type="default" type="default"
icon="ri-refresh-line" icon="ri-refresh-line"
onClick={() => { onClick={() => {
// 清除所有定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
if (processingStatusIntervalRef.current) { if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current); clearInterval(processingStatusIntervalRef.current);
} }
+33 -8
View File
@@ -25,7 +25,7 @@
* @author 中国烟草AI合同及卷宗审核系统开发团队 * @author 中国烟草AI合同及卷宗审核系统开发团队
*/ */
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node"; import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react"; import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
import reviewsStyles from "~/styles/reviews.css?url"; import reviewsStyles from "~/styles/reviews.css?url";
@@ -345,17 +345,24 @@ export const handle = {
}; };
export async function loader({ request }: LoaderFunctionArgs): Promise<Response> { export async function loader({ request }: LoaderFunctionArgs): Promise<Response> {
try { const url = new URL(request.url);
const url = new URL(request.url); const id = url.searchParams.get('id') || '';
const id = url.searchParams.get('id') || ''; const previousRoute = url.searchParams.get('previousRoute') || '';
const previousRoute = url.searchParams.get('previousRoute') || '';
try {
if (!id) { if (!id) {
return Response.json({ result: false, message: '文件ID不能为空', previousRoute }); return Response.json({ result: false, message: '文件ID不能为空', previousRoute });
} }
const { getUserSession } = await import("~/api/login/auth.server"); const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request); const { userInfo, frontendJWT } = await getUserSession(request);
if (!frontendJWT || !userInfo?.role) {
throw redirect('/login');
}
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission('/reviewsTest', userInfo.role, frontendJWT);
const reviewData = await getReviewPoints_fromApi(id, request); const reviewData = await getReviewPoints_fromApi(id, request);
if ('error' in reviewData && reviewData.error) { if ('error' in reviewData && reviewData.error) {
@@ -382,11 +389,25 @@ export async function loader({ request }: LoaderFunctionArgs): Promise<Response>
detailMode: 'leaudit', detailMode: 'leaudit',
}); });
} catch (error) { } catch (error) {
if (error instanceof Response) {
if (error.status === 401) {
throw redirect('/login');
}
if (error.status === 403) {
return Response.json({
result: false,
message: '当前账号没有评查详情访问权限,请联系管理员开通文档查看权限。',
previousRoute,
}, { status: 403 });
}
}
console.error('[reviewsTest loader] Failed to load review data:', error); console.error('[reviewsTest loader] Failed to load review data:', error);
return Response.json({ return Response.json({
result: false, result: false,
message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}`, message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}`,
previousRoute: '', previousRoute,
}); });
} }
} }
@@ -621,6 +642,11 @@ export default function ReviewDetails() {
}; };
const handleDownloadFile = async () => { const handleDownloadFile = async () => {
if (!previewPath) {
toastService.warning('当前文档暂无可下载原文件');
return;
}
try { try {
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(previewPath)}`; const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(previewPath)}`;
const response = await axios.get(downloadUrl, { responseType: 'blob' }); const response = await axios.get(downloadUrl, { responseType: 'blob' });
@@ -828,8 +854,7 @@ export default function ReviewDetails() {
if (result.success) { if (result.success) {
toastService.success('评查结果已确认,文档审核状态已更新'); toastService.success('评查结果已确认,文档审核状态已更新');
// 导航到文档列表页 navigate(getReturnUrl());
navigate('/documents/list');
} else { } else {
console.error('确认评查结果失败:', result.error); console.error('确认评查结果失败:', result.error);
toastService.error(`确认评查结果失败: ${result.error || '未知错误'}`); toastService.error(`确认评查结果失败: ${result.error || '未知错误'}`);
+369 -31
View File
@@ -1,5 +1,5 @@
import { type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; import { type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react"; import { useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios"; import axios from "axios";
@@ -15,6 +15,7 @@ import {
import { Button } from "~/components/ui/Button"; import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card"; import { Card } from "~/components/ui/Card";
import { API_BASE_URL } from "~/config/api-config"; import { API_BASE_URL } from "~/config/api-config";
import { usePermission } from "~/hooks/usePermission";
import { parseRuleSummariesFromYaml, type RuleSummary } from "~/utils/rule-yaml-parser"; import { parseRuleSummariesFromYaml, type RuleSummary } from "~/utils/rule-yaml-parser";
export function links() { export function links() {
@@ -71,6 +72,32 @@ interface LoaderData {
frontendJWT?: string | null; frontendJWT?: string | null;
} }
interface RuleTemplatePayload {
groupId?: number;
groupName?: string;
parentGroupName?: string;
documentTypeName?: string;
entryModuleName?: string;
ruleType?: string;
ruleName?: string;
yamlTemplate?: string;
yamlText?: string;
ossPreviewKey?: string;
}
interface RuleDraftCreateResult {
packId?: number | null;
groupId?: number | null;
groupName?: string | null;
ruleSetId?: number | null;
ruleSetName?: string | null;
ruleName?: string | null;
ruleType?: string | null;
versionId?: number | null;
versionNo?: string | null;
bindingId?: number | null;
}
interface GroupFormState { interface GroupFormState {
id?: number; id?: number;
mode: "create" | "edit"; mode: "create" | "edit";
@@ -119,22 +146,32 @@ async function fetchGroupTree(token?: string | null): Promise<RuleGroupNode[]> {
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const { getUserSession } = await import("~/api/login/auth.server"); const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request); const { frontendJWT, userInfo } = await getUserSession(request);
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
const [groups, docTypesRes, entryModulesRes, ruleSetsRes] = await Promise.all([ await requireRoutePermission("/rule-groups", userInfo?.role || "", frontendJWT || undefined);
fetchGroupTree(frontendJWT),
getDocumentTypes({ page: 1, pageSize: 500 }, frontendJWT),
getEntryModules(frontendJWT),
getRuleSets(frontendJWT),
]);
return Response.json({ try {
groups, const [groups, docTypesRes, entryModulesRes, ruleSetsRes] = await Promise.all([
docTypes: docTypesRes.data?.types || [], fetchGroupTree(frontendJWT),
entryModules: entryModulesRes.data || [], getDocumentTypes({ page: 1, pageSize: 500 }, frontendJWT),
ruleSets: ruleSetsRes.data || [], getEntryModules(frontendJWT),
frontendJWT, getRuleSets(frontendJWT),
} satisfies LoaderData); ]);
return Response.json({
groups,
docTypes: docTypesRes.data?.types || [],
entryModules: entryModulesRes.data || [],
ruleSets: ruleSetsRes.data || [],
frontendJWT,
} satisfies LoaderData);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 403) {
throw new Response("无权访问评查点分组页面", { status: 403 });
}
throw error;
}
} }
function formatVersionLabel(binding: BindingItem): string { function formatVersionLabel(binding: BindingItem): string {
@@ -278,6 +315,75 @@ type RulePreviewState = {
rules: RuleSummary[]; rules: RuleSummary[];
}; };
type RuleDraftFormState = {
groupId: number;
yamlText: string;
changeNote: string;
template: RuleTemplatePayload | null;
loading: boolean;
saving: boolean;
error: string | null;
success: RuleDraftCreateResult | null;
};
function unwrapApiData<T>(payload: any): T {
return (payload?.data ?? payload) as T;
}
function normalizeRuleTemplatePayload(payload: any): RuleTemplatePayload {
const item = unwrapApiData<any>(payload) || {};
const context = item.context || {};
return {
groupId: item.groupId ?? item.group_id ?? context.groupId ?? context.group_id,
groupName: item.groupName ?? item.group_name ?? context.groupName ?? context.group_name,
parentGroupName: item.parentGroupName ?? item.root_group_name ?? context.parentGroupName ?? context.parent_group_name,
documentTypeName: item.documentTypeName ?? item.document_type_name ?? context.documentTypeName ?? context.document_type_name,
entryModuleName: item.entryModuleName ?? item.entry_module_name ?? context.entryModuleName ?? context.entry_module_name,
ruleType: item.ruleType ?? item.rule_type,
ruleName: item.ruleName ?? item.rule_name,
yamlTemplate: item.yamlTemplate ?? item.yaml_template,
yamlText: item.yamlText ?? item.yaml_text,
ossPreviewKey: item.ossPreviewKey ?? item.oss_preview_key,
};
}
function normalizeRuleDraftCreateResult(payload: any): RuleDraftCreateResult {
const item = unwrapApiData<any>(payload) || {};
const createdVersion = item.createdVersion ?? item.created_version ?? {};
const binding = item.binding ?? {};
return {
packId: item.packId ?? item.pack_id ?? item.groupId ?? item.group_id,
groupId: item.groupId ?? item.group_id,
groupName: item.groupName ?? item.group_name,
ruleSetId: item.ruleSetId ?? item.rule_set_id ?? createdVersion.ruleSetId ?? createdVersion.rule_set_id,
ruleSetName: item.ruleSetName ?? item.rule_set_name ?? binding.rule_name,
ruleName: item.ruleName ?? item.rule_name ?? binding.rule_name,
ruleType: item.ruleType ?? item.rule_type ?? binding.rule_type,
versionId: item.versionId ?? item.version_id ?? createdVersion.id,
versionNo: item.versionNo ?? item.version_no ?? createdVersion.versionNo ?? createdVersion.version_no,
bindingId: item.bindingId ?? item.binding_id ?? binding.id,
};
}
function findGroupNode(groups: RuleGroupNode[], groupId: number): RuleGroupNode | null {
for (const root of groups) {
if (root.id === groupId) return root;
const child = (root.children || []).find((item) => item.id === groupId);
if (child) return child;
}
return null;
}
function buildRuleDraftJumpUrl(group: RuleGroupNode | null, success: RuleDraftCreateResult | null, template: RuleTemplatePayload | null): string {
if (success?.packId) {
return `/rulesTest/detail?packId=${encodeURIComponent(String(success.packId))}`;
}
const params = new URLSearchParams();
const keyword = success?.ruleType || template?.ruleType || group?.code || group?.name || "";
if (keyword) params.set("keyword", keyword);
return `/rulesTest/list${params.toString() ? `?${params.toString()}` : ""}`;
}
function GroupModal({ function GroupModal({
visible, visible,
form, form,
@@ -506,10 +612,125 @@ function BindingModal({
); );
} }
function RuleDraftModal({
visible,
group,
form,
onClose,
onChange,
onSubmit,
onOpenRules,
}: {
visible: boolean;
group: RuleGroupNode | null;
form: RuleDraftFormState;
onClose: () => void;
onChange: (patch: Partial<RuleDraftFormState>) => void;
onSubmit: () => void;
onOpenRules: () => void;
}) {
if (!visible || !group) return null;
const template = form.template;
return (
<div className="rg-modal-backdrop">
<div className="rg-modal large">
<div className="rg-modal-header">
<h3> / YAML</h3>
<button type="button" className="icon-button" onClick={onClose}>
<i className="ri-close-line"></i>
</button>
</div>
<div className="rg-modal-body grid-form">
<div className="form-item form-item-span-2">
<div className="modal-tip">
<strong>{getSubtypeGroupDisplayName(group)}</strong>
<span>
{template?.parentGroupName || "-"} · {group.document_type_name || template?.documentTypeName || "-"}
{` · 入口模块:${group.entry_module_name || template?.entryModuleName || "-"}`}
</span>
</div>
</div>
<div className="form-item form-item-span-2">
<div className="detail-alert info compact">
<strong> YAML 稿</strong>
<span></span>
</div>
</div>
<div className="form-item">
<label></label>
<input value={template?.ruleType || ""} readOnly placeholder={form.loading ? "模板加载中..." : "等待后端返回"} />
</div>
<div className="form-item">
<label></label>
<input value={template?.ruleName || ""} readOnly placeholder={form.loading ? "模板加载中..." : "等待后端返回"} />
</div>
<div className="form-item form-item-span-2">
<label>OSS </label>
<input value={template?.ossPreviewKey || ""} readOnly placeholder={form.loading ? "模板加载中..." : "等待后端返回"} />
<p className="field-tip"></p>
</div>
<div className="form-item form-item-span-2">
<label></label>
<input
value={form.changeNote}
onChange={(event) => onChange({ changeNote: event.target.value, success: null })}
placeholder="例如:初始化建设工程合同规则草稿"
/>
</div>
<div className="form-item form-item-span-2">
<label> YAML</label>
<textarea
rows={24}
value={form.yamlText}
onChange={(event) => onChange({ yamlText: event.target.value, error: null, success: null })}
placeholder={form.loading ? "模板加载中..." : "这里显示后端生成的完整 YAML 模板"}
/>
<p className="field-tip"> YAML </p>
</div>
{form.error ? (
<div className="form-item form-item-span-2">
<div className="detail-alert danger compact">
<strong></strong>
<span>{form.error}</span>
</div>
</div>
) : null}
{form.success ? (
<div className="form-item form-item-span-2">
<div className="detail-alert success compact">
<strong>稿</strong>
<span>
{form.success.ruleName || form.success.ruleSetName || template?.ruleName || "规则集"} ·
{` 版本 ${form.success.versionNo || form.success.versionId || "已创建"} `}
{form.success.bindingId ? `· 已同步绑定 #${form.success.bindingId}` : "· 绑定结果以后端返回为准"}
</span>
</div>
</div>
) : null}
</div>
<div className="rg-modal-footer">
<Button type="default" onClick={onClose}></Button>
{form.success ? (
<Button type="primary" onClick={onOpenRules}></Button>
) : (
<Button type="primary" onClick={onSubmit} disabled={form.loading || form.saving}>
{form.loading ? "模板加载中..." : form.saving ? "保存中..." : "保存规则草稿"}
</Button>
)}
</div>
</div>
</div>
);
}
export default function RuleGroupsIndex() { export default function RuleGroupsIndex() {
const { groups, docTypes, entryModules, ruleSets, frontendJWT } = useLoaderData<LoaderData>(); const { groups, docTypes, entryModules, ruleSets, frontendJWT } = useLoaderData<LoaderData>();
const navigate = useNavigate();
const { hasAnyPermission } = usePermission();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const topGroups = useMemo(() => toTopGroups(groups), [groups]); const [groupTree, setGroupTree] = useState<RuleGroupNode[]>(groups);
const topGroups = useMemo(() => toTopGroups(groupTree), [groupTree]);
const nameValue = searchParams.get("name") || ""; const nameValue = searchParams.get("name") || "";
const codeValue = searchParams.get("code") || ""; const codeValue = searchParams.get("code") || "";
const statusValue = searchParams.get("status") || ""; const statusValue = searchParams.get("status") || "";
@@ -521,6 +742,7 @@ export default function RuleGroupsIndex() {
const [rulePreviewMap, setRulePreviewMap] = useState<Record<number, RulePreviewState>>({}); const [rulePreviewMap, setRulePreviewMap] = useState<Record<number, RulePreviewState>>({});
const [groupModalOpen, setGroupModalOpen] = useState(false); const [groupModalOpen, setGroupModalOpen] = useState(false);
const [bindingModalOpen, setBindingModalOpen] = useState(false); const [bindingModalOpen, setBindingModalOpen] = useState(false);
const [draftModalOpen, setDraftModalOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [groupForm, setGroupForm] = useState<GroupFormState>({ const [groupForm, setGroupForm] = useState<GroupFormState>({
mode: "create", mode: "create",
@@ -542,8 +764,22 @@ export default function RuleGroupsIndex() {
isActive: true, isActive: true,
}); });
const [activeBindingGroup, setActiveBindingGroup] = useState<RuleGroupNode | null>(null); const [activeBindingGroup, setActiveBindingGroup] = useState<RuleGroupNode | null>(null);
const [activeDraftGroup, setActiveDraftGroup] = useState<RuleGroupNode | null>(null);
const [draftForm, setDraftForm] = useState<RuleDraftFormState>({
groupId: 0,
yamlText: "",
changeNote: "",
template: null,
loading: false,
saving: false,
error: null,
success: null,
});
const mountedRef = useRef(false); const mountedRef = useRef(false);
const childGroupStats = useMemo(() => buildChildGroupStats(topGroups), [topGroups]); const childGroupStats = useMemo(() => buildChildGroupStats(topGroups), [topGroups]);
const canManageGroups = hasAnyPermission(["evaluation_group:create:write", "evaluation_group:update:write"]);
const canManageBindings = hasAnyPermission(["evaluation_group:update:write"]);
const canCreateRuleDraft = hasAnyPermission(["evaluation_group:update:write", "rules:create:write"]);
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
return topGroups return topGroups
@@ -605,8 +841,12 @@ export default function RuleGroupsIndex() {
const resetFilters = () => setSearchParams(new URLSearchParams()); const resetFilters = () => setSearchParams(new URLSearchParams());
const reloadPage = () => { const reloadPage = async (): Promise<RuleGroupNode[]> => {
if (typeof window !== "undefined") window.location.reload(); const nextGroups = await fetchGroupTree(frontendJWT);
setGroupTree(nextGroups);
setActiveBindingGroup((current) => (current ? findGroupNode(nextGroups, current.id) : null));
setActiveDraftGroup((current) => (current ? findGroupNode(nextGroups, current.id) : null));
return nextGroups;
}; };
const handleApiError = (error: any) => { const handleApiError = (error: any) => {
@@ -656,6 +896,39 @@ export default function RuleGroupsIndex() {
setBindingModalOpen(true); setBindingModalOpen(true);
}; };
const openCreateRuleDraft = async (group: RuleGroupNode) => {
setActiveDraftGroup(group);
setDraftForm({
groupId: group.id,
yamlText: "",
changeNote: "",
template: null,
loading: true,
saving: false,
error: null,
success: null,
});
setDraftModalOpen(true);
try {
const response = await axios.get(`${API_BASE_URL}/api/v3/evaluation-point-groups/${group.id}/rule-template`, {
headers: authHeaders(frontendJWT),
});
const template = normalizeRuleTemplatePayload(response?.data);
const yamlText = String(template?.yamlTemplate || template?.yamlText || "");
setDraftForm((prev) => ({
...prev,
loading: false,
template,
yamlText,
changeNote: prev.changeNote || `初始化 ${getSubtypeGroupDisplayName(group)} 规则草稿`,
error: yamlText.trim() ? null : "后端未返回可编辑的 YAML 模板",
}));
} catch (error: any) {
const message = error?.response?.data?.msg || error?.response?.data?.detail || error?.message || "模板加载失败";
setDraftForm((prev) => ({ ...prev, loading: false, error: String(message) }));
}
};
const openEditBinding = (group: RuleGroupNode, binding: BindingItem) => { const openEditBinding = (group: RuleGroupNode, binding: BindingItem) => {
setActiveBindingGroup(group); setActiveBindingGroup(group);
setBindingForm({ setBindingForm({
@@ -691,7 +964,8 @@ export default function RuleGroupsIndex() {
} else { } else {
await axios.put(`${API_BASE_URL}/api/v3/evaluation-point-groups/${groupForm.id}`, payload, { headers: { ...authHeaders(frontendJWT) } }); await axios.put(`${API_BASE_URL}/api/v3/evaluation-point-groups/${groupForm.id}`, payload, { headers: { ...authHeaders(frontendJWT) } });
} }
reloadPage(); await reloadPage();
setGroupModalOpen(false);
} catch (error) { } catch (error) {
handleApiError(error); handleApiError(error);
} finally { } finally {
@@ -714,7 +988,8 @@ export default function RuleGroupsIndex() {
} else { } else {
await axios.put(`${API_BASE_URL}/api/v3/evaluation-point-groups/bindings/${bindingForm.bindingId}`, payload, { headers: { ...authHeaders(frontendJWT) } }); await axios.put(`${API_BASE_URL}/api/v3/evaluation-point-groups/bindings/${bindingForm.bindingId}`, payload, { headers: { ...authHeaders(frontendJWT) } });
} }
reloadPage(); await reloadPage();
setBindingModalOpen(false);
} catch (error) { } catch (error) {
handleApiError(error); handleApiError(error);
} finally { } finally {
@@ -729,7 +1004,7 @@ export default function RuleGroupsIndex() {
const response = await axios.delete(`${API_BASE_URL}/api/v3/evaluation-point-groups/${group.id}`, { headers: authHeaders(frontendJWT) }); const response = await axios.delete(`${API_BASE_URL}/api/v3/evaluation-point-groups/${group.id}`, { headers: authHeaders(frontendJWT) });
const payload = response?.data?.data ?? response?.data; const payload = response?.data?.data ?? response?.data;
if (payload?.success === false) return window.alert(payload?.message || "删除失败"); if (payload?.success === false) return window.alert(payload?.message || "删除失败");
reloadPage(); await reloadPage();
} catch (error) { } catch (error) {
handleApiError(error); handleApiError(error);
} }
@@ -739,12 +1014,45 @@ export default function RuleGroupsIndex() {
if (!window.confirm(`确认解除规则集「${binding.rule_name || binding.rule_type || binding.rule_set_id}」吗?`)) return; if (!window.confirm(`确认解除规则集「${binding.rule_name || binding.rule_type || binding.rule_set_id}」吗?`)) return;
try { try {
await axios.delete(`${API_BASE_URL}/api/v3/evaluation-point-groups/bindings/${binding.id}`, { headers: authHeaders(frontendJWT) }); await axios.delete(`${API_BASE_URL}/api/v3/evaluation-point-groups/bindings/${binding.id}`, { headers: authHeaders(frontendJWT) });
reloadPage(); await reloadPage();
} catch (error) { } catch (error) {
handleApiError(error); handleApiError(error);
} }
}; };
const submitRuleDraft = async () => {
if (!activeDraftGroup) return;
if (!draftForm.yamlText.trim()) {
setDraftForm((prev) => ({ ...prev, error: "请先确认 YAML 内容后再保存" }));
return;
}
setDraftForm((prev) => ({ ...prev, saving: true, error: null }));
try {
const payload = {
yaml_text: draftForm.yamlText,
change_note: draftForm.changeNote.trim() || null,
};
const response = await axios.post(
`${API_BASE_URL}/api/v3/evaluation-point-groups/${activeDraftGroup.id}/rule-drafts`,
payload,
{ headers: authHeaders(frontendJWT) },
);
const result = normalizeRuleDraftCreateResult(response?.data);
const nextGroups = await reloadPage();
const refreshedGroup = findGroupNode(nextGroups, activeDraftGroup.id) || activeDraftGroup;
setActiveDraftGroup(refreshedGroup);
setDraftForm((prev) => ({ ...prev, saving: false, success: result }));
} catch (error: any) {
const message = error?.response?.data?.msg || error?.response?.data?.detail || error?.message || "规则草稿保存失败";
setDraftForm((prev) => ({ ...prev, saving: false, error: String(message) }));
}
};
const openRuleDraftResult = () => {
const target = buildRuleDraftJumpUrl(activeDraftGroup, draftForm.success, draftForm.template);
navigate(target);
};
const toggleRoot = (groupId: number) => { const toggleRoot = (groupId: number) => {
setExpandedGroupIds((prev) => { setExpandedGroupIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -812,7 +1120,9 @@ export default function RuleGroupsIndex() {
<div className="page-actions"> <div className="page-actions">
<Button type="default" icon="ri-expand-up-down-line" onClick={() => setExpandedGroupIds(new Set(filteredGroups.map((item) => item.id)))}></Button> <Button type="default" icon="ri-expand-up-down-line" onClick={() => setExpandedGroupIds(new Set(filteredGroups.map((item) => item.id)))}></Button>
<Button type="default" icon="ri-collapse-diagonal-line" onClick={() => { setExpandedGroupIds(new Set()); setExpandedBindingIds(new Set()); }}></Button> <Button type="default" icon="ri-collapse-diagonal-line" onClick={() => { setExpandedGroupIds(new Set()); setExpandedBindingIds(new Set()); }}></Button>
<Button type="primary" icon="ri-add-line" onClick={openCreateRoot}></Button> {canManageGroups ? (
<Button type="primary" icon="ri-add-line" onClick={openCreateRoot}></Button>
) : null}
</div> </div>
</div> </div>
@@ -959,9 +1269,15 @@ export default function RuleGroupsIndex() {
<td>{formatDateTime(root.updated_at || root.created_at)}</td> <td>{formatDateTime(root.updated_at || root.created_at)}</td>
<td> <td>
<div className="action-links"> <div className="action-links">
<button type="button" className="action-link button-link" onClick={() => openCreateChild(root)}></button> {canManageGroups ? (
<button type="button" className="action-link button-link" onClick={() => openEditGroup(root)}></button> <button type="button" className="action-link button-link" onClick={() => openCreateChild(root)}></button>
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(root)}></button> ) : null}
{canManageGroups ? (
<button type="button" className="action-link button-link" onClick={() => openEditGroup(root)}></button>
) : null}
{canManageGroups ? (
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(root)}></button>
) : null}
</div> </div>
</td> </td>
</tr> </tr>
@@ -1026,9 +1342,18 @@ export default function RuleGroupsIndex() {
> >
{child.bindings.length ? (child.bindings.every((item) => expandedBindingIds.has(item.id)) ? "收起规则集" : "查看规则集") : "暂无规则集"} {child.bindings.length ? (child.bindings.every((item) => expandedBindingIds.has(item.id)) ? "收起规则集" : "查看规则集") : "暂无规则集"}
</button> </button>
<button type="button" className="action-link button-link" onClick={() => openCreateBinding(child)}></button> {canCreateRuleDraft ? (
<button type="button" className="action-link button-link" onClick={() => openEditGroup(child)}></button> <button type="button" className="action-link button-link" onClick={() => openCreateRuleDraft(child)}>/YAML</button>
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(child)}></button> ) : null}
{canManageBindings ? (
<button type="button" className="action-link button-link" onClick={() => openCreateBinding(child)}></button>
) : null}
{canManageGroups ? (
<button type="button" className="action-link button-link" onClick={() => openEditGroup(child)}></button>
) : null}
{canManageGroups ? (
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(child)}></button>
) : null}
</div> </div>
</td> </td>
</tr> </tr>
@@ -1071,8 +1396,12 @@ export default function RuleGroupsIndex() {
<button type="button" className="action-link button-link" onClick={() => toggleBindingPreview(binding)}> <button type="button" className="action-link button-link" onClick={() => toggleBindingPreview(binding)}>
{expandedBinding ? "收起规则明细" : "查看组内规则"} {expandedBinding ? "收起规则明细" : "查看组内规则"}
</button> </button>
<button type="button" className="action-link button-link" onClick={() => openEditBinding(child, binding)}></button> {canManageBindings ? (
<button type="button" className="action-link danger button-link" onClick={() => deleteBinding(binding)}></button> <button type="button" className="action-link button-link" onClick={() => openEditBinding(child, binding)}></button>
) : null}
{canManageBindings ? (
<button type="button" className="action-link danger button-link" onClick={() => deleteBinding(binding)}></button>
) : null}
</div> </div>
</td> </td>
</tr> </tr>
@@ -1157,6 +1486,15 @@ export default function RuleGroupsIndex() {
onSubmit={submitBinding} onSubmit={submitBinding}
saving={saving} saving={saving}
/> />
<RuleDraftModal
visible={draftModalOpen}
group={activeDraftGroup}
form={draftForm}
onClose={() => setDraftModalOpen(false)}
onChange={(patch) => setDraftForm((prev) => ({ ...prev, ...patch }))}
onSubmit={submitRuleDraft}
onOpenRules={openRuleDraftResult}
/>
</div> </div>
); );
} }
+8
View File
@@ -237,6 +237,10 @@ function mapApiRuleToModel(apiRule: ApiRule): Rule {
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT, userInfo } = await getUserSession(request);
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission("/rules/list", userInfo?.role || "", frontendJWT || undefined);
// 从 URL 参数中提取查询条件 // 从 URL 参数中提取查询条件
const params = { const params = {
@@ -280,6 +284,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: LoaderFunctionArgs) { export async function action({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT, userInfo } = await getUserSession(request);
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission("/rules/list", userInfo?.role || "", frontendJWT || undefined);
const formData = await request.formData(); const formData = await request.formData();
const _action = formData.get('_action'); const _action = formData.get('_action');
const ruleId = formData.get('ruleId'); const ruleId = formData.get('ruleId');
+5
View File
@@ -18,6 +18,11 @@ export const handle = {
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT, userInfo } = await getUserSession(request);
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission("/rules", userInfo?.role || "", frontendJWT || undefined);
if (url.pathname === '/rules') { if (url.pathname === '/rules') {
const query = url.searchParams.toString(); const query = url.searchParams.toString();
File diff suppressed because it is too large Load Diff
+54 -25
View File
@@ -7,8 +7,8 @@ import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterP
import { Pagination } from '~/components/ui/Pagination'; import { Pagination } from '~/components/ui/Pagination';
import { Table } from '~/components/ui/Table'; import { Table } from '~/components/ui/Table';
import { Tag, type TagColor } from '~/components/ui/Tag'; import { Tag, type TagColor } from '~/components/ui/Tag';
import { loadRuleConfigPacks } from '~/utils/rules-config-packs.server'; import { loadRuleConfigPackSummaries, type RuleConfigPackSummary } from '~/utils/rules-config-packs.server';
import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server'; import type { RuleSummary } from '~/utils/rules-yaml-mock.server';
import styles from '~/styles/pages/rules_test.css?url'; import styles from '~/styles/pages/rules_test.css?url';
export const links = () => [ export const links = () => [
@@ -31,12 +31,12 @@ type RuleRow = RuleSummary & {
mainType: string; mainType: string;
subtype: string; subtype: string;
yamlName: string; yamlName: string;
yamlStatus: RuleYamlPack['sourceStatus']; yamlStatus: RuleConfigPackSummary['sourceStatus'];
isPlaceholder?: boolean;
}; };
type LoaderData = { type LoaderData = {
rows: RuleRow[]; rows: RuleRow[];
packs: RuleYamlPack[];
filters: { filters: {
documentType: string; documentType: string;
mainType: string; mainType: string;
@@ -61,7 +61,7 @@ function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean))); return Array.from(new Set(values.filter(Boolean)));
} }
function resolveDocumentScope(pack: Pick<RuleYamlPack, 'documentType' | 'mainType' | 'moduleType'>): string { function resolveDocumentScope(pack: Pick<RuleConfigPackSummary, 'documentType' | 'mainType' | 'moduleType'>): string {
const values = [pack.documentType, pack.mainType, pack.moduleType].join(' '); const values = [pack.documentType, pack.mainType, pack.moduleType].join(' ');
if (values.includes('合同')) return '合同'; if (values.includes('合同')) return '合同';
if (values.includes('案卷') || values.includes('卷宗') || values.includes('行政处罚') || values.includes('行政许可')) { if (values.includes('案卷') || values.includes('卷宗') || values.includes('行政处罚') || values.includes('行政许可')) {
@@ -71,6 +71,10 @@ function resolveDocumentScope(pack: Pick<RuleYamlPack, 'documentType' | 'mainTyp
return pack.documentType || pack.mainType || pack.moduleType || '未分类'; return pack.documentType || pack.mainType || pack.moduleType || '未分类';
} }
function resolveBusinessType(pack: Pick<RuleConfigPackSummary, 'businessType' | 'mainType'>): string {
return pack.businessType || pack.mainType || '';
}
function riskColor(risk: string): TagColor { function riskColor(risk: string): TagColor {
if (risk === 'high') return 'red'; if (risk === 'high') return 'red';
if (risk === 'medium') return 'orange'; if (risk === 'medium') return 'orange';
@@ -95,7 +99,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
pageSize: [10, 20, 30, 50].includes(requestedPageSize) ? requestedPageSize : 10 pageSize: [10, 20, 30, 50].includes(requestedPageSize) ? requestedPageSize : 10
}; };
const packs = await loadRuleConfigPacks(request); const packs = await loadRuleConfigPackSummaries(request);
const packScopes = packs.map(pack => ({ const packScopes = packs.map(pack => ({
pack, pack,
scope: resolveDocumentScope(pack), scope: resolveDocumentScope(pack),
@@ -103,7 +107,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const documentTypes = unique(packScopes.map(item => item.scope)); const documentTypes = unique(packScopes.map(item => item.scope));
const requestedDocumentType = requestedFilters.documentType; const requestedDocumentType = requestedFilters.documentType;
const inferredDocumentType = requestedMainType const inferredDocumentType = requestedMainType
? packScopes.find(item => item.pack.mainType === requestedMainType)?.scope || '' ? packScopes.find(item => resolveBusinessType(item.pack) === requestedMainType)?.scope || ''
: ''; : '';
const currentDocumentType = documentTypes.includes(requestedDocumentType) const currentDocumentType = documentTypes.includes(requestedDocumentType)
? requestedDocumentType ? requestedDocumentType
@@ -114,18 +118,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
const scopedFilters = { const scopedFilters = {
...requestedFilters, ...requestedFilters,
documentType: currentDocumentType, documentType: currentDocumentType,
mainType: scopedDocumentPacks.some(pack => pack.mainType === requestedFilters.mainType) mainType: scopedDocumentPacks.some(pack => resolveBusinessType(pack) === requestedFilters.mainType)
? requestedFilters.mainType ? requestedFilters.mainType
: '', : '',
subtype: scopedDocumentPacks.some(pack => subtype: scopedDocumentPacks.some(pack =>
(!requestedFilters.mainType || pack.mainType === requestedFilters.mainType) && (!requestedFilters.mainType || resolveBusinessType(pack) === requestedFilters.mainType) &&
pack.subtype === requestedFilters.subtype pack.subtype === requestedFilters.subtype
) )
? requestedFilters.subtype ? requestedFilters.subtype
: '' : ''
}; };
const scopedByMainTypePacks = scopedDocumentPacks.filter(pack => const scopedByMainTypePacks = scopedDocumentPacks.filter(pack =>
!scopedFilters.mainType || pack.mainType === scopedFilters.mainType !scopedFilters.mainType || resolveBusinessType(pack) === scopedFilters.mainType
); );
const subtypeOptions = unique(scopedByMainTypePacks.map(pack => pack.subtype)); const subtypeOptions = unique(scopedByMainTypePacks.map(pack => pack.subtype));
const ruleGroupSourcePacks = scopedFilters.subtype const ruleGroupSourcePacks = scopedFilters.subtype
@@ -141,7 +145,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : '' ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : ''
}; };
const visiblePacks = scopedDocumentPacks.filter(pack => const visiblePacks = scopedDocumentPacks.filter(pack =>
(!filters.mainType || pack.mainType === filters.mainType) && (!filters.mainType || resolveBusinessType(pack) === filters.mainType) &&
(!filters.subtype || pack.subtype === filters.subtype) (!filters.subtype || pack.subtype === filters.subtype)
); );
@@ -152,13 +156,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
packId: pack.id, packId: pack.id,
documentType: pack.documentType, documentType: pack.documentType,
moduleType: pack.moduleType, moduleType: pack.moduleType,
mainType: pack.mainType, mainType: resolveBusinessType(pack),
subtype: pack.subtype, subtype: pack.subtype,
yamlName: pack.metadata.name || '待配置 YAML', yamlName: pack.yamlName || '待配置 YAML',
yamlStatus: pack.sourceStatus, yamlStatus: pack.sourceStatus,
id: `${pack.id}-empty`, id: `${pack.id}-empty`,
ruleId: '-', ruleId: '-',
name: '暂无规则配置', name: `${pack.subtype}待配置`,
group: '待配置', group: '待配置',
risk: '-', risk: '-',
score: '-', score: '-',
@@ -172,7 +176,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
stageCount: 0, stageCount: 0,
appliesIn: [], appliesIn: [],
prompt: '', prompt: '',
description: '当前文档类型已保留规则列表与 YAML 配置页流程,等待后续接入规则文件。' description: pack.sourceStatus === 'missing'
? '当前规则集已建立,但生效版本正文暂未成功加载,请进入配置页检查并重新保存。'
: '当前子类型还没有正式评查点,请进入配置页补充字段、子文档与评查规则。',
isPlaceholder: true,
}]; }];
} }
@@ -182,9 +189,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
packId: pack.id, packId: pack.id,
documentType: pack.documentType, documentType: pack.documentType,
moduleType: pack.moduleType, moduleType: pack.moduleType,
mainType: pack.mainType, mainType: resolveBusinessType(pack),
subtype: pack.subtype, subtype: pack.subtype,
yamlName: pack.metadata.name, yamlName: pack.yamlName,
yamlStatus: pack.sourceStatus yamlStatus: pack.sourceStatus
})); }));
}).filter(row => { }).filter(row => {
@@ -203,7 +210,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
return Response.json({ return Response.json({
rows, rows,
packs,
filters: { filters: {
...filters, ...filters,
page: currentPage page: currentPage
@@ -213,7 +219,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
pageSize: filters.pageSize, pageSize: filters.pageSize,
options: { options: {
documentTypes, documentTypes,
mainTypes: unique(scopedDocumentPacks.map(pack => pack.mainType)), mainTypes: unique(scopedDocumentPacks.map(pack => resolveBusinessType(pack))),
subtypes: subtypeOptions, subtypes: subtypeOptions,
ruleGroups: ruleGroupOptions ruleGroups: ruleGroupOptions
} }
@@ -244,6 +250,16 @@ export default function RulesTestList() {
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = event.target; const { name, value } = event.target;
if (name === 'mainType') {
updateParams({
mainType: value,
ruleTypeName: undefined,
subtype: undefined,
documentAttributeType: undefined,
ruleGroup: undefined,
});
return;
}
if (name === 'subtype') { if (name === 'subtype') {
updateParams({ subtype: value, documentAttributeType: undefined, ruleGroup: undefined }); updateParams({ subtype: value, documentAttributeType: undefined, ruleGroup: undefined });
return; return;
@@ -257,7 +273,7 @@ export default function RulesTestList() {
const handleReset = () => { const handleReset = () => {
const nextParams = new URLSearchParams(searchParams); const nextParams = new URLSearchParams(searchParams);
['ruleGroup', 'subtype', 'documentAttributeType', 'keyword', 'page'].forEach(key => nextParams.delete(key)); ['mainType', 'ruleTypeName', 'ruleGroup', 'subtype', 'documentAttributeType', 'keyword', 'page'].forEach(key => nextParams.delete(key));
nextParams.set('page', '1'); nextParams.set('page', '1');
setSearchParams(nextParams); setSearchParams(nextParams);
}; };
@@ -278,7 +294,7 @@ export default function RulesTestList() {
render: (_: unknown, record: RuleRow) => ( render: (_: unknown, record: RuleRow) => (
<div className="rule-name"> <div className="rule-name">
<strong>{record.name}</strong> <strong>{record.name}</strong>
<span>{record.ruleId}</span> <span>{record.isPlaceholder ? record.description : record.ruleId}</span>
</div> </div>
) )
}, },
@@ -306,7 +322,9 @@ export default function RulesTestList() {
width: '8%', width: '8%',
align: 'center' as const, align: 'center' as const,
render: (_: unknown, record: RuleRow) => ( render: (_: unknown, record: RuleRow) => (
<Tag color={riskColor(record.risk)} size="sm">{record.risk}</Tag> <Tag color={record.isPlaceholder ? (record.yamlStatus === 'missing' ? 'orange' : 'blue') : riskColor(record.risk)} size="sm">
{record.isPlaceholder ? (record.yamlStatus === 'missing' ? '待修复' : '待配置') : record.risk}
</Tag>
) )
}, },
{ {
@@ -315,7 +333,7 @@ export default function RulesTestList() {
width: '8%', width: '8%',
align: 'center' as const, align: 'center' as const,
render: (_: unknown, record: RuleRow) => ( render: (_: unknown, record: RuleRow) => (
<Tag color="gray" size="sm">{record.score}</Tag> <Tag color="gray" size="sm">{record.isPlaceholder ? '-' : record.score}</Tag>
) )
}, },
{ {
@@ -323,7 +341,7 @@ export default function RulesTestList() {
key: 'dependencies', key: 'dependencies',
width: '20%', width: '20%',
render: (_: unknown, record: RuleRow) => ( render: (_: unknown, record: RuleRow) => (
<span>{record.dependencies.length > 0 ? record.dependencies.slice(0, 3).join('、') : '-'}</span> <span>{record.isPlaceholder ? '先进入配置页补规则与依赖' : (record.dependencies.length > 0 ? record.dependencies.slice(0, 3).join('、') : '-')}</span>
) )
}, },
{ {
@@ -333,7 +351,7 @@ export default function RulesTestList() {
align: 'center' as const, align: 'center' as const,
render: (_: unknown, record: RuleRow) => ( render: (_: unknown, record: RuleRow) => (
<Link className="operation-btn" to={`/rulesTest/detail?packId=${encodeURIComponent(record.packId)}&ruleId=${encodeURIComponent(record.ruleId || record.id)}`}> <Link className="operation-btn" to={`/rulesTest/detail?packId=${encodeURIComponent(record.packId)}&ruleId=${encodeURIComponent(record.ruleId || record.id)}`}>
<i className="ri-settings-3-line"></i> <i className="ri-settings-3-line"></i> {record.isPlaceholder ? '去配置' : '配置'}
</Link> </Link>
) )
} }
@@ -362,6 +380,16 @@ export default function RulesTestList() {
</Button> </Button>
} }
> >
<FilterSelect
label="业务类型"
name="mainType"
value={filters.mainType}
options={options.mainTypes.map(mainType => ({ value: mainType, label: mainType }))}
onChange={handleFilterChange}
className="mr-3 w-[18%]"
placeholder="全部"
/>
<FilterSelect <FilterSelect
label="子类型" label="子类型"
name="subtype" name="subtype"
@@ -369,6 +397,7 @@ export default function RulesTestList() {
options={options.subtypes.map(subtype => ({ value: subtype, label: subtype }))} options={options.subtypes.map(subtype => ({ value: subtype, label: subtype }))}
onChange={handleFilterChange} onChange={handleFilterChange}
className="mr-3 w-[18%]" className="mr-3 w-[18%]"
placeholder={filters.mainType || options.mainTypes.length <= 1 ? '全部' : '请先选择业务类型'}
/> />
<FilterSelect <FilterSelect
+105 -2
View File
@@ -184,7 +184,10 @@
} }
.rules-test-page .rules-test-table td { .rules-test-page .rules-test-table td {
padding-top: 18px;
padding-bottom: 18px;
padding-right: 12px; padding-right: 12px;
vertical-align: top;
} }
.rules-test-page .rules-test-table td:nth-child(2), .rules-test-page .rules-test-table td:nth-child(2),
@@ -213,7 +216,9 @@
.rules-test-page .rule-name { .rules-test-page .rule-name {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
min-height: 54px;
justify-content: center;
} }
.rules-test-page .rule-name strong { .rules-test-page .rule-name strong {
@@ -230,7 +235,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 4px; gap: 6px;
min-height: 54px;
justify-content: center;
} }
.rules-test-page .rule-check-type span:last-child { .rules-test-page .rule-check-type span:last-child {
@@ -433,6 +440,89 @@
color: #34483e; color: #34483e;
} }
.rules-test-page .config-section-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.rules-test-page .config-section-tip {
color: #61756b;
font-size: 13px;
}
.rules-test-page .config-item-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rules-test-page .config-item-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #dbe7e1;
border-radius: 8px;
background: #fbfdfc;
}
.rules-test-page .config-item-main {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.rules-test-page .config-item-main strong {
color: #173d2f;
font-size: 14px;
font-weight: 650;
}
.rules-test-page .config-item-main span {
color: #63766d;
font-size: 13px;
line-height: 1.5;
}
.rules-test-page .config-item-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.rules-test-page .rules-version-select {
min-width: 168px;
height: 32px;
padding: 0 10px;
border: 1px solid #d5ded9;
border-radius: 6px;
background: #fff;
color: #20352c;
}
.rules-test-page .document-draft-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.rules-test-page .document-draft-field-row {
display: grid;
grid-template-columns: 120px 1.2fr 110px 1.4fr 72px;
gap: 8px;
}
.rules-test-page .document-draft-field-row input,
.rules-test-page .document-draft-field-row select {
width: 100%;
}
.rules-test-page .document-fields-card { .rules-test-page .document-fields-card {
margin-top: 12px; margin-top: 12px;
} }
@@ -643,6 +733,19 @@
font-weight: 600; font-weight: 600;
} }
.rules-test-page .drawer-form label.drawer-checkbox-row {
flex-direction: row;
align-items: center;
gap: 10px;
font-weight: 500;
}
.rules-test-page .drawer-form label.drawer-checkbox-row input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
}
.rules-test-page .drawer-form input, .rules-test-page .drawer-form input,
.rules-test-page .drawer-form select, .rules-test-page .drawer-form select,
.rules-test-page .drawer-form textarea { .rules-test-page .drawer-form textarea {
+275 -1
View File
@@ -1,5 +1,8 @@
import YAML from 'yaml';
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server'; import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server';
export type VisualElementSummary = RuleYamlPack['visualElements'][number];
export type DependencyOption = { export type DependencyOption = {
value: string; value: string;
label: string; label: string;
@@ -17,12 +20,13 @@ export type ValidationIssue = {
export type EditableRuleConfig = { export type EditableRuleConfig = {
metadata: RuleYamlPack['metadata']; metadata: RuleYamlPack['metadata'];
yamlSource: string;
documentType: string; documentType: string;
mainType: string; mainType: string;
subtype: string; subtype: string;
fields: ExtractFieldSummary[]; fields: ExtractFieldSummary[];
subDocuments: SubDocumentSummary[]; subDocuments: SubDocumentSummary[];
visualElements: RuleYamlPack['visualElements']; visualElements: VisualElementSummary[];
rules: RuleSummary[]; rules: RuleSummary[];
}; };
@@ -175,6 +179,15 @@ export function validateEditableRuleConfig(config: EditableRuleConfig): Validati
message: '字段类型不能为空。' message: '字段类型不能为空。'
}); });
} }
if (field.type === 'enum' && (!Array.isArray(field.allowed) || field.allowed.length === 0)) {
issues.push({
id: `field-allowed-${field.id}`,
severity: 'error',
area: '抽取配置',
target: field.name || '未命名字段',
message: '枚举字段必须配置可选值。'
});
}
}); });
config.subDocuments.forEach(document => { config.subDocuments.forEach(document => {
@@ -215,6 +228,15 @@ export function validateEditableRuleConfig(config: EditableRuleConfig): Validati
message: '文书字段类型不能为空。' message: '文书字段类型不能为空。'
}); });
} }
if (field.type === 'enum' && (!Array.isArray(field.allowed) || field.allowed.length === 0)) {
issues.push({
id: `document-field-allowed-${document.id}-${field.id}`,
severity: 'error',
area: '案卷文书',
target: field.name || '未命名字段',
message: '文书枚举字段必须配置可选值。'
});
}
}); });
}); });
@@ -287,6 +309,247 @@ function yamlValue(value: string | number | boolean | undefined): string {
return text ? `'${text}'` : "''"; return text ? `'${text}'` : "''";
} }
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function groupBy<T>(items: T[], keyGetter: (item: T) => string): Map<string, T[]> {
const groups = new Map<string, T[]>();
items.forEach((item) => {
const key = keyGetter(item);
const list = groups.get(key) || [];
list.push(item);
groups.set(key, list);
});
return groups;
}
function normalizeBooleanText(value: string | boolean | undefined): boolean {
if (typeof value === 'boolean') return value;
return String(value || '').trim().toLowerCase() === 'true';
}
function rewriteExtractNodes(fields: ExtractFieldSummary[]): Array<Record<string, unknown>> {
const topLevelFields = fields.filter((field) => !field.name.includes('[*].'));
return Array.from(groupBy(topLevelFields, (field) => field.group || '未分组').entries()).map(([group, items]) => ({
group,
fields: items.map((field) => ({
name: field.name,
type: field.multipleEntities ? 'multi_entity' : field.type || 'verbatim',
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0 ? { allowed: [...field.allowed] } : {}),
required_from: field.requiredFrom || 'draft',
desc: field.description || '',
})),
}));
}
function rewriteSubDocumentNodes(subDocuments: SubDocumentSummary[]): Array<Record<string, unknown>> {
return subDocuments.map((document) => ({
id: document.id,
name: document.name,
required: document.required || 'false',
extract: Array.from(groupBy(document.fields || [], (field) => field.group || '未分组').entries()).map(([group, fields]) => ({
group,
fields: fields.map((field) => ({
name: field.name,
type: field.multipleEntities ? 'multi_entity' : field.type || 'verbatim',
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0 ? { allowed: [...field.allowed] } : {}),
desc: field.description || '',
})),
})),
}));
}
function rewriteVisualElementNodes(visualElements: VisualElementSummary[]): Record<string, unknown> {
const sections = {
seals: [] as Array<Record<string, unknown>>,
signatures: [] as Array<Record<string, unknown>>,
cross_page_seals: [] as Array<Record<string, unknown>>,
};
visualElements.forEach((item) => {
const node: Record<string, unknown> = {
id: item.id,
name: item.name,
required: normalizeBooleanText(item.required),
};
if (item.requiredFrom) node.required_from = item.requiredFrom;
if (item.expectedMatchField) {
node.expected_text_match = {
field: item.expectedMatchField,
...(item.expectedMatchAlternatives && item.expectedMatchAlternatives.length > 0
? { alternatives: [...item.expectedMatchAlternatives] }
: {}),
};
}
if (item.prompt) node.prompt = item.prompt;
if (item.type === '签名') {
if (item.signerRoles && item.signerRoles.length > 0) node.signer_roles = [...item.signerRoles];
if (item.signatureTypes && item.signatureTypes.length > 0) node.signature_types = [...item.signatureTypes];
if (item.privateSealRestricted) node.private_seal_restricted = true;
sections.signatures.push(node);
return;
}
if (item.type === '骑缝章') {
sections.cross_page_seals.push(node);
return;
}
if (item.signatureTypes && item.signatureTypes.length > 0) node.allowed_types = [...item.signatureTypes];
sections.seals.push(node);
});
return sections;
}
function getRuleLookupKey(rule: Pick<RuleSummary, 'ruleId' | 'name' | 'id'>): string {
return rule.ruleId || rule.name || rule.id;
}
function findAiStage(stages: Array<Record<string, unknown>>): Record<string, unknown> | undefined {
return stages.find((stage) => String(stage?.check || stage?.type || '').trim() === 'ai');
}
function buildMinimalRuleNode(rule: RuleSummary): Record<string, unknown> {
const base: Record<string, unknown> = {
rule_id: rule.ruleId,
name: rule.name,
risk: rule.risk || 'medium',
score: rule.score || '1',
type: rule.type || 'deterministic',
desc: rule.description || '',
};
if (rule.appliesIn.length > 0) {
base.applies_in = [...rule.appliesIn];
}
if (rule.type === 'rule_group') {
base.logic = rule.logic || '';
base.rules = [...rule.subRuleIds];
return base;
}
if (rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) {
base.stages = [{
id: '1',
check: 'ai',
prompt: rule.prompt || '请根据规则要求检查文档内容并输出结论。',
}];
return base;
}
if (rule.dependencies.length > 0) {
base.stages = [{
id: '1',
check: rule.checkTypes[0] || 'required',
field: rule.dependencies[0],
}];
return base;
}
throw new Error(`评查点【${rule.name || rule.ruleId || rule.id}】缺少可生成的正式 stages 结构,请先补充依赖字段或改为 AI/规则组合类型。`);
}
function rewriteRuleNode(baseRule: Record<string, unknown> | undefined, rule: RuleSummary): Record<string, unknown> {
const nextRule = baseRule ? deepClone(baseRule) : buildMinimalRuleNode(rule);
nextRule.rule_id = rule.ruleId;
nextRule.name = rule.name;
nextRule.risk = rule.risk || 'medium';
nextRule.score = rule.score || '1';
nextRule.type = rule.type || 'deterministic';
nextRule.desc = rule.description || '';
if (rule.appliesIn.length > 0) nextRule.applies_in = [...rule.appliesIn];
else delete nextRule.applies_in;
delete nextRule.dependencies;
if (rule.type === 'rule_group') {
nextRule.logic = rule.logic || '';
nextRule.rules = [...rule.subRuleIds];
delete nextRule.stages;
return nextRule;
}
delete nextRule.rules;
delete nextRule.logic;
const existingStages = Array.isArray(nextRule.stages) ? (nextRule.stages as Array<Record<string, unknown>>) : [];
const stages = existingStages.length > 0 ? deepClone(existingStages) : (buildMinimalRuleNode(rule).stages as Array<Record<string, unknown>>);
if (rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) {
const aiStage = findAiStage(stages);
if (aiStage) {
aiStage.check = 'ai';
aiStage.prompt = rule.prompt || aiStage.prompt || '请根据规则要求检查文档内容并输出结论。';
} else {
stages.unshift({
id: '1',
check: 'ai',
prompt: rule.prompt || '请根据规则要求检查文档内容并输出结论。',
});
}
}
nextRule.stages = stages;
return nextRule;
}
export function serializeEditableRuleConfig(config: EditableRuleConfig): string {
const parsed = YAML.parse(config.yamlSource || '') as Record<string, unknown> | null;
const root = parsed && typeof parsed === 'object' ? deepClone(parsed) : {};
const metadata = (root.metadata && typeof root.metadata === 'object' ? deepClone(root.metadata) : {}) as Record<string, unknown>;
metadata.type_id = config.metadata.typeId || metadata.type_id || 'pending.internal.document';
metadata.name = config.metadata.name || metadata.name || `${config.subtype}规则配置`;
metadata.version = config.metadata.version || metadata.version || 'v1';
metadata.last_updated = new Date().toISOString().slice(0, 10);
if (config.metadata.parent || metadata.parent) metadata.parent = config.metadata.parent || metadata.parent;
if (config.metadata.description || metadata.description) metadata.description = config.metadata.description || metadata.description;
if (Array.isArray(config.metadata.keywords) && config.metadata.keywords.length > 0) metadata.classification_keywords = [...config.metadata.keywords];
if (Array.isArray(config.metadata.inheritsFrom) && config.metadata.inheritsFrom.length > 0) metadata.inherits_from = [...config.metadata.inheritsFrom];
root.metadata = metadata;
root.extract = rewriteExtractNodes(config.fields);
root.sub_documents = rewriteSubDocumentNodes(config.subDocuments);
root.visual_elements = rewriteVisualElementNodes(config.visualElements);
const existingGroups = Array.isArray(root.rules) ? (root.rules as Array<Record<string, unknown>>) : [];
const existingRuleMap = new Map<string, Record<string, unknown>>();
existingGroups.forEach((groupBlock) => {
const groupRules = Array.isArray(groupBlock?.rules) ? (groupBlock.rules as Array<Record<string, unknown>>) : [];
groupRules.forEach((ruleNode) => {
const key = String(ruleNode?.rule_id || ruleNode?.name || '').trim();
if (key) existingRuleMap.set(key, ruleNode);
});
});
const nextGroups = new Map<string, Array<Record<string, unknown>>>();
config.rules.forEach((rule) => {
const groupName = rule.group || '未分组';
const list = nextGroups.get(groupName) || [];
const existingRule = existingRuleMap.get(getRuleLookupKey(rule));
list.push(rewriteRuleNode(existingRule, rule));
nextGroups.set(groupName, list);
});
root.rules = Array.from(nextGroups.entries()).map(([group, rules]) => ({ group, rules }));
return YAML.stringify(root).replace(
/^(\s*last_updated:\s*)(.+)$/m,
(_match, prefix, value) => `${prefix}'${String(value).replace(/^['"]|['"]$/g, '')}'`,
);
}
export function prepareDraftYamlForSave(yamlText: string): string {
return yamlText
.replace(/^(\s*version:\s*)(.+)$/m, `${'$1'}''`)
.replace(
/^(\s*last_updated:\s*)(.+)$/m,
(_match, prefix, value) => `${prefix}'${String(value).replace(/^['"]|['"]$/g, '')}'`,
);
}
export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSummary): string { export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSummary): string {
const lines: string[] = [ const lines: string[] = [
`# ${config.documentType} / ${config.mainType} / ${config.subtype}`, `# ${config.documentType} / ${config.mainType} / ${config.subtype}`,
@@ -331,6 +594,11 @@ export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSumma
} }
export function buildYamlPreview(config: EditableRuleConfig): string { export function buildYamlPreview(config: EditableRuleConfig): string {
try {
return serializeEditableRuleConfig(config);
} catch {
// 预览失败时仍回退旧实现,避免页面直接白屏;保存时会使用正式序列化并给出错误。
}
const lines: string[] = [ const lines: string[] = [
'metadata:', 'metadata:',
` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`, ` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`,
@@ -347,6 +615,9 @@ export function buildYamlPreview(config: EditableRuleConfig): string {
lines.push( lines.push(
` - name: ${yamlValue(field.name)}`, ` - name: ${yamlValue(field.name)}`,
` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`, ` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`,
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0
? [` allowed: [${field.allowed.map((item) => yamlValue(item)).join(', ')}]`]
: []),
` desc: ${yamlValue(field.description)}` ` desc: ${yamlValue(field.description)}`
); );
}); });
@@ -370,6 +641,9 @@ export function buildYamlPreview(config: EditableRuleConfig): string {
lines.push( lines.push(
` - name: ${yamlValue(field.name)}`, ` - name: ${yamlValue(field.name)}`,
` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`, ` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`,
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0
? [` allowed: [${field.allowed.map((item) => yamlValue(item)).join(', ')}]`]
: []),
` desc: ${yamlValue(field.description)}` ` desc: ${yamlValue(field.description)}`
); );
}); });
+54
View File
@@ -3,6 +3,7 @@ import { getUserSession } from '~/api/login/auth.server';
import { import {
buildRuleYamlPack, buildRuleYamlPack,
EMPTY_RULE_YAML, EMPTY_RULE_YAML,
type RuleSummary,
type RuleYamlPack, type RuleYamlPack,
} from './rules-yaml-mock.server'; } from './rules-yaml-mock.server';
@@ -28,6 +29,23 @@ type RuleConfigPackApi = {
sourceStatus?: 'ready' | 'empty' | 'missing'; sourceStatus?: 'ready' | 'empty' | 'missing';
}; };
export type RuleConfigPackSummary = {
id: string;
documentType: string;
moduleType: string;
mainType: string;
subtype: string;
businessType: string;
ruleTypeCode: string;
currentVersionId?: number | null;
fallbackVersionId?: number | null;
resolvedVersionId?: number | null;
yamlName: string;
sourceStatus: 'ready' | 'empty' | 'missing';
rules: RuleSummary[];
};
type ApiEnvelope<T> = { type ApiEnvelope<T> = {
code?: number; code?: number;
message?: string; message?: string;
@@ -52,7 +70,32 @@ function getMessage(payload: unknown, fallback: string): string {
return String((payload as ApiEnvelope<unknown>).message || (payload as ApiEnvelope<unknown>).msg || fallback); return String((payload as ApiEnvelope<unknown>).message || (payload as ApiEnvelope<unknown>).msg || fallback);
} }
function mapApiPackSummary(item: RuleConfigPackApi & { yamlName?: string; rules?: RuleSummary[] }): RuleConfigPackSummary {
const ruleTypeCode = String(item.ruleType || '').trim();
const businessType = item.mainType || item.documentType || '';
return {
id: String(item.packId),
documentType: item.documentType || '',
moduleType: item.moduleType || (item.documentType ? `${item.documentType}评查` : '规则配置'),
mainType: item.mainType || item.documentType || '',
subtype: item.subtype || '通用',
businessType,
ruleTypeCode,
currentVersionId: item.currentVersionId ?? null,
fallbackVersionId: item.fallbackVersionId ?? null,
resolvedVersionId: item.resolvedVersionId ?? null,
yamlName: String(item.yamlName || item.ruleName || ''),
sourceStatus: item.sourceStatus || 'empty',
rules: Array.isArray(item.rules) ? item.rules : [],
};
}
function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack { function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack {
const ruleTypeCode = String(item.ruleType || '').trim();
// 业务类型必须以后端 pack 聚合返回的 mainType 为准。
// 不能再从 ruleType 第二段硬拆;例如 contract.entrust 会被错误显示成 entrust。
const businessType = item.mainType || item.documentType || '';
const yamlSource = (item.yamlText || '').trim() ? String(item.yamlText) : EMPTY_RULE_YAML; const yamlSource = (item.yamlText || '').trim() ? String(item.yamlText) : EMPTY_RULE_YAML;
const sourceStatus = item.sourceStatus || ((item.yamlText || '').trim() ? 'ready' : 'empty'); const sourceStatus = item.sourceStatus || ((item.yamlText || '').trim() ? 'ready' : 'empty');
@@ -64,6 +107,11 @@ function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack {
moduleType: item.moduleType || (item.documentType ? `${item.documentType}评查` : '规则配置'), moduleType: item.moduleType || (item.documentType ? `${item.documentType}评查` : '规则配置'),
mainType: item.mainType || item.documentType || '', mainType: item.mainType || item.documentType || '',
subtype: item.subtype || '通用', subtype: item.subtype || '通用',
businessType,
ruleTypeCode,
currentVersionId: item.currentVersionId ?? null,
fallbackVersionId: item.fallbackVersionId ?? null,
resolvedVersionId: item.resolvedVersionId ?? null,
}, },
yamlSource, yamlSource,
sourceStatus, sourceStatus,
@@ -92,6 +140,12 @@ async function fetchRuleConfigPayload<T>(request: Request, path: string): Promis
return payload.data; return payload.data;
} }
export async function loadRuleConfigPackSummaries(request: Request): Promise<RuleConfigPackSummary[]> {
const items = await fetchRuleConfigPayload<Array<RuleConfigPackApi & { yamlName?: string; rules?: RuleSummary[] }>>(request, '/api/v3/rule-config-packs?summaryOnly=true');
return items.map(mapApiPackSummary);
}
export async function loadRuleConfigPacks(request: Request): Promise<RuleYamlPack[]> { export async function loadRuleConfigPacks(request: Request): Promise<RuleYamlPack[]> {
const items = await fetchRuleConfigPayload<RuleConfigPackApi[]>(request, '/api/v3/rule-config-packs'); const items = await fetchRuleConfigPayload<RuleConfigPackApi[]>(request, '/api/v3/rule-config-packs');
return items.map(mapApiPackToRuleYamlPack); return items.map(mapApiPackToRuleYamlPack);
+224 -125
View File
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import YAML from 'yaml';
const LEAUDIT_RULES_ROOT = `${process.cwd()}/mock-data/leaudit-rules/packs/yc`; const LEAUDIT_RULES_ROOT = `${process.cwd()}/mock-data/leaudit-rules/packs/yc`;
@@ -7,6 +8,8 @@ export type RulePackScope = {
moduleType: string; moduleType: string;
mainType: string; mainType: string;
subtype: string; subtype: string;
businessType?: string;
ruleTypeCode?: string;
}; };
export type RuleSummary = { export type RuleSummary = {
@@ -39,6 +42,7 @@ export type ExtractFieldSummary = {
name: string; name: string;
type: string; type: string;
multipleEntities: boolean; multipleEntities: boolean;
allowed?: string[];
requiredFrom: string; requiredFrom: string;
description: string; description: string;
}; };
@@ -58,6 +62,9 @@ export type RuleYamlPack = RulePackScope & {
yamlPath: string | null; yamlPath: string | null;
yamlSource: string; yamlSource: string;
sourceStatus: 'ready' | 'empty' | 'missing'; sourceStatus: 'ready' | 'empty' | 'missing';
currentVersionId?: number | null;
fallbackVersionId?: number | null;
resolvedVersionId?: number | null;
metadata: { metadata: {
typeId: string; typeId: string;
name: string; name: string;
@@ -83,9 +90,13 @@ export type RuleYamlPack = RulePackScope & {
name: string; name: string;
type: string; type: string;
required: string; required: string;
requiredFrom?: string;
signerRoles?: string[]; signerRoles?: string[];
signatureTypes?: string[]; signatureTypes?: string[];
privateSealRestricted?: boolean; privateSealRestricted?: boolean;
expectedMatchField?: string;
expectedMatchAlternatives?: string[];
prompt?: string;
}>; }>;
}; };
@@ -138,6 +149,13 @@ function stripYamlValue(value = ''): string {
return value.trim().replace(/^['"]|['"]$/g, '').replace(/\u0000/g, ''); return value.trim().replace(/^['"]|['"]$/g, '').replace(/\u0000/g, '');
} }
function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
function parseScalar(section: string, key: string): string { function parseScalar(section: string, key: string): string {
const match = section.match(new RegExp(`^\\s{2}${key}:\\s*(.*)$`, 'm')); const match = section.match(new RegExp(`^\\s{2}${key}:\\s*(.*)$`, 'm'));
return stripYamlValue(match?.[1] || ''); return stripYamlValue(match?.[1] || '');
@@ -193,10 +211,10 @@ function splitBlocks(section: string, marker: RegExp): string[] {
function parseRules(source: string): RuleSummary[] { function parseRules(source: string): RuleSummary[] {
const section = getTopLevelSection(source, 'rules'); const section = getTopLevelSection(source, 'rules');
const groups = splitBlocks(section, /^-\s+group:\s*/); const groups = splitBlocks(section, /^\s*-\s+group:\s*/);
const readExplicitDependencies = (block: string): string[] => { const readExplicitDependencies = (block: string): string[] => {
const lines = block.split('\n'); const lines = block.split('\n');
const start = lines.findIndex(line => /^\s{4}dependencies:\s*$/.test(line)); const start = lines.findIndex(line => /^\s+dependencies:\s*$/.test(line));
if (start === -1) { if (start === -1) {
return []; return [];
@@ -205,10 +223,10 @@ function parseRules(source: string): RuleSummary[] {
const dependencies: string[] = []; const dependencies: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) { for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index]; const line = lines[index];
if (/^\s{4}[a-zA-Z_][^:]*:\s*/.test(line)) { if (/^\s+[a-zA-Z_][^:]*:\s*/.test(line) && !/^\s*-\s+/.test(line)) {
break; break;
} }
const match = line.match(/^\s{4}-\s+(.+)$/); const match = line.match(/^\s*-\s+(.+)$/);
if (match) { if (match) {
dependencies.push(stripYamlValue(match[1])); dependencies.push(stripYamlValue(match[1]));
} }
@@ -252,20 +270,28 @@ function parseRules(source: string): RuleSummary[] {
return prompts.filter(Boolean); return prompts.filter(Boolean);
}; };
const readPromptDependencies = (prompts: string[]): string[] => {
return Array.from(new Set(
prompts.flatMap((prompt) => Array.from(prompt.matchAll(/\{\{\s*([^{}]+?)\s*\}\}/g)).map((match) => stripYamlValue(match[1])))
));
};
const readList = (block: string, key: string, indent = 4): string[] => { const readList = (block: string, key: string, indent = 4): string[] => {
const lines = block.split('\n'); const lines = block.split('\n');
const start = lines.findIndex(line => new RegExp(`^\\s{${indent}}${key}:\\s*$`).test(line)); const start = lines.findIndex(line => new RegExp(`^\\s+${key}:\\s*$`).test(line));
if (start === -1) { if (start === -1) {
return []; return [];
} }
const baseIndent = lines[start].match(/^\s*/)?.[0].length || indent;
const values: string[] = []; const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) { for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index]; const line = lines[index];
if (new RegExp(`^\\s{${indent}}[a-zA-Z_][^:]*:\\s*`).test(line)) { const lineIndent = line.match(/^\s*/)?.[0].length || 0;
if (line.trim() && lineIndent <= baseIndent) {
break; break;
} }
const match = line.match(new RegExp(`^\\s{${indent}}-\\s+(.+)$`)); const match = line.match(/^\s*-\s+(.+)$/);
if (match) { if (match) {
values.push(stripYamlValue(match[1])); values.push(stripYamlValue(match[1]));
} }
@@ -294,25 +320,28 @@ function parseRules(source: string): RuleSummary[] {
}; };
const readStageList = (block: string, key: string): string[] => { const readStageList = (block: string, key: string): string[] => {
const lines = block.split('\n'); const lines = block.split('\n');
const start = lines.findIndex(line => new RegExp(`^\\s{6}${key}:\\s*$`).test(line)); const start = lines.findIndex(line => new RegExp(`^\\s+${key}:\\s*$`).test(line));
if (start === -1) { if (start === -1) {
return []; return [];
} }
const baseIndent = lines[start].match(/^\s*/)?.[0].length || 6;
const values: string[] = []; const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) { for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index]; const line = lines[index];
if (/^\s{6}[a-zA-Z_][^:]*:\s*/.test(line)) { const lineIndent = line.match(/^\s*/)?.[0].length || 0;
if (line.trim() && lineIndent <= baseIndent) {
break; break;
} }
const match = line.match(/^\s{6}-\s+(.+)$/); const match = line.match(/^\s*-\s+(.+)$/);
if (match) { if (match) {
values.push(stripYamlValue(match[1])); values.push(stripYamlValue(match[1]));
} }
} }
return values; return values;
}; };
const readStageScalar = (block: string, key: string): string => stripYamlValue(block.match(new RegExp(`^\\s{6}${key}:\\s*(.+)$`, 'm'))?.[1] || ''); const readStageScalar = (block: string, key: string): string => stripYamlValue(block.match(new RegExp(`^\\s+${key}:\\s*(.+)$`, 'm'))?.[1] || '');
const summarizeStage = (stageBlock: string): string => { const summarizeStage = (stageBlock: string): string => {
const fields = readStageList(stageBlock, 'fields'); const fields = readStageList(stageBlock, 'fields');
const field = readStageScalar(stageBlock, 'field'); const field = readStageScalar(stageBlock, 'field');
@@ -332,7 +361,7 @@ function parseRules(source: string): RuleSummary[] {
return stageBlock.split('\n').map(line => line.trim()).filter(Boolean).slice(1, 4).join('') || '未配置内容'; return stageBlock.split('\n').map(line => line.trim()).filter(Boolean).slice(1, 4).join('') || '未配置内容';
}; };
const readSubRules = (block: string) => splitBlocks(block, /^\s{4}-\s+id:\s*/).map(stageBlock => { const readSubRules = (block: string) => splitBlocks(block, /^\s{4}-\s+id:\s*/).map(stageBlock => {
const id = stripYamlValue(stageBlock.match(/^\s{4}-\s+id:\s*(.+)$/m)?.[1] || ''); const id = stripYamlValue(stageBlock.match(/^\s*-\s+id:\s*(.+)$/m)?.[1] || '');
const check = readStageScalar(stageBlock, 'check') || readStageScalar(stageBlock, 'type') || '-'; const check = readStageScalar(stageBlock, 'check') || readStageScalar(stageBlock, 'type') || '-';
return { return {
id, id,
@@ -342,16 +371,17 @@ function parseRules(source: string): RuleSummary[] {
}).filter(stage => stage.id); }).filter(stage => stage.id);
return groups.flatMap(groupBlock => { return groups.flatMap(groupBlock => {
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组'); const group = stripYamlValue(groupBlock.match(/^\s*-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s{2}-\s+rule_id:\s*/).map(ruleBlock => { return splitBlocks(groupBlock, /^\s*-\s+rule_id:\s*/).map(ruleBlock => {
const ruleId = stripYamlValue(ruleBlock.match(/^\s{2}-\s+rule_id:\s*(.+)$/m)?.[1] || ''); const ruleId = stripYamlValue(ruleBlock.match(/^\s*-\s+rule_id:\s*(.+)$/m)?.[1] || '');
const name = stripYamlValue(ruleBlock.match(/^\s{4}name:\s*(.+)$/m)?.[1] || '未命名规则'); const name = stripYamlValue(ruleBlock.match(/^\s+name:\s*(.+)$/m)?.[1] || '未命名规则');
const checkTypes = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s{6,}(?:check|type):\s*(.+)$/gm)).map(match => stripYamlValue(match[1])))); const checkTypes = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s+(?:check|type):\s*(.+)$/gm)).map(match => stripYamlValue(match[1]))));
const stageDependencies = Array.from(ruleBlock.matchAll(/^\s{6,}(?:field|number|chinese|left|right|left_field|right_field|target|element|seal_id|signature_id):\s*(.+)$/gm)) const stageDependencies = Array.from(ruleBlock.matchAll(/^\s+(?:field|number|chinese|left|right|left_field|right_field|target|element|seal_id|signature_id):\s*(.+)$/gm))
.map(match => normalizeDependency(match[1])); .map(match => normalizeDependency(match[1]));
const dependencies = Array.from(new Set([...readExplicitDependencies(ruleBlock), ...stageDependencies]));
const scope = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s{4,}-\s*([^:\n]+)$/gm)).map(match => stripYamlValue(match[1])).filter(value => !/^\d+$/.test(value))));
const prompts = readPrompts(ruleBlock); const prompts = readPrompts(ruleBlock);
const promptDependencies = readPromptDependencies(prompts).map(normalizeDependency);
const dependencies = Array.from(new Set([...readExplicitDependencies(ruleBlock), ...stageDependencies, ...promptDependencies]));
const scope = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s{4,}-\s*([^:\n]+)$/gm)).map(match => stripYamlValue(match[1])).filter(value => !/^\d+$/.test(value))));
const subRules = readSubRules(ruleBlock); const subRules = readSubRules(ruleBlock);
return { return {
@@ -359,19 +389,19 @@ function parseRules(source: string): RuleSummary[] {
ruleId, ruleId,
name, name,
group, group,
risk: stripYamlValue(ruleBlock.match(/^\s{4}risk:\s*(.+)$/m)?.[1] || 'medium'), risk: stripYamlValue(ruleBlock.match(/^\s+risk:\s*(.+)$/m)?.[1] || 'medium'),
score: stripYamlValue(ruleBlock.match(/^\s{4}score:\s*(.+)$/m)?.[1] || '-'), score: stripYamlValue(ruleBlock.match(/^\s+score:\s*(.+)$/m)?.[1] || '-'),
type: stripYamlValue(ruleBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || 'deterministic'), type: stripYamlValue(ruleBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'deterministic'),
checkTypes, checkTypes,
logic: stripYamlValue(ruleBlock.match(/^\s{4}logic:\s*(.+)$/m)?.[1] || ''), logic: stripYamlValue(ruleBlock.match(/^\s+logic:\s*(.+)$/m)?.[1] || ''),
subRules, subRules,
subRuleIds: readList(ruleBlock, 'rules'), subRuleIds: readList(ruleBlock, 'rules'),
scope: scope.slice(0, 8), scope: scope.slice(0, 8),
dependencies: dependencies.slice(0, 8), dependencies,
stageCount: subRules.length, stageCount: subRules.length,
appliesIn: readFlexibleList(ruleBlock, 'applies_in'), appliesIn: readFlexibleList(ruleBlock, 'applies_in'),
prompt: prompts.join('\n\n'), prompt: prompts.join('\n\n'),
description: stripYamlValue(ruleBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || '') description: stripYamlValue(ruleBlock.match(/^\s+desc:\s*(.+)$/m)?.[1] || '')
}; };
}); });
}); });
@@ -382,143 +412,212 @@ export function parseRuleSummariesFromYaml(source: string): RuleSummary[] {
} }
function parseTopLevelFields(source: string): ExtractFieldSummary[] { function parseTopLevelFields(source: string): ExtractFieldSummary[] {
const section = getTopLevelSection(source, 'extract'); const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
const extractedFields = splitBlocks(section, /^-\s+group:\s*/).flatMap(groupBlock => { const extractGroups = Array.isArray(parsed?.extract) ? parsed.extract : [];
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组'); const extractedFields = extractGroups.flatMap((groupNode) => {
return splitBlocks(groupBlock, /^\s{2}-\s+name:\s*/).flatMap(fieldBlock => { if (!groupNode || typeof groupNode !== 'object') {
const name = stripYamlValue(fieldBlock.match(/^\s{2}-\s+name:\s*(.+)$/m)?.[1] || ''); return [];
const rawType = stripYamlValue(fieldBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || '-'); }
const parentField = { const groupObject = groupNode as Record<string, unknown>;
const group = String(groupObject.group || '未分组').trim() || '未分组';
const fields = Array.isArray(groupObject.fields) ? groupObject.fields : [];
return fields.flatMap((fieldNode) => {
if (!fieldNode || typeof fieldNode !== 'object') {
return [];
}
const field = fieldNode as Record<string, unknown>;
const name = String(field.name || '').trim();
if (!name) {
return [];
}
const rawType = String(field.type || '-').trim();
const requiredFrom = String(field.required_from || '-').trim() || '-';
const parentField: ExtractFieldSummary = {
id: `${group}-${name}`, id: `${group}-${name}`,
group, group,
name, name,
type: rawType === 'multi_entity' ? 'verbatim' : rawType, type: rawType === 'multi_entity' ? 'verbatim' : rawType,
multipleEntities: rawType === 'multi_entity', multipleEntities: rawType === 'multi_entity',
requiredFrom: stripYamlValue(fieldBlock.match(/^\s{4}required_from:\s*(.+)$/m)?.[1] || '-'), allowed: toStringList(field.allowed),
description: stripYamlValue(fieldBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || '') requiredFrom,
description: String(field.desc || '').trim(),
}; };
const childFields = Array.from(fieldBlock.matchAll(/^\s{4}-\s+name:\s*(.+)$/gm)).map(match => { const childFields = Array.isArray(field.fields) ? field.fields : [];
const childName = stripYamlValue(match[1]); const normalizedChildFields = childFields.flatMap((childNode) => {
const start = fieldBlock.indexOf(match[0]); if (!childNode || typeof childNode !== 'object') {
const next = fieldBlock.slice(start + match[0].length).search(/^\s{4}-\s+name:\s*/m); return [];
const childBlock = next === -1 ? fieldBlock.slice(start) : fieldBlock.slice(start, start + match[0].length + next); }
const childType = stripYamlValue(childBlock.match(/^\s{6}type:\s*(.+)$/m)?.[1] || 'verbatim'); const childField = childNode as Record<string, unknown>;
return { const childName = String(childField.name || '').trim();
if (!childName) {
return [];
}
return [{
id: `${group}-${name}-${childName}`, id: `${group}-${name}-${childName}`,
group, group,
name: `${name}[*].${childName}`, name: `${name}[*].${childName}`,
type: childType, type: String(childField.type || 'verbatim').trim() || 'verbatim',
multipleEntities: false, multipleEntities: false,
requiredFrom: stripYamlValue(childBlock.match(/^\s{6}required_from:\s*(.+)$/m)?.[1] || parentField.requiredFrom), allowed: toStringList(childField.allowed),
description: stripYamlValue(childBlock.match(/^\s{6}desc:\s*(.+)$/m)?.[1] || `${name}的子字段`) requiredFrom: String(childField.required_from || requiredFrom).trim() || requiredFrom,
}; description: String(childField.desc || `${name}的子字段`).trim(),
}];
}); });
return [parentField, ...childFields]; return [parentField, ...normalizedChildFields];
}); });
}).filter(field => field.name); }).filter(field => field.name);
const derivedSection = getTopLevelSection(source, 'derived_fields'); const derivedSection = getTopLevelSection(source, 'derived_fields');
const derivedFields = splitBlocks(derivedSection, /^-\s+name:\s*/).map(fieldBlock => { const derivedFields = splitBlocks(derivedSection, /^\s*-\s+name:\s*/).map(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^-\s+name:\s*(.+)$/m)?.[1] || ''); const name = stripYamlValue(fieldBlock.match(/^\s*-\s+name:\s*(.+)$/m)?.[1] || '');
return { return {
id: `derived-${name}`, id: `derived-${name}`,
group: '派生字段', group: '派生字段',
name, name,
type: stripYamlValue(fieldBlock.match(/^\s{2}type:\s*(.+)$/m)?.[1] || 'computed'), type: stripYamlValue(fieldBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'computed'),
multipleEntities: false, multipleEntities: false,
requiredFrom: '-', requiredFrom: '-',
description: stripYamlValue(fieldBlock.match(/^\s{2}compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出') description: stripYamlValue(fieldBlock.match(/^\s+compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出')
}; };
}).filter(field => field.name); }).filter(field => field.name);
return [...extractedFields, ...derivedFields]; return [...extractedFields, ...derivedFields];
} }
function parseDocumentFields(docBlock: string, documentId: string): ExtractFieldSummary[] {
return splitBlocks(docBlock, /^\s{2}-\s+group:\s*/).flatMap(groupBlock => {
const group = stripYamlValue(groupBlock.match(/^\s{2}-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s{4}-\s+name:\s*/).map(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^\s{4}-\s+name:\s*(.+)$/m)?.[1] || '');
const rawType = stripYamlValue(fieldBlock.match(/^\s{6}type:\s*(.+)$/m)?.[1] || '-');
return {
id: `${documentId}-${group}-${name}`,
group,
name,
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
multipleEntities: rawType === 'multi_entity',
requiredFrom: '-',
description: stripYamlValue(fieldBlock.match(/^\s{6}desc:\s*(.+)$/m)?.[1] || '')
};
});
}).filter(field => field.name);
}
function parseSubDocuments(source: string): SubDocumentSummary[] { function parseSubDocuments(source: string): SubDocumentSummary[] {
const section = getTopLevelSection(source, 'sub_documents'); const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
return splitBlocks(section, /^-\s+id:\s*/).map(docBlock => { const subDocuments = parsed?.sub_documents;
const id = stripYamlValue(docBlock.match(/^-\s+id:\s*(.+)$/m)?.[1] || ''); if (!Array.isArray(subDocuments)) {
const groups = Array.from(new Set(Array.from(docBlock.matchAll(/^\s{2,}-\s+group:\s*(.+)$/gm)).map(match => stripYamlValue(match[1])))); return [];
const fields = parseDocumentFields(docBlock, id); }
const classifier = docBlock.match(/^\s{2}classifier:\s*$/m);
let description = ''; return subDocuments.flatMap((documentNode) => {
if (classifier) { if (!documentNode || typeof documentNode !== 'object') {
const keywordsMatch = docBlock.match(/keywords:\s*\n((?:\s{4}-\s+.+\n)+)/m); return [];
if (keywordsMatch) {
const keywords = Array.from(keywordsMatch[1].matchAll(/^\s{4}-\s+(.+)$/gm)).map(match => stripYamlValue(match[1])).slice(0, 3);
description = keywords.join('、');
}
} }
return { const document = documentNode as Record<string, unknown>;
id, const id = String(document.id || '').trim();
name: stripYamlValue(docBlock.match(/^\s{2}name:\s*(.+)$/m)?.[1] || id), if (!id) {
required: stripYamlValue(docBlock.match(/^\s{2}required:\s*(.+)$/m)?.[1] || '-'),
fieldCount: fields.length,
groups,
description,
fields
};
}).filter(doc => doc.id);
}
function parseVisualElements(source: string): RuleYamlPack['visualElements'] {
const section = getTopLevelSection(source, 'visual_elements');
const typedSections = [
{ key: 'seals', label: '签章' },
{ key: 'signatures', label: '签名' },
{ key: 'cross_page_seals', label: '骑缝章' }
];
return typedSections.flatMap(({ key, label }) => {
const lines = section.split('\n');
const start = lines.findIndex(line => new RegExp(`^\\s{2}${key}:`).test(line));
if (start === -1) {
return []; return [];
} }
// 找到下一个同级分类的起始位置(2空格+字母+冒号) const extractGroups = Array.isArray(document.extract) ? document.extract : [];
let end = lines.length; const fields = extractGroups.flatMap((groupNode) => {
for (let i = start + 1; i < lines.length; i++) { if (!groupNode || typeof groupNode !== 'object') {
if (/^\s{2}[a-zA-Z_][\w-]*:/.test(lines[i])) { return [];
end = i;
break;
} }
const groupObject = groupNode as Record<string, unknown>;
const group = String(groupObject.group || '未分组').trim() || '未分组';
const groupFields = Array.isArray(groupObject.fields) ? groupObject.fields : [];
return groupFields.flatMap((fieldNode) => {
if (!fieldNode || typeof fieldNode !== 'object') {
return [];
}
const field = fieldNode as Record<string, unknown>;
const name = String(field.name || '').trim();
if (!name) {
return [];
}
const rawType = String(field.type || '-').trim();
return [{
id: `${id}-${group}-${name}`,
group,
name,
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
multipleEntities: rawType === 'multi_entity',
allowed: toStringList(field.allowed),
requiredFrom: '-',
description: String(field.desc || '').trim(),
}];
});
});
const groups = Array.from(new Set(fields.map((field) => field.group).filter(Boolean)));
const classifier = document.classifier && typeof document.classifier === 'object'
? (document.classifier as Record<string, unknown>)
: null;
const description = Array.isArray(classifier?.keywords)
? classifier!.keywords.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 3).join('、')
: '';
return [{
id,
name: String(document.name || id).trim(),
required: String(document.required ?? '-').trim(),
fieldCount: fields.length,
groups,
description,
fields,
}];
});
}
function parseVisualElements(source: string): RuleYamlPack['visualElements'] {
const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
const visualRoot = parsed?.visual_elements;
if (!visualRoot || typeof visualRoot !== 'object') {
return [];
}
const typedSections = [
{ key: 'seals', label: '签章' },
{ key: 'signatures', label: '签名' },
{ key: 'cross_page_seals', label: '骑缝章' },
] as const;
return typedSections.flatMap(({ key, label }) => {
const bucket = (visualRoot as Record<string, unknown>)[key];
if (!Array.isArray(bucket)) {
return [];
} }
const subSection = lines.slice(start + 1, end).join('\n'); return bucket.flatMap((item) => {
return splitBlocks(subSection, /^\s{2}-\s+id:\s*/).map(block => ({ if (!item || typeof item !== 'object') {
id: stripYamlValue(block.match(/^\s{2}-\s+id:\s*(.+)$/m)?.[1] || ''), return [];
name: stripYamlValue(block.match(/^\s{4}name:\s*(.+)$/m)?.[1] || ''), }
type: label, const node = item as Record<string, unknown>;
required: stripYamlValue(block.match(/^\s{4}required:\s*(.+)$/m)?.[1] || '-'), const id = String(node.id || '').trim();
signerRoles: block.match(/^\s{4}signer_roles:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [], if (!id) {
signatureTypes: block.match(/^\s{4}signature_types:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [], return [];
privateSealRestricted: block.match(/^\s{4}private_seal_restricted:\s*(.+)$/m)?.[1] === 'true' }
})); const toStringList = (value: unknown): string[] => (
}).filter(item => item.id); Array.isArray(value)
? value.map((entry) => String(entry || '').trim()).filter(Boolean)
: []
);
const expectedMatch = node.expected_text_match && typeof node.expected_text_match === 'object'
? (node.expected_text_match as Record<string, unknown>)
: null;
const signatureTypes = key === 'seals'
? toStringList(node.allowed_types)
: toStringList(node.signature_types);
return [{
id,
name: String(node.name || id).trim(),
type: label,
required: String(node.required ?? '-').trim(),
requiredFrom: String(node.required_from ?? '').trim(),
signerRoles: key === 'signatures' ? toStringList(node.signer_roles) : [],
signatureTypes,
privateSealRestricted: key === 'signatures' ? Boolean(node.private_seal_restricted) : false,
expectedMatchField: String(expectedMatch?.field || '').trim(),
expectedMatchAlternatives: toStringList(expectedMatch?.alternatives),
prompt: String(node.prompt || '').trim(),
}];
});
});
} }
export function buildRuleYamlPack( export function buildRuleYamlPack(
config: RulePackScope & { id: string; yamlPath: string | null }, config: RulePackScope & {
id: string;
yamlPath: string | null;
currentVersionId?: number | null;
fallbackVersionId?: number | null;
resolvedVersionId?: number | null;
},
yamlSource: string, yamlSource: string,
sourceStatus: RuleYamlPack['sourceStatus'] sourceStatus: RuleYamlPack['sourceStatus']
): RuleYamlPack { ): RuleYamlPack {