Compare commits
10 Commits
153865c734
...
c00e5feff0
| Author | SHA1 | Date | |
|---|---|---|---|
| c00e5feff0 | |||
| 87e82d1caa | |||
| d6ce62457a | |||
| 71476fc919 | |||
| e7bac9a33f | |||
| 8fcd79b608 | |||
| 61bbf6907b | |||
| 57c744eb17 | |||
| 2d8bab2c91 | |||
| c7bbe59882 |
+11
-37
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 规则详情页顶部栏 */}
|
{/* 规则详情页顶部栏 */}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || '未知错误'}`);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+949
-171
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user