Compare commits

...

10 Commits

Author SHA1 Message Date
wren c00e5feff0 feat: sync rule management and review ui fixes 2026-05-07 17:27:42 +08:00
wren 87e82d1caa fix: improve rule detail dependency sync 2026-05-07 11:20:55 +08:00
wren d6ce62457a fix: stabilize rule detail config persistence 2026-05-07 10:58:42 +08:00
wren 71476fc919 fix: stabilize rules detail editor flow 2026-05-07 09:59:01 +08:00
wren e7bac9a33f fix: tighten route permission guards 2026-05-06 20:06:41 +08:00
wren 8fcd79b608 fix: unify review detail page badges 2026-05-06 18:34:03 +08:00
wren 61bbf6907b feat: wire real upload progress and subtype mapping 2026-05-06 18:33:53 +08:00
wren 57c744eb17 fix: redirect expired sessions to login 2026-05-06 18:33:43 +08:00
wren 2d8bab2c91 fix: align case-file rule filters with business types 2026-05-06 18:18:49 +08:00
wren c7bbe59882 fix: replace page shorthand labels in review detail 2026-05-06 18:05:51 +08:00
24 changed files with 2554 additions and 674 deletions
+11 -37
View File
@@ -1024,14 +1024,24 @@ function buildFallbackRoutes(roleKey: string): {
const mappedRoleKey = mapUserRoleToRoleKey(roleKey);
const fallbackMenus = FALLBACK_MENU_DATA[mappedRoleKey] || FALLBACK_MENU_DATA.common;
const permissionMap: Record<string, string[]> = {};
const safeFallbackMenus = stripDisallowedFallbackRoutes(fallbackMenus);
return {
success: true,
data: normalizeMenuStructure(fallbackMenus.filter(item => isMinimalMenuPath(item.path))),
data: normalizeMenuStructure(safeFallbackMenus.filter(item => isMinimalMenuPath(item.path))),
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 {
return path === '/rules/sets';
}
@@ -1059,41 +1069,5 @@ function normalizeMenuStructure(menuItems: MenuItem[]): MenuItem[] {
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;
}
+14 -10
View File
@@ -219,10 +219,12 @@ function getFileExtension(filename: string): string {
function mapProcessingStatusToFileStatus(status?: string | null): string {
const normalized = (status || '').toLowerCase();
if (normalized === 'completed') return 'Processed';
if (normalized === 'completed' || normalized === 'processed') return 'Processed';
if (normalized === 'failed') return 'Failed';
if (normalized === 'running' || normalized === 'queued' || normalized === 'dispatch') return 'Evaluationing';
if (normalized === 'waiting' || normalized === 'pending') return 'Waiting';
if (normalized === 'cutting') return 'Cutting';
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';
}
@@ -827,13 +829,15 @@ export async function getDocumentsListFromAPI(searchParams: {
if (name) params.keyword = name;
if (fileStatus) {
const normalizedFileStatus = fileStatus.toLowerCase();
if (normalizedFileStatus === 'processed') {
params.processingStatus = 'completed';
} else if (normalizedFileStatus === 'failed') {
params.processingStatus = 'failed';
} else {
params.processingStatus = 'running';
}
const processingStatusMap: Record<string, string> = {
waiting: 'waiting',
cutting: 'Cutting',
extractioning: 'Extractioning',
evaluationing: 'Evaluationing',
processed: 'completed',
failed: 'failed',
};
params.processingStatus = processingStatusMap[normalizedFileStatus] || fileStatus;
}
if (documentTypeIds && documentTypeIds.length > 0) {
+85 -20
View File
@@ -147,8 +147,10 @@ export interface DocumentType {
name: string;
code?: string;
entryModuleId?: number;
entryModuleName?: string | null;
isEnabled?: boolean;
ruleSetIds?: number[];
childDocumentTypeIds?: number[];
}
export interface DocumentSubtypeGroup {
@@ -233,6 +235,12 @@ export interface UploadResult {
error?: string;
}
export interface UploadProgressInfo {
loaded: number;
total: number;
percent: number;
}
// 旧接口上传响应(uploadContractTemplate / appendContractAttachments 仍在使用)
interface LegacyUploadResponse {
success: boolean;
@@ -684,6 +692,7 @@ export async function uploadDocumentToServer(
autoRun: boolean = true,
speed: string = "normal",
jwtToken?: string,
onProgress?: (progress: UploadProgressInfo) => void,
): Promise<{ data: UploadResult } | { error: string; status?: number; payload?: UploadErrorPayload }> {
try {
const formData = new FormData();
@@ -711,7 +720,23 @@ export async function uploadDocumentToServer(
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;
// Result<DocumentUploadVO> envelope
@@ -826,8 +851,6 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT
const params: Record<string, string> = {};
if (selectedModuleId) {
params.entry_module_id = String(selectedModuleId);
} else if (documentTypeIds && documentTypeIds.length > 0) {
params.ids = documentTypeIds.join(",");
}
const headers: Record<string, string> = {};
@@ -835,18 +858,52 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT
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;
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[] }) => ({
id: item.id,
name: item.name,
code: item.code,
entryModuleId: item.entryModuleId,
isEnabled: item.isEnabled,
ruleSetIds: item.ruleSetIds,
}));
let types: DocumentType[] = body.data.map((item: {
id: number;
name: string;
code?: string;
entryModuleId?: number | null;
entryModuleName?: string | null;
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 { error: body?.message || "获取文档类型失败", status: response.status };
@@ -919,18 +976,26 @@ async function fetchAllEvaluationPointGroupRoots(token?: string): Promise<any[]>
function collectSubtypeGroupsFromRoots(
roots: any[],
documentTypeId: number,
rootOrDocumentTypeId: number,
entryModuleId?: number | null,
): DocumentSubtypeGroup[] {
return dedupeSubtypeGroups(
roots.flatMap((root: any) => {
if (!Array.isArray(root?.children)) return [];
if (entryModuleId && Number(root?.entry_module_id || 0) !== Number(entryModuleId)) {
return [];
}
const scopedRoots = roots.filter((root: any) => {
if (entryModuleId && Number(root?.entry_module_id || 0) !== Number(entryModuleId)) {
return false;
}
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
.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));
}),
);
+15 -2
View File
@@ -31,6 +31,17 @@ export function ClientAuthGuard({ isPublicPath, frontendJWT, userInfo }: ClientA
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。
// 不能只在本地没有 token 时才回填,否则本地残留旧 token 会导致:
// - SSR 页面可打开(服务端 session 是新的)
@@ -55,14 +66,16 @@ export function ClientAuthGuard({ isPublicPath, frontendJWT, userInfo }: ClientA
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 });
} else {
// console.log('✅ [Auth Guard] 已认证,允许访问');
}
}, [isPublicPath, navigate, location.pathname, frontendJWT, userInfo]);
}, [isPublicPath, navigate, location.pathname, location.search, frontendJWT, userInfo]);
// 这个组件不渲染任何内容
return null;
+9 -6
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import type { MenuItem } from '~/api/auth/user-routes';
import { Sidebar } from './Sidebar';
// import { Header } from './Header';
import { Breadcrumb } from './Breadcrumb';
@@ -10,6 +11,7 @@ interface LayoutProps {
userRole?: UserRole;
frontendJWT?: string;
isMobile?: boolean; // 是否为移动端设备(服务端通过 User-Agent 检测)
menuItems?: MenuItem[];
}
// 添加一个接口表示路由handle可能包含的属性
@@ -30,13 +32,14 @@ type RulesTestDetailData = {
pack?: {
documentType?: string;
mainType?: string;
businessType?: string;
fields?: unknown[];
subDocuments?: 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 [effectiveUserRole, setEffectiveUserRole] = useState<UserRole>(userRole);
const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState<string>(frontendJWT);
@@ -136,13 +139,12 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
const isRulesTestTopbarPage = isRulesTestDetail;
const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined;
const detailPack = rulesTestDetailData?.pack;
const isContractDetail = !!detailPack?.documentType?.includes('合同');
const isCaseFileDetail = !!detailPack?.documentType?.includes('案卷');
const showFieldNav = isContractDetail && (detailPack?.fields?.length || 0) > 0;
const showSubDocumentNav = isCaseFileDetail && (detailPack?.subDocuments?.length || 0) > 0;
const detailPackFilterMainType = detailPack?.businessType || detailPack?.mainType || '';
const showFieldNav = (detailPack?.fields?.length || 0) > 0;
const showSubDocumentNav = (detailPack?.subDocuments?.length || 0) > 0;
const showVisualNav = (detailPack?.visualElements?.length || 0) > 0;
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';
return (
@@ -153,6 +155,7 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
onToggle={toggleSidebar}
userRole={effectiveUserRole}
frontendJWT={effectiveFrontendJWT}
menuItems={menuItems}
/>
{/* 规则详情页顶部栏 */}
+12 -31
View File
@@ -10,13 +10,14 @@ interface SidebarProps {
collapsed: boolean;
userRole: UserRole;
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 [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [menuItems, setMenuItems] = useState<MenuItem[]>([]); // 动态菜单项
const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(true); // 路由加载状态
const [menuItems, setMenuItems] = useState<MenuItem[]>(initialMenuItems); // 动态菜单项
const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(initialMenuItems.length === 0); // 路由加载状态
const [isMobile, setIsMobile] = useState<boolean>(false); // 移动端检测
const [selectedModuleName, setSelectedModuleName] = useState<string>(''); // 当前选中的模块名称
const [selectedModulePicPath, setSelectedModulePicPath] = useState<string>(''); // 当前选中的模块图片路径
@@ -39,12 +40,15 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
// 获取用户路由权限
useEffect(() => {
// console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限');
if (initialMenuItems.length > 0) {
setMenuItems(initialMenuItems);
setIsLoadingRoutes(false);
return;
}
const fetchUserRoutes = async () => {
setIsLoadingRoutes(true);
try {
// 优先使用传入的 frontendJWT,否则从 localStorage 读取
let jwt = frontendJWT;
if (!jwt && typeof window !== 'undefined') {
@@ -59,29 +63,20 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
return;
}
// console.log('🔍 [Sidebar] 当前用户角色:', userRole, 'JWT前20字符:', jwt.substring(0, 20));
// console.log('🔍 [Sidebar] 映射后的角色key:', roleKey);
const result = await getUserRoutesByRole(userRole, jwt);
if (result.success && result.data) {
setMenuItems(result.data);
// console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data);
} else {
console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error);
// 如果需要重定向到首页
if (result.shouldRedirectToHome) {
// console.log('🔄 [Sidebar] 重定向到首页');
navigate('/');
return;
}
// 其他错误情况,使用空数组
setMenuItems([]);
}
} catch (error) {
console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error);
// 发生异常时也重定向到首页
navigate('/');
return;
} finally {
@@ -90,7 +85,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
};
fetchUserRoutes();
}, [userRole, frontendJWT, navigate]);
}, [userRole, frontendJWT, navigate, initialMenuItems]);
// 🔑 检查是否处于系统设置模式或交叉评查模式
const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false);
@@ -344,22 +339,8 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
if (isCaseFileModule) {
return {
...item,
children: [
{
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
}
]
path: buildRulesTestListPath(),
children: undefined
};
}
+22 -11
View File
@@ -27,12 +27,13 @@ interface ReviewTabsProps {
comparisonId?: number;
};
onConfirmResults: () => void;
onExportReport?: () => void;
jwtToken?: string | null;
/** 下载前保存文档的回调,返回 true 表示保存成功可以继续下载 */
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 [isReuploadModalOpen, setIsReuploadModalOpen] = useState(false);
const [selectedTemplateFiles, setSelectedTemplateFiles] = useState<File[]>([]);
@@ -58,14 +59,21 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
: previousRoute === 'filesUpload'
? "/files/upload"
: "/rules-files";
// 立即导航返回
navigate(returnTo);
// 触发上级页面数据重新加载
navigate(returnTo);
setTimeout(() => {
revalidator.revalidate();
setIsNavigating(false);
loadingBarService.hide();
}, 0);
};
// 下载原文件
const handleDownloadFile = async () => {
if (!fileInfo.path) {
toastService.warning('当前文档暂无可下载原文件');
return;
}
try {
// 如果有保存回调,先执行保存(仅对 DOCX 文件有效)
if (onSaveBeforeDownload) {
@@ -311,12 +319,15 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
</>
)}
</button>
{/* <button
className="ant-btn ant-btn-default flex items-center"
onClick={handleExportReport}
>
<i className="ri-file-copy-line mr-1"></i> 导出评查报告
</button> */}
{onExportReport && (
<button
className="ant-btn ant-btn-default inline-flex items-center my-2"
onClick={onExportReport}
disabled={isNavigating}
>
<i className="ri-file-copy-line mr-1"></i>
</button>
)}
<button
className={`ant-btn ant-btn-primary my-2 flex items-center ${fileInfo.auditStatus === 1 ? 'hidden' : ''}`}
onClick={onConfirmResults}
@@ -437,4 +448,4 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
</Modal>
</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({
reviewPoints,
onFieldClick,
@@ -218,16 +229,16 @@ function ExtractedFieldsPanel({
{f.page ? (
<button
type="button"
className="mt-0.5 text-[10px] text-[#00684a] hover:underline"
className={getPageBadgeClass(f.page)}
onClick={(event) => {
event.stopPropagation();
handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight);
}}
>
p.{f.page}
{formatPageLabel(f.page)}
</button>
) : (
<div className="mt-0.5 text-[10px] text-slate-300">-</div>
<div className={getPageBadgeClass()}></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 系统 ──
let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false };
function TooltipPortal() {
@@ -708,7 +719,9 @@ function LeauditReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
{enterpriseButton && enterpriseButton}
</div>
<div className="flex items-center gap-2 shrink-0">
{page && <span className="text-[10.5px] text-slate-400 shrink-0">P{page}</span>}
<span className={`${getPageBadgeClass(page)} shrink-0`}>
{formatPageLabel(page)}
</span>
</div>
</div>
<div
+27 -5
View File
@@ -69,9 +69,21 @@ export type { UserRole };
// 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由)
interface MenuItem {
path: string;
title?: string;
hideBreadcrumb?: boolean;
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[] {
const paths: string[] = [];
@@ -208,6 +220,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
let userInfo: any = null;
let allowedPaths: string[] = []; // 用户允许访问的路由列表
let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表
let menuItems: MenuItem[] = [];
if (!isPublicPath) {
try {
@@ -252,6 +265,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
allowedPaths = extractAllPaths(routesResult.data);
// console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths);
menuItems = filterVisibleMenuItems(routesResult.data as MenuItem[]);
// ✅ 保存权限映射表
if (routesResult.permissionMap) {
permissionMap = routesResult.permissionMap;
@@ -300,12 +315,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (error instanceof Error && error.name === 'AuthenticationError') {
console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页");
// 保存当前路径,登录后可以跳转回来
const redirectTo = pathname !== '/login' ? pathname : '/';
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`);
const redirectTo = pathname !== '/login' ? `${pathname}${url.search}` : '/';
return redirect(`/login?expired=true&redirect=${encodeURIComponent(redirectTo)}`);
}
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() 中统一处理
@@ -360,6 +381,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
isPublicPath, // 传递给客户端,用于判断是否需要认证
isMobile, // 🔒 传递移动端标识
permissionMap, // ✅ 传递权限映射表
menuItems,
ENV: {
// 客户端不再需要直接调用 Dify API
},
@@ -395,7 +417,7 @@ export function links() {
}
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 (
@@ -435,7 +457,7 @@ export default function App() {
{isPublicPath ? (
<Outlet />
) : (
<Layout userRole={userRole} frontendJWT={frontendJWT} isMobile={isMobile}>
<Layout userRole={userRole} frontendJWT={frontendJWT} isMobile={isMobile} menuItems={menuItems}>
<Outlet />
</Layout>
)}
+4 -1
View File
@@ -26,7 +26,10 @@ interface LoaderData {
export async function loader({ request }: LoaderFunctionArgs) {
try {
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);
return {
+3 -1
View File
@@ -33,7 +33,9 @@ interface LoaderData {
export async function loader({ request }: LoaderFunctionArgs) {
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 editId = url.searchParams.get("id");
+68 -17
View File
@@ -82,6 +82,8 @@ interface DocumentListScope {
documentTypeIds: number[];
}
const LIST_SCOPE_STORAGE_KEY = 'documents.listScope';
// 审核状态筛选选项
const auditStatusOptions = [
// { value: "", label: "全部" },
@@ -257,6 +259,10 @@ export default function DocumentsIndex() {
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 恢复
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 {
const nextScope = readListScopeFromSession();
setListScope(nextScope);
persistListScope(nextScope);
setScopeReady(true);
} catch (error) {
console.error('❌ [useEffect] 初始化文档列表作用域失败:', error);
@@ -421,7 +459,7 @@ export default function DocumentsIndex() {
setScopeReady(true);
loadingBarService.hide();
}
}, [readListScopeFromSession]);
}, [persistListScope, readListScopeFromSession]);
// 监听 URL 参数变化,重新获取数据
useEffect(() => {
@@ -628,6 +666,11 @@ export default function DocumentsIndex() {
// 下载文档
const handleDownload = (path: string) => {
if (!path) {
toastService.warning('当前版本暂无可下载原文件');
return;
}
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(path)}`;
@@ -650,7 +693,7 @@ export default function DocumentsIndex() {
// 删除文档
const handleDelete = (id: string, name: string, fileStatus: string) => {
// 禁止删除处理中的文件
if (fileStatus !== "Processed" && fileStatus !== "Failed") {
if (!isDeletableFileStatus(fileStatus)) {
toastService.warning("文件正在处理中,无法删除");
return;
}
@@ -684,7 +727,7 @@ export default function DocumentsIndex() {
// 检查是否有正在处理中的文件
const hasProcessingFiles = documents.some((doc: DocumentUI) =>
selectedRowKeys.includes(doc.id.toString()) && doc.fileStatus !== 'Processed'
selectedRowKeys.includes(doc.id.toString()) && !isDeletableFileStatus(doc.fileStatus)
);
if (hasProcessingFiles) {
@@ -1149,6 +1192,10 @@ export default function DocumentsIndex() {
// 渲染历史版本行的辅助函数
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 (
<tr key={`history-${historyDoc.id}`} className="history-row">
<td className="align-middle px-4 py-3" style={{ width: '50px' }}>
@@ -1194,16 +1241,20 @@ export default function DocumentsIndex() {
})()}
</td>
<td className="px-4 py-3" style={{ width: '15%' }}>
<ResultStats
passCount={historyDoc.pass_count}
warningCount={historyDoc.warning_count}
errorCount={historyDoc.error_count}
manualCount={historyDoc.manual_count}
previousPassCount={historyDoc.previous_pass_count}
previousWarningCount={historyDoc.previous_warning_count}
previousErrorCount={historyDoc.previous_error_count}
previousManualCount={historyDoc.previous_manual_count}
/>
{canShowHistoryStats ? (
<ResultStats
passCount={historyDoc.pass_count}
warningCount={historyDoc.warning_count}
errorCount={historyDoc.error_count}
manualCount={historyDoc.manual_count}
previousPassCount={historyDoc.previous_pass_count}
previousWarningCount={historyDoc.previous_warning_count}
previousErrorCount={historyDoc.previous_error_count}
previousManualCount={historyDoc.previous_manual_count}
/>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</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%' }}>
@@ -1230,7 +1281,7 @@ export default function DocumentsIndex() {
</Link>
)}
{/* 下载按钮 - 需要 document:document:view 权限 */}
{canView && (
{canView && canDownloadHistory && (
<button
type="button"
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>
)}
{/* 追加附件和上传模板按钮 - 需要 document:document:view 权限 */}
{canView && parentDoc.type === '1' && historyDoc.fileStatus === 'Processed' && (
{canView && canAppendHistoryAssets && (
<>
<button
type="button"
@@ -1272,7 +1323,7 @@ export default function DocumentsIndex() {
</>
)}
{/* 删除按钮 - 需要 document:document:view 权限 */}
{canView && (
{canView && isDeletableFileStatus(historyDoc.fileStatus) && (
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
+180 -166
View File
@@ -25,6 +25,7 @@ import {
type DocumentType,
type DocumentSubtypeGroup,
type UploadErrorDetails,
type UploadProgressInfo,
type UploadResult,
DocumentStatus
} from "~/api/files/files-upload";
@@ -139,6 +140,7 @@ async function handleFileUpload(
createdBy?: number,
attachments?: File[],
jwtToken?: string,
onProgress?: (progress: UploadProgressInfo) => void,
): Promise<UploadResult> {
const speed = priority === Priority.NORMAL ? "normal" : "urgent";
@@ -154,6 +156,7 @@ async function handleFileUpload(
true,
speed,
jwtToken,
onProgress,
);
if ("error" in response || !response.data) {
@@ -172,6 +175,48 @@ async function handleFileUpload(
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返回数据的类型
type ActionData = {
errors?: {
@@ -199,7 +244,7 @@ export async function action({ request }: ActionFunctionArgs) {
const errors: Record<string, string> = {};
if (!fileType) {
errors.fileType = "上传文件之前请选择文类型";
errors.fileType = "上传文件之前请选择文类型";
}
if (!fileUpload) {
@@ -408,6 +453,9 @@ export default function FilesUpload() {
const hasMultipleSubtypeGroups = subtypeGroups.length > 1;
const singleSubtypeGroup = subtypeGroups.length === 1 ? subtypeGroups[0] : null;
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 selectedEntryModuleName = selectedSubtypeGroup?.entryModuleName || singleSubtypeGroup?.entryModuleName || "";
@@ -476,7 +524,11 @@ export default function FilesUpload() {
const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined);
if (!cancelled && !scopedTypesResponse.error && 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);
await filterDocuments(effectiveTypeIds);
if (effectiveTypeIds && effectiveTypeIds.includes(1)) {
setIsContractType(true);
const contractType = scopedTypes.find(type => type.id === 1);
if (contractType) {
setFileType(contractType.id.toString());
setFileTypeError(null);
}
if (scopedTypes.length === 1) {
const onlyType = scopedTypes[0];
setFileType(onlyType.id.toString());
setIsContractType(onlyType.name.includes('合同'));
setFileTypeError(null);
} else {
setIsContractType(false);
}
@@ -527,7 +577,10 @@ export default function FilesUpload() {
}
// 根据 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);
};
@@ -590,7 +643,6 @@ export default function FilesUpload() {
const [completedFiles, setCompletedFiles] = useState<UploadedFile[]>([]);
// 计时器引用 - 分离为三个独立的定时器
const uploadProgressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const processingStatusIntervalRef = useRef<NodeJS.Timeout | null>(null);
const queueStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); // 原 statusCheckIntervalRef
@@ -828,7 +880,9 @@ export default function FilesUpload() {
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('合同'));
// console.log('【调试-handleFileTypeChange】文件类型检查:', {
// selectedType,
@@ -859,7 +913,7 @@ export default function FilesUpload() {
setFileType("");
setIsContractType(false);
// 如果用户选择了空选项,显示错误信息
setFileTypeError("上传文件之前请选择文类型");
setFileTypeError("上传文件之前请选择文类型");
}
};
@@ -1264,7 +1318,12 @@ export default function FilesUpload() {
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) {
const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
@@ -1291,31 +1350,8 @@ export default function FilesUpload() {
setProcessingSteps(updatedSteps);
}
// 计算总大小并开启与旧逻辑一致的模拟进度(按时间推进到 95%)
const totalSize = filesForProgress.reduce((sum, f) => sum + (f?.size || 0), 0);
const startTime = Date.now();
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);
setUploadSpeed("上传中");
// 转二进制
const binaryData = await uploadFileToBinary(mainFile);
@@ -1324,9 +1360,20 @@ export default function FilesUpload() {
const region = (loaderData.userInfo?.area as string) || "default";
const createdBy = loaderData.userInfo?.user_id as number | undefined;
const uploadResp = await handleFileUpload(
binaryData, mainFile.name, mainFile.type,
fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority,
region, createdBy, attachmentFiles, loaderData.frontendJWT || undefined,
binaryData,
mainFile.name,
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) {
@@ -1350,10 +1397,6 @@ export default function FilesUpload() {
}
// 完成:清理进度定时器并置满
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
setUploadProgress(100);
setUploadSpeed('完成');
@@ -1378,11 +1421,6 @@ export default function FilesUpload() {
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('合同首传上传失败:', error);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
// 清空合同模板文件缓存
setContractTemplateFiles([]);
console.log('【合同上传失败】已清空合同模板文件缓存');
@@ -1412,7 +1450,7 @@ export default function FilesUpload() {
// 检查是否选择了文件类型
if (!fileType) {
console.error('【调试-checkAndPrepareUpload】未选择文件类型');
toastService.error('请先选择文类型');
toastService.error('请先选择文类型');
return;
}
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('合同'));
// console.log('【调试-checkAndPrepareUpload】文件类型检查', {
@@ -1454,7 +1492,12 @@ export default function FilesUpload() {
// 检查主文件名称是否重复(在任何状态变化之前进行检查)
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) {
const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
@@ -1505,8 +1548,8 @@ export default function FilesUpload() {
}
}
} else {
console.error('【调试-checkAndPrepareUpload】未选择文类型,无法上传');
toastService.error('请选择文类型');
console.error('【调试-checkAndPrepareUpload】未选择文类型,无法上传');
toastService.error('请选择文类型');
}
} else {
console.error('【调试-checkAndPrepareUpload】没有文件可上传');
@@ -1551,13 +1594,12 @@ export default function FilesUpload() {
setUploadStage("uploading");
setUploadProgress(0);
setUploadSpeed("上传中");
// 计算总文件大小
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
let uploadedSize = 0;
// console.log('【调试-startUpload】总文件大小:', formatFileSize(totalSize));
// 更新步骤状态
const updatedSteps = [...processingSteps];
updatedSteps[0].status = "active";
@@ -1571,33 +1613,6 @@ export default function FilesUpload() {
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[] = [];
@@ -1629,44 +1644,35 @@ export default function FilesUpload() {
// 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添加超时处理
const region = (loaderData.userInfo?.area as string) || "default";
const createdBy = loaderData.userInfo?.user_id as number | undefined;
if (!effectiveDocumentTypeId) {
throw new Error("当前子类型缺少可提交的文档类型绑定,请先检查评查点分组配置");
}
const uploadPromise = handleFileUpload(
binaryData, file.name, file.type,
fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority,
region, createdBy, undefined, loaderData.frontendJWT || undefined,
binaryData,
file.name,
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) => {
@@ -1675,16 +1681,7 @@ export default function FilesUpload() {
}, 600000);
});
// 并行执行上传和进度更新
const [uploadResult] = await Promise.all([
Promise.race([uploadPromise, timeoutPromise]),
progressPromise
]);
// 清除进度定时器
if (progressInterval) {
clearInterval(progressInterval);
}
const uploadResult = await Promise.race([uploadPromise, timeoutPromise]);
// 再次检查组件是否已卸载
if (!isMountedRef.current) {
@@ -1748,10 +1745,6 @@ export default function FilesUpload() {
}
// 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
// 更新上传状态
setUploadProgress(100);
setUploadSpeed("完成");
@@ -1763,7 +1756,7 @@ export default function FilesUpload() {
return {
id,
name: file.name,
type_id: fileType ? parseInt(fileType) : 0,
type_id: effectiveDocumentTypeId || (fileType ? parseInt(fileType) : 0),
file_size: file.size,
status: DocumentStatus.CUTTING,
created_at: new Date().toISOString()
@@ -1789,11 +1782,6 @@ export default function FilesUpload() {
setProcessingSteps(errorSteps);
// 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
showFriendlyUploadError(error, { titlePrefix: '文件上传失败' });
resetUpload();
@@ -1824,6 +1812,8 @@ export default function FilesUpload() {
updatedSteps[1].description = "文档正在排队等待处理...";
setProcessingSteps(updatedSteps);
setUploadProgress(mapProcessingStatusToProgress(DocumentStatus.QUEUED));
setUploadSpeed(mapProcessingStatusToSpeed(DocumentStatus.QUEUED));
// 获取文件ID列表
const fileIds = files.map(file => file.id).filter(id => id > 0);
@@ -1925,6 +1915,17 @@ export default function FilesUpload() {
// console.log('【调试-checkProcessingStatus】没有返回文件状态数据');
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);
@@ -1955,6 +1956,8 @@ export default function FilesUpload() {
completedSteps[5].description = "文档已准备就绪,可以查看";
setProcessingSteps(completedSteps);
setUploadProgress(100);
setUploadSpeed(mapProcessingStatusToSpeed(DocumentStatus.PROCESSED));
setUploadStage("completed");
} else {
// 根据当前状态更新步骤
@@ -2036,9 +2039,18 @@ export default function FilesUpload() {
updatedSteps[5].status = "done";
updatedSteps[5].description = "文档已准备就绪,可以查看";
break;
case DocumentStatus.FAILED:
updatedSteps[1].status = "done";
updatedSteps[1].description = "已进入处理队列";
updatedSteps[2].status = "error";
updatedSteps[2].description = "文档处理失败,请检查上传文件或稍后重试";
break;
}
setProcessingSteps(updatedSteps);
setUploadProgress(mapProcessingStatusToProgress(status));
setUploadSpeed(mapProcessingStatusToSpeed(status));
};
// 更新队列中文件的状态
@@ -2063,12 +2075,6 @@ export default function FilesUpload() {
// 重置上传状态 - 不清除队列状态检查定时器
const resetUpload = () => {
// 清除上传和处理相关的定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null;
@@ -2136,7 +2142,11 @@ export default function FilesUpload() {
// 获取文档类型名称
const getDocumentTypeName = (codeId: number) => {
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}>
{/* 文件类型选择 */}
<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="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
id="file-type-select"
name="fileType"
@@ -2402,7 +2412,7 @@ export default function FilesUpload() {
onChange={handleFileTypeChange}
disabled={uploadStage !== "idle"}
>
<option value=""></option>
<option value=""></option>
{documentTypesState.map(type => (
<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="form-tip"></div>
<div className="form-tip"></div>
</div>
<div className="form-group">
<label htmlFor="priority-select" className="form-label"></label>
@@ -2446,11 +2456,11 @@ export default function FilesUpload() {
required={subtypeGroups.length > 1}
>
{!fileType ? (
<option value=""></option>
<option value=""></option>
) : groupOptionsLoading ? (
<option value="">...</option>
) : subtypeGroups.length === 0 ? (
<option value=""></option>
<option value=""></option>
) : subtypeGroups.length === 1 ? (
<option value={String(subtypeGroups[0].id)}>
{getSubtypeDisplayName(subtypeGroups[0])}
@@ -2468,27 +2478,35 @@ export default function FilesUpload() {
</select>
<div className="form-tip">
{!fileType
? "请先选择文件类型,再确定本次上传实际命中的子类型。"
? "请先选择一级文档类型,再确定本次上传实际命中的子类型。"
: groupOptionsLoading
? "正在加载当前文档类型下可用的子类型配置。"
? "正在加载当前一级文档类型下可用的子类型配置。"
: subtypeGroups.length === 0
? "当前文档类型在当前入口下还没有可用子类型,请先到评查点分组管理补齐“一级分组 / 二级分组 / 规则集”绑定。"
? "当前一级文档类型在当前入口下还没有可用子类型,请先到评查点分组管理补齐“一级分组 / 二级分组 / 规则集”绑定。"
: hasMultipleSubtypeGroups
? "同一文档类型在当前入口下已拆分多个子类型,请选择本次上传实际命中的子类型。"
? "当前一级文档类型在当前入口下已拆分多个子类型,请选择本次上传实际命中的子类型。"
: isSingleDefaultSubtype
? "当前文档类型在当前入口下尚未拆分业务子类型,系统将按默认子类型处理;后续可在评查点分组管理中继续细分。"
: "当前文档类型在当前入口下仅配置了个子类型,系统自动带出该子类型。"}
? "当前一级文档类型在当前入口下尚未拆分业务子类型,系统将按默认子类型处理;后续可在评查点分组管理中继续细分。"
: "当前一级文档类型在当前入口下仅配置了 1 个子类型,系统自动带出。"}
</div>
{selectedRootGroupName ? (
<div className="form-tip">
{selectedRootGroupName}
{selectedEntryModuleName ? ` · 入口模块:${selectedEntryModuleName}` : ""}
</div>
) : null}
{selectedSubtypeGroup ? (
{selectedEntryModuleName ? (
<div className="form-tip">
{getSubtypeDisplayName(selectedSubtypeGroup)}
{selectedSubtypeGroup.displayHint ? ` · ${selectedSubtypeGroup.displayHint}` : ""}
{selectedEntryModuleName}
</div>
) : null}
{effectiveSubtypeGroup ? (
<div className="form-tip">
{getSubtypeDisplayName(effectiveSubtypeGroup)}
</div>
) : null}
{effectiveSubtypeGroup?.code ? (
<div className="form-tip">
{effectiveSubtypeGroup.code}
</div>
) : null}
</div>
@@ -2802,7 +2820,7 @@ export default function FilesUpload() {
fileName={`${currentFiles.length}个文件`}
fileSize={formatFileSize(currentFiles.reduce((sum, file) => sum + file.size, 0))}
progress={uploadProgress}
speed={''}
speed={uploadSpeed}
/>
)}
@@ -2867,10 +2885,6 @@ export default function FilesUpload() {
type="default"
icon="ri-refresh-line"
onClick={() => {
// 清除所有定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
}
+33 -8
View File
@@ -25,7 +25,7 @@
* @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 { useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
import reviewsStyles from "~/styles/reviews.css?url";
@@ -345,17 +345,24 @@ export const handle = {
};
export async function loader({ request }: LoaderFunctionArgs): Promise<Response> {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id') || '';
const previousRoute = url.searchParams.get('previousRoute') || '';
const url = new URL(request.url);
const id = url.searchParams.get('id') || '';
const previousRoute = url.searchParams.get('previousRoute') || '';
try {
if (!id) {
return Response.json({ result: false, message: '文件ID不能为空', previousRoute });
}
const { getUserSession } = await import("~/api/login/auth.server");
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);
if ('error' in reviewData && reviewData.error) {
@@ -382,11 +389,25 @@ export async function loader({ request }: LoaderFunctionArgs): Promise<Response>
detailMode: 'leaudit',
});
} 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);
return Response.json({
result: false,
message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}`,
previousRoute: '',
previousRoute,
});
}
}
@@ -621,6 +642,11 @@ export default function ReviewDetails() {
};
const handleDownloadFile = async () => {
if (!previewPath) {
toastService.warning('当前文档暂无可下载原文件');
return;
}
try {
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(previewPath)}`;
const response = await axios.get(downloadUrl, { responseType: 'blob' });
@@ -828,8 +854,7 @@ export default function ReviewDetails() {
if (result.success) {
toastService.success('评查结果已确认,文档审核状态已更新');
// 导航到文档列表页
navigate('/documents/list');
navigate(getReturnUrl());
} else {
console.error('确认评查结果失败:', result.error);
toastService.error(`确认评查结果失败: ${result.error || '未知错误'}`);
+369 -31
View File
@@ -1,5 +1,5 @@
import { type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
@@ -15,6 +15,7 @@ import {
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { API_BASE_URL } from "~/config/api-config";
import { usePermission } from "~/hooks/usePermission";
import { parseRuleSummariesFromYaml, type RuleSummary } from "~/utils/rule-yaml-parser";
export function links() {
@@ -71,6 +72,32 @@ interface LoaderData {
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 {
id?: number;
mode: "create" | "edit";
@@ -119,22 +146,32 @@ async function fetchGroupTree(token?: string | null): Promise<RuleGroupNode[]> {
export async function loader({ request }: LoaderFunctionArgs) {
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([
fetchGroupTree(frontendJWT),
getDocumentTypes({ page: 1, pageSize: 500 }, frontendJWT),
getEntryModules(frontendJWT),
getRuleSets(frontendJWT),
]);
await requireRoutePermission("/rule-groups", userInfo?.role || "", frontendJWT || undefined);
return Response.json({
groups,
docTypes: docTypesRes.data?.types || [],
entryModules: entryModulesRes.data || [],
ruleSets: ruleSetsRes.data || [],
frontendJWT,
} satisfies LoaderData);
try {
const [groups, docTypesRes, entryModulesRes, ruleSetsRes] = await Promise.all([
fetchGroupTree(frontendJWT),
getDocumentTypes({ page: 1, pageSize: 500 }, frontendJWT),
getEntryModules(frontendJWT),
getRuleSets(frontendJWT),
]);
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 {
@@ -278,6 +315,75 @@ type RulePreviewState = {
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({
visible,
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() {
const { groups, docTypes, entryModules, ruleSets, frontendJWT } = useLoaderData<LoaderData>();
const navigate = useNavigate();
const { hasAnyPermission } = usePermission();
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 codeValue = searchParams.get("code") || "";
const statusValue = searchParams.get("status") || "";
@@ -521,6 +742,7 @@ export default function RuleGroupsIndex() {
const [rulePreviewMap, setRulePreviewMap] = useState<Record<number, RulePreviewState>>({});
const [groupModalOpen, setGroupModalOpen] = useState(false);
const [bindingModalOpen, setBindingModalOpen] = useState(false);
const [draftModalOpen, setDraftModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [groupForm, setGroupForm] = useState<GroupFormState>({
mode: "create",
@@ -542,8 +764,22 @@ export default function RuleGroupsIndex() {
isActive: true,
});
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 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(() => {
return topGroups
@@ -605,8 +841,12 @@ export default function RuleGroupsIndex() {
const resetFilters = () => setSearchParams(new URLSearchParams());
const reloadPage = () => {
if (typeof window !== "undefined") window.location.reload();
const reloadPage = async (): Promise<RuleGroupNode[]> => {
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) => {
@@ -656,6 +896,39 @@ export default function RuleGroupsIndex() {
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) => {
setActiveBindingGroup(group);
setBindingForm({
@@ -691,7 +964,8 @@ export default function RuleGroupsIndex() {
} else {
await axios.put(`${API_BASE_URL}/api/v3/evaluation-point-groups/${groupForm.id}`, payload, { headers: { ...authHeaders(frontendJWT) } });
}
reloadPage();
await reloadPage();
setGroupModalOpen(false);
} catch (error) {
handleApiError(error);
} finally {
@@ -714,7 +988,8 @@ export default function RuleGroupsIndex() {
} else {
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) {
handleApiError(error);
} 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 payload = response?.data?.data ?? response?.data;
if (payload?.success === false) return window.alert(payload?.message || "删除失败");
reloadPage();
await reloadPage();
} catch (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;
try {
await axios.delete(`${API_BASE_URL}/api/v3/evaluation-point-groups/bindings/${binding.id}`, { headers: authHeaders(frontendJWT) });
reloadPage();
await reloadPage();
} catch (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) => {
setExpandedGroupIds((prev) => {
const next = new Set(prev);
@@ -812,7 +1120,9 @@ export default function RuleGroupsIndex() {
<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-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>
@@ -959,9 +1269,15 @@ export default function RuleGroupsIndex() {
<td>{formatDateTime(root.updated_at || root.created_at)}</td>
<td>
<div className="action-links">
<button type="button" className="action-link button-link" onClick={() => openCreateChild(root)}></button>
<button type="button" className="action-link button-link" onClick={() => openEditGroup(root)}></button>
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(root)}></button>
{canManageGroups ? (
<button type="button" className="action-link button-link" onClick={() => openCreateChild(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>
</td>
</tr>
@@ -1026,9 +1342,18 @@ export default function RuleGroupsIndex() {
>
{child.bindings.length ? (child.bindings.every((item) => expandedBindingIds.has(item.id)) ? "收起规则集" : "查看规则集") : "暂无规则集"}
</button>
<button type="button" className="action-link button-link" onClick={() => openCreateBinding(child)}></button>
<button type="button" className="action-link button-link" onClick={() => openEditGroup(child)}></button>
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(child)}></button>
{canCreateRuleDraft ? (
<button type="button" className="action-link button-link" onClick={() => openCreateRuleDraft(child)}>/YAML</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>
</td>
</tr>
@@ -1071,8 +1396,12 @@ export default function RuleGroupsIndex() {
<button type="button" className="action-link button-link" onClick={() => toggleBindingPreview(binding)}>
{expandedBinding ? "收起规则明细" : "查看组内规则"}
</button>
<button type="button" className="action-link button-link" onClick={() => openEditBinding(child, binding)}></button>
<button type="button" className="action-link danger button-link" onClick={() => deleteBinding(binding)}></button>
{canManageBindings ? (
<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>
</td>
</tr>
@@ -1157,6 +1486,15 @@ export default function RuleGroupsIndex() {
onSubmit={submitBinding}
saving={saving}
/>
<RuleDraftModal
visible={draftModalOpen}
group={activeDraftGroup}
form={draftForm}
onClose={() => setDraftModalOpen(false)}
onChange={(patch) => setDraftForm((prev) => ({ ...prev, ...patch }))}
onSubmit={submitRuleDraft}
onOpenRules={openRuleDraftResult}
/>
</div>
);
}
+8
View File
@@ -237,6 +237,10 @@ function mapApiRuleToModel(apiRule: ApiRule): Rule {
export async function loader({ request }: LoaderFunctionArgs) {
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 参数中提取查询条件
const params = {
@@ -280,6 +284,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: LoaderFunctionArgs) {
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 _action = formData.get('_action');
const ruleId = formData.get('ruleId');
+5
View File
@@ -18,6 +18,11 @@ export const handle = {
export async function loader({ request }: LoaderFunctionArgs) {
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') {
const query = url.searchParams.toString();
File diff suppressed because it is too large Load Diff
+54 -25
View File
@@ -7,8 +7,8 @@ import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterP
import { Pagination } from '~/components/ui/Pagination';
import { Table } from '~/components/ui/Table';
import { Tag, type TagColor } from '~/components/ui/Tag';
import { loadRuleConfigPacks } from '~/utils/rules-config-packs.server';
import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server';
import { loadRuleConfigPackSummaries, type RuleConfigPackSummary } from '~/utils/rules-config-packs.server';
import type { RuleSummary } from '~/utils/rules-yaml-mock.server';
import styles from '~/styles/pages/rules_test.css?url';
export const links = () => [
@@ -31,12 +31,12 @@ type RuleRow = RuleSummary & {
mainType: string;
subtype: string;
yamlName: string;
yamlStatus: RuleYamlPack['sourceStatus'];
yamlStatus: RuleConfigPackSummary['sourceStatus'];
isPlaceholder?: boolean;
};
type LoaderData = {
rows: RuleRow[];
packs: RuleYamlPack[];
filters: {
documentType: string;
mainType: string;
@@ -61,7 +61,7 @@ function unique(values: string[]): string[] {
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(' ');
if (values.includes('合同')) return '合同';
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 || '未分类';
}
function resolveBusinessType(pack: Pick<RuleConfigPackSummary, 'businessType' | 'mainType'>): string {
return pack.businessType || pack.mainType || '';
}
function riskColor(risk: string): TagColor {
if (risk === 'high') return 'red';
if (risk === 'medium') return 'orange';
@@ -95,7 +99,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
pageSize: [10, 20, 30, 50].includes(requestedPageSize) ? requestedPageSize : 10
};
const packs = await loadRuleConfigPacks(request);
const packs = await loadRuleConfigPackSummaries(request);
const packScopes = packs.map(pack => ({
pack,
scope: resolveDocumentScope(pack),
@@ -103,7 +107,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const documentTypes = unique(packScopes.map(item => item.scope));
const requestedDocumentType = requestedFilters.documentType;
const inferredDocumentType = requestedMainType
? packScopes.find(item => item.pack.mainType === requestedMainType)?.scope || ''
? packScopes.find(item => resolveBusinessType(item.pack) === requestedMainType)?.scope || ''
: '';
const currentDocumentType = documentTypes.includes(requestedDocumentType)
? requestedDocumentType
@@ -114,18 +118,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
const scopedFilters = {
...requestedFilters,
documentType: currentDocumentType,
mainType: scopedDocumentPacks.some(pack => pack.mainType === requestedFilters.mainType)
mainType: scopedDocumentPacks.some(pack => resolveBusinessType(pack) === requestedFilters.mainType)
? requestedFilters.mainType
: '',
subtype: scopedDocumentPacks.some(pack =>
(!requestedFilters.mainType || pack.mainType === requestedFilters.mainType) &&
(!requestedFilters.mainType || resolveBusinessType(pack) === requestedFilters.mainType) &&
pack.subtype === requestedFilters.subtype
)
? requestedFilters.subtype
: ''
};
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 ruleGroupSourcePacks = scopedFilters.subtype
@@ -141,7 +145,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : ''
};
const visiblePacks = scopedDocumentPacks.filter(pack =>
(!filters.mainType || pack.mainType === filters.mainType) &&
(!filters.mainType || resolveBusinessType(pack) === filters.mainType) &&
(!filters.subtype || pack.subtype === filters.subtype)
);
@@ -152,13 +156,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
packId: pack.id,
documentType: pack.documentType,
moduleType: pack.moduleType,
mainType: pack.mainType,
mainType: resolveBusinessType(pack),
subtype: pack.subtype,
yamlName: pack.metadata.name || '待配置 YAML',
yamlName: pack.yamlName || '待配置 YAML',
yamlStatus: pack.sourceStatus,
id: `${pack.id}-empty`,
ruleId: '-',
name: '暂无规则配置',
name: `${pack.subtype}待配置`,
group: '待配置',
risk: '-',
score: '-',
@@ -172,7 +176,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
stageCount: 0,
appliesIn: [],
prompt: '',
description: '当前文档类型已保留规则列表与 YAML 配置页流程,等待后续接入规则文件。'
description: pack.sourceStatus === 'missing'
? '当前规则集已建立,但生效版本正文暂未成功加载,请进入配置页检查并重新保存。'
: '当前子类型还没有正式评查点,请进入配置页补充字段、子文档与评查规则。',
isPlaceholder: true,
}];
}
@@ -182,9 +189,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
packId: pack.id,
documentType: pack.documentType,
moduleType: pack.moduleType,
mainType: pack.mainType,
mainType: resolveBusinessType(pack),
subtype: pack.subtype,
yamlName: pack.metadata.name,
yamlName: pack.yamlName,
yamlStatus: pack.sourceStatus
}));
}).filter(row => {
@@ -203,7 +210,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
return Response.json({
rows,
packs,
filters: {
...filters,
page: currentPage
@@ -213,7 +219,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
pageSize: filters.pageSize,
options: {
documentTypes,
mainTypes: unique(scopedDocumentPacks.map(pack => pack.mainType)),
mainTypes: unique(scopedDocumentPacks.map(pack => resolveBusinessType(pack))),
subtypes: subtypeOptions,
ruleGroups: ruleGroupOptions
}
@@ -244,6 +250,16 @@ export default function RulesTestList() {
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = event.target;
if (name === 'mainType') {
updateParams({
mainType: value,
ruleTypeName: undefined,
subtype: undefined,
documentAttributeType: undefined,
ruleGroup: undefined,
});
return;
}
if (name === 'subtype') {
updateParams({ subtype: value, documentAttributeType: undefined, ruleGroup: undefined });
return;
@@ -257,7 +273,7 @@ export default function RulesTestList() {
const handleReset = () => {
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');
setSearchParams(nextParams);
};
@@ -278,7 +294,7 @@ export default function RulesTestList() {
render: (_: unknown, record: RuleRow) => (
<div className="rule-name">
<strong>{record.name}</strong>
<span>{record.ruleId}</span>
<span>{record.isPlaceholder ? record.description : record.ruleId}</span>
</div>
)
},
@@ -306,7 +322,9 @@ export default function RulesTestList() {
width: '8%',
align: 'center' as const,
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%',
align: 'center' as const,
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',
width: '20%',
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,
render: (_: unknown, record: RuleRow) => (
<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>
)
}
@@ -362,6 +380,16 @@ export default function RulesTestList() {
</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
label="子类型"
name="subtype"
@@ -369,6 +397,7 @@ export default function RulesTestList() {
options={options.subtypes.map(subtype => ({ value: subtype, label: subtype }))}
onChange={handleFilterChange}
className="mr-3 w-[18%]"
placeholder={filters.mainType || options.mainTypes.length <= 1 ? '全部' : '请先选择业务类型'}
/>
<FilterSelect
+105 -2
View File
@@ -184,7 +184,10 @@
}
.rules-test-page .rules-test-table td {
padding-top: 18px;
padding-bottom: 18px;
padding-right: 12px;
vertical-align: top;
}
.rules-test-page .rules-test-table td:nth-child(2),
@@ -213,7 +216,9 @@
.rules-test-page .rule-name {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
min-height: 54px;
justify-content: center;
}
.rules-test-page .rule-name strong {
@@ -230,7 +235,9 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
gap: 6px;
min-height: 54px;
justify-content: center;
}
.rules-test-page .rule-check-type span:last-child {
@@ -433,6 +440,89 @@
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 {
margin-top: 12px;
}
@@ -643,6 +733,19 @@
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 select,
.rules-test-page .drawer-form textarea {
+275 -1
View File
@@ -1,5 +1,8 @@
import YAML from 'yaml';
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server';
export type VisualElementSummary = RuleYamlPack['visualElements'][number];
export type DependencyOption = {
value: string;
label: string;
@@ -17,12 +20,13 @@ export type ValidationIssue = {
export type EditableRuleConfig = {
metadata: RuleYamlPack['metadata'];
yamlSource: string;
documentType: string;
mainType: string;
subtype: string;
fields: ExtractFieldSummary[];
subDocuments: SubDocumentSummary[];
visualElements: RuleYamlPack['visualElements'];
visualElements: VisualElementSummary[];
rules: RuleSummary[];
};
@@ -175,6 +179,15 @@ export function validateEditableRuleConfig(config: EditableRuleConfig): Validati
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 => {
@@ -215,6 +228,15 @@ export function validateEditableRuleConfig(config: EditableRuleConfig): Validati
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}'` : "''";
}
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 {
const lines: string[] = [
`# ${config.documentType} / ${config.mainType} / ${config.subtype}`,
@@ -331,6 +594,11 @@ export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSumma
}
export function buildYamlPreview(config: EditableRuleConfig): string {
try {
return serializeEditableRuleConfig(config);
} catch {
// 预览失败时仍回退旧实现,避免页面直接白屏;保存时会使用正式序列化并给出错误。
}
const lines: string[] = [
'metadata:',
` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`,
@@ -347,6 +615,9 @@ export function buildYamlPreview(config: EditableRuleConfig): string {
lines.push(
` - name: ${yamlValue(field.name)}`,
` 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)}`
);
});
@@ -370,6 +641,9 @@ export function buildYamlPreview(config: EditableRuleConfig): string {
lines.push(
` - name: ${yamlValue(field.name)}`,
` 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)}`
);
});
+54
View File
@@ -3,6 +3,7 @@ import { getUserSession } from '~/api/login/auth.server';
import {
buildRuleYamlPack,
EMPTY_RULE_YAML,
type RuleSummary,
type RuleYamlPack,
} from './rules-yaml-mock.server';
@@ -28,6 +29,23 @@ type RuleConfigPackApi = {
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> = {
code?: number;
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);
}
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 {
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 sourceStatus = item.sourceStatus || ((item.yamlText || '').trim() ? 'ready' : 'empty');
@@ -64,6 +107,11 @@ function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack {
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,
},
yamlSource,
sourceStatus,
@@ -92,6 +140,12 @@ async function fetchRuleConfigPayload<T>(request: Request, path: string): Promis
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[]> {
const items = await fetchRuleConfigPayload<RuleConfigPackApi[]>(request, '/api/v3/rule-config-packs');
return items.map(mapApiPackToRuleYamlPack);
+224 -125
View File
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import YAML from 'yaml';
const LEAUDIT_RULES_ROOT = `${process.cwd()}/mock-data/leaudit-rules/packs/yc`;
@@ -7,6 +8,8 @@ export type RulePackScope = {
moduleType: string;
mainType: string;
subtype: string;
businessType?: string;
ruleTypeCode?: string;
};
export type RuleSummary = {
@@ -39,6 +42,7 @@ export type ExtractFieldSummary = {
name: string;
type: string;
multipleEntities: boolean;
allowed?: string[];
requiredFrom: string;
description: string;
};
@@ -58,6 +62,9 @@ export type RuleYamlPack = RulePackScope & {
yamlPath: string | null;
yamlSource: string;
sourceStatus: 'ready' | 'empty' | 'missing';
currentVersionId?: number | null;
fallbackVersionId?: number | null;
resolvedVersionId?: number | null;
metadata: {
typeId: string;
name: string;
@@ -83,9 +90,13 @@ export type RuleYamlPack = RulePackScope & {
name: string;
type: string;
required: string;
requiredFrom?: string;
signerRoles?: string[];
signatureTypes?: string[];
privateSealRestricted?: boolean;
expectedMatchField?: string;
expectedMatchAlternatives?: string[];
prompt?: string;
}>;
};
@@ -138,6 +149,13 @@ function stripYamlValue(value = ''): string {
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 {
const match = section.match(new RegExp(`^\\s{2}${key}:\\s*(.*)$`, 'm'));
return stripYamlValue(match?.[1] || '');
@@ -193,10 +211,10 @@ function splitBlocks(section: string, marker: RegExp): string[] {
function parseRules(source: string): RuleSummary[] {
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 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) {
return [];
@@ -205,10 +223,10 @@ function parseRules(source: string): RuleSummary[] {
const dependencies: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
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;
}
const match = line.match(/^\s{4}-\s+(.+)$/);
const match = line.match(/^\s*-\s+(.+)$/);
if (match) {
dependencies.push(stripYamlValue(match[1]));
}
@@ -252,20 +270,28 @@ function parseRules(source: string): RuleSummary[] {
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 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) {
return [];
}
const baseIndent = lines[start].match(/^\s*/)?.[0].length || indent;
const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
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;
}
const match = line.match(new RegExp(`^\\s{${indent}}-\\s+(.+)$`));
const match = line.match(/^\s*-\s+(.+)$/);
if (match) {
values.push(stripYamlValue(match[1]));
}
@@ -294,25 +320,28 @@ function parseRules(source: string): RuleSummary[] {
};
const readStageList = (block: string, key: string): string[] => {
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) {
return [];
}
const baseIndent = lines[start].match(/^\s*/)?.[0].length || 6;
const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
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;
}
const match = line.match(/^\s{6}-\s+(.+)$/);
const match = line.match(/^\s*-\s+(.+)$/);
if (match) {
values.push(stripYamlValue(match[1]));
}
}
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 fields = readStageList(stageBlock, 'fields');
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('') || '未配置内容';
};
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') || '-';
return {
id,
@@ -342,16 +371,17 @@ function parseRules(source: string): RuleSummary[] {
}).filter(stage => stage.id);
return groups.flatMap(groupBlock => {
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s{2}-\s+rule_id:\s*/).map(ruleBlock => {
const ruleId = stripYamlValue(ruleBlock.match(/^\s{2}-\s+rule_id:\s*(.+)$/m)?.[1] || '');
const name = stripYamlValue(ruleBlock.match(/^\s{4}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 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 group = stripYamlValue(groupBlock.match(/^\s*-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s*-\s+rule_id:\s*/).map(ruleBlock => {
const ruleId = stripYamlValue(ruleBlock.match(/^\s*-\s+rule_id:\s*(.+)$/m)?.[1] || '');
const name = stripYamlValue(ruleBlock.match(/^\s+name:\s*(.+)$/m)?.[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+(?:field|number|chinese|left|right|left_field|right_field|target|element|seal_id|signature_id):\s*(.+)$/gm))
.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 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);
return {
@@ -359,19 +389,19 @@ function parseRules(source: string): RuleSummary[] {
ruleId,
name,
group,
risk: stripYamlValue(ruleBlock.match(/^\s{4}risk:\s*(.+)$/m)?.[1] || 'medium'),
score: stripYamlValue(ruleBlock.match(/^\s{4}score:\s*(.+)$/m)?.[1] || '-'),
type: stripYamlValue(ruleBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || 'deterministic'),
risk: stripYamlValue(ruleBlock.match(/^\s+risk:\s*(.+)$/m)?.[1] || 'medium'),
score: stripYamlValue(ruleBlock.match(/^\s+score:\s*(.+)$/m)?.[1] || '-'),
type: stripYamlValue(ruleBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'deterministic'),
checkTypes,
logic: stripYamlValue(ruleBlock.match(/^\s{4}logic:\s*(.+)$/m)?.[1] || ''),
logic: stripYamlValue(ruleBlock.match(/^\s+logic:\s*(.+)$/m)?.[1] || ''),
subRules,
subRuleIds: readList(ruleBlock, 'rules'),
scope: scope.slice(0, 8),
dependencies: dependencies.slice(0, 8),
dependencies,
stageCount: subRules.length,
appliesIn: readFlexibleList(ruleBlock, 'applies_in'),
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[] {
const section = getTopLevelSection(source, 'extract');
const extractedFields = splitBlocks(section, /^-\s+group:\s*/).flatMap(groupBlock => {
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s{2}-\s+name:\s*/).flatMap(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^\s{2}-\s+name:\s*(.+)$/m)?.[1] || '');
const rawType = stripYamlValue(fieldBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || '-');
const parentField = {
const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
const extractGroups = Array.isArray(parsed?.extract) ? parsed.extract : [];
const extractedFields = extractGroups.flatMap((groupNode) => {
if (!groupNode || typeof groupNode !== 'object') {
return [];
}
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}`,
group,
name,
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
multipleEntities: rawType === 'multi_entity',
requiredFrom: stripYamlValue(fieldBlock.match(/^\s{4}required_from:\s*(.+)$/m)?.[1] || '-'),
description: stripYamlValue(fieldBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || '')
allowed: toStringList(field.allowed),
requiredFrom,
description: String(field.desc || '').trim(),
};
const childFields = Array.from(fieldBlock.matchAll(/^\s{4}-\s+name:\s*(.+)$/gm)).map(match => {
const childName = stripYamlValue(match[1]);
const start = fieldBlock.indexOf(match[0]);
const next = fieldBlock.slice(start + match[0].length).search(/^\s{4}-\s+name:\s*/m);
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');
return {
const childFields = Array.isArray(field.fields) ? field.fields : [];
const normalizedChildFields = childFields.flatMap((childNode) => {
if (!childNode || typeof childNode !== 'object') {
return [];
}
const childField = childNode as Record<string, unknown>;
const childName = String(childField.name || '').trim();
if (!childName) {
return [];
}
return [{
id: `${group}-${name}-${childName}`,
group,
name: `${name}[*].${childName}`,
type: childType,
type: String(childField.type || 'verbatim').trim() || 'verbatim',
multipleEntities: false,
requiredFrom: stripYamlValue(childBlock.match(/^\s{6}required_from:\s*(.+)$/m)?.[1] || parentField.requiredFrom),
description: stripYamlValue(childBlock.match(/^\s{6}desc:\s*(.+)$/m)?.[1] || `${name}的子字段`)
};
allowed: toStringList(childField.allowed),
requiredFrom: String(childField.required_from || requiredFrom).trim() || requiredFrom,
description: String(childField.desc || `${name}的子字段`).trim(),
}];
});
return [parentField, ...childFields];
return [parentField, ...normalizedChildFields];
});
}).filter(field => field.name);
const derivedSection = getTopLevelSection(source, 'derived_fields');
const derivedFields = splitBlocks(derivedSection, /^-\s+name:\s*/).map(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^-\s+name:\s*(.+)$/m)?.[1] || '');
const derivedFields = splitBlocks(derivedSection, /^\s*-\s+name:\s*/).map(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^\s*-\s+name:\s*(.+)$/m)?.[1] || '');
return {
id: `derived-${name}`,
group: '派生字段',
name,
type: stripYamlValue(fieldBlock.match(/^\s{2}type:\s*(.+)$/m)?.[1] || 'computed'),
type: stripYamlValue(fieldBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'computed'),
multipleEntities: false,
requiredFrom: '-',
description: stripYamlValue(fieldBlock.match(/^\s{2}compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出')
description: stripYamlValue(fieldBlock.match(/^\s+compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出')
};
}).filter(field => field.name);
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[] {
const section = getTopLevelSection(source, 'sub_documents');
return splitBlocks(section, /^-\s+id:\s*/).map(docBlock => {
const id = stripYamlValue(docBlock.match(/^-\s+id:\s*(.+)$/m)?.[1] || '');
const groups = Array.from(new Set(Array.from(docBlock.matchAll(/^\s{2,}-\s+group:\s*(.+)$/gm)).map(match => stripYamlValue(match[1]))));
const fields = parseDocumentFields(docBlock, id);
const classifier = docBlock.match(/^\s{2}classifier:\s*$/m);
let description = '';
if (classifier) {
const keywordsMatch = docBlock.match(/keywords:\s*\n((?:\s{4}-\s+.+\n)+)/m);
if (keywordsMatch) {
const keywords = Array.from(keywordsMatch[1].matchAll(/^\s{4}-\s+(.+)$/gm)).map(match => stripYamlValue(match[1])).slice(0, 3);
description = keywords.join('、');
}
const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
const subDocuments = parsed?.sub_documents;
if (!Array.isArray(subDocuments)) {
return [];
}
return subDocuments.flatMap((documentNode) => {
if (!documentNode || typeof documentNode !== 'object') {
return [];
}
return {
id,
name: stripYamlValue(docBlock.match(/^\s{2}name:\s*(.+)$/m)?.[1] || 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) {
const document = documentNode as Record<string, unknown>;
const id = String(document.id || '').trim();
if (!id) {
return [];
}
// 找到下一个同级分类的起始位置(2空格+字母+冒号)
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^\s{2}[a-zA-Z_][\w-]*:/.test(lines[i])) {
end = i;
break;
const extractGroups = Array.isArray(document.extract) ? document.extract : [];
const fields = extractGroups.flatMap((groupNode) => {
if (!groupNode || typeof groupNode !== 'object') {
return [];
}
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 splitBlocks(subSection, /^\s{2}-\s+id:\s*/).map(block => ({
id: stripYamlValue(block.match(/^\s{2}-\s+id:\s*(.+)$/m)?.[1] || ''),
name: stripYamlValue(block.match(/^\s{4}name:\s*(.+)$/m)?.[1] || ''),
type: label,
required: stripYamlValue(block.match(/^\s{4}required:\s*(.+)$/m)?.[1] || '-'),
signerRoles: block.match(/^\s{4}signer_roles:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [],
signatureTypes: block.match(/^\s{4}signature_types:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [],
privateSealRestricted: block.match(/^\s{4}private_seal_restricted:\s*(.+)$/m)?.[1] === 'true'
}));
}).filter(item => item.id);
return bucket.flatMap((item) => {
if (!item || typeof item !== 'object') {
return [];
}
const node = item as Record<string, unknown>;
const id = String(node.id || '').trim();
if (!id) {
return [];
}
const toStringList = (value: unknown): string[] => (
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(
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,
sourceStatus: RuleYamlPack['sourceStatus']
): RuleYamlPack {