refactor: align document type page with root groups

This commit is contained in:
wren
2026-05-06 14:20:53 +08:00
parent 5366270ad6
commit 63bf3f56ce
6 changed files with 334 additions and 467 deletions
+5 -1
View File
@@ -1032,11 +1032,15 @@ function buildFallbackRoutes(roleKey: string): {
};
}
function isLegacyRuleSetsMenu(path: string | undefined): boolean {
return path === '/rules/sets';
}
function normalizeMenuStructure(menuItems: MenuItem[]): MenuItem[] {
const clonedMenuItems = menuItems.map(item => ({
...item,
children: item.children ? normalizeMenuStructure(item.children) : undefined,
}));
})).filter(item => !isLegacyRuleSetsMenu(item.path));
const collectDescendantPaths = (items: MenuItem[] | undefined): string[] => {
if (!items || items.length === 0) {
+150
View File
@@ -16,6 +16,36 @@ export interface DocumentType {
updatedAt?: string;
}
export interface DocumentTypeRoot {
id: number;
name: string;
code: string;
description: string | null;
entryModuleId: number | null;
entryModuleName?: string | null;
isEnabled: boolean;
childGroupCount: number;
ruleSetCount: number;
ruleSetIds: number[];
}
export interface DocumentTypeRootCreateDTO {
code: string;
name: string;
description?: string;
entryModuleId?: number | null;
isEnabled?: boolean;
sortOrder?: number;
}
export interface DocumentTypeRootUpdateDTO {
name?: string;
description?: string;
entryModuleId?: number | null;
isEnabled?: boolean;
sortOrder?: number;
}
export interface DocumentTypeCreateDTO {
code: string;
name: string;
@@ -273,3 +303,123 @@ export async function getRuleSets(
return { error: error instanceof Error ? error.message : "获取规则集失败" };
}
}
export async function getDocumentTypeRoots(
searchParams: { entry_module_id?: number } = {},
token?: string,
): Promise<{ data?: { types: DocumentTypeRoot[]; total: number }; error?: string; status?: number }> {
try {
const params: Record<string, number> = {};
if (searchParams.entry_module_id) params.entry_module_id = searchParams.entry_module_id;
const response = await axios.get(`${API_BASE_URL}/api/v3/document-type-roots`, {
params,
headers: authHeaders(token),
});
const items = extractData<any[]>(response) || [];
const types: DocumentTypeRoot[] = items.map((item: any) => ({
id: item.id,
name: item.name,
code: item.code,
description: item.description || null,
entryModuleId: item.entryModuleId || null,
entryModuleName: item.entryModuleName || null,
isEnabled: item.isEnabled !== false,
childGroupCount: Number(item.childGroupCount || 0),
ruleSetCount: Number(item.ruleSetCount || 0),
ruleSetIds: item.ruleSetIds || [],
}));
return { data: { types, total: types.length } };
} catch (error) {
return { error: error instanceof Error ? error.message : "获取一级文档类型失败", status: 500 };
}
}
export async function getDocumentTypeRoot(
id: number,
token?: string,
): Promise<{ data?: DocumentTypeRoot; error?: string; status?: number }> {
try {
const response = await axios.get(`${API_BASE_URL}/api/v3/document-type-roots/${id}`, {
headers: authHeaders(token),
});
const item = extractData<any>(response);
if (!item) return { error: "一级文档类型不存在", status: 404 };
return {
data: {
id: item.id,
name: item.name,
code: item.code,
description: item.description || null,
entryModuleId: item.entryModuleId || null,
entryModuleName: item.entryModuleName || null,
isEnabled: item.isEnabled !== false,
childGroupCount: Number(item.childGroupCount || 0),
ruleSetCount: Number(item.ruleSetCount || 0),
ruleSetIds: item.ruleSetIds || [],
},
};
} catch (error) {
return { error: error instanceof Error ? error.message : "获取一级文档类型失败", status: 500 };
}
}
export async function createDocumentTypeRoot(
dto: DocumentTypeRootCreateDTO,
token?: string,
): Promise<{ data?: DocumentTypeRoot; error?: string; status?: number }> {
try {
const response = await axios.post(`${API_BASE_URL}/api/v3/document-type-roots`, dto, {
headers: { ...authHeaders(token), "Content-Type": "application/json" },
});
const item = extractData<any>(response);
if (!item) return { error: "创建失败", status: 500 };
return {
data: {
id: item.id,
name: item.name,
code: item.code,
description: item.description || null,
entryModuleId: item.entryModuleId || null,
entryModuleName: item.entryModuleName || null,
isEnabled: item.isEnabled !== false,
childGroupCount: Number(item.childGroupCount || 0),
ruleSetCount: Number(item.ruleSetCount || 0),
ruleSetIds: item.ruleSetIds || [],
},
};
} catch (error) {
const msg = (error as any)?.response?.data?.message || (error instanceof Error ? error.message : "创建失败");
return { error: msg, status: (error as any)?.response?.status || 500 };
}
}
export async function updateDocumentTypeRoot(
id: number,
dto: DocumentTypeRootUpdateDTO,
token?: string,
): Promise<{ data?: DocumentTypeRoot; error?: string; status?: number }> {
try {
const response = await axios.put(`${API_BASE_URL}/api/v3/document-type-roots/${id}`, dto, {
headers: { ...authHeaders(token), "Content-Type": "application/json" },
});
const item = extractData<any>(response);
if (!item) return { error: "更新失败", status: 500 };
return {
data: {
id: item.id,
name: item.name,
code: item.code,
description: item.description || null,
entryModuleId: item.entryModuleId || null,
entryModuleName: item.entryModuleName || null,
isEnabled: item.isEnabled !== false,
childGroupCount: Number(item.childGroupCount || 0),
ruleSetCount: Number(item.ruleSetCount || 0),
ruleSetIds: item.ruleSetIds || [],
},
};
} catch (error) {
const msg = (error as any)?.response?.data?.message || (error instanceof Error ? error.message : "更新失败");
return { error: msg, status: (error as any)?.response?.status || 500 };
}
}
+35 -94
View File
@@ -3,15 +3,7 @@ import { useNavigate, useLoaderData } from "@remix-run/react";
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { toastService } from "~/components/ui/Toast";
import {
getDocumentTypes,
deleteDocumentType,
getEntryModules,
type DocumentType,
type EntryModuleOption,
} from "~/api/document-types/document-types";
import { getDocumentSubtypeGroupsMap, type DocumentSubtypeGroup } from "~/api/files/files-upload";
import { getDocumentTypeRoots, type DocumentTypeRoot } from "~/api/document-types/document-types";
import documentTypesStyles from "~/styles/pages/document-types_index.css?url";
export function links() {
@@ -21,14 +13,12 @@ export function links() {
export const meta: MetaFunction = () => {
return [
{ title: "文档类型管理 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "管理文档类型及其汇总规则集绑定" },
{ name: "description", content: "按一级大类管理文档类型入口归属" },
];
};
interface LoaderData {
types: DocumentType[];
entryModules: EntryModuleOption[];
subtypeGroupsByTypeId: Record<number, DocumentSubtypeGroup[]>;
rootCategories: DocumentTypeRoot[];
frontendJWT?: string | null;
error?: string;
}
@@ -37,68 +27,26 @@ export async function loader({ request }: LoaderFunctionArgs) {
try {
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const [typesRes, modulesRes] = await Promise.all([
getDocumentTypes({}, frontendJWT),
getEntryModules(frontendJWT),
]);
const types = typesRes.data?.types || [];
const subtypeGroupsRes = await getDocumentSubtypeGroupsMap(
types.map((type) => type.id),
frontendJWT,
);
const rootsRes = await getDocumentTypeRoots({}, frontendJWT);
return {
types,
entryModules: modulesRes.data || [],
subtypeGroupsByTypeId: "data" in subtypeGroupsRes && subtypeGroupsRes.data ? subtypeGroupsRes.data : {},
rootCategories: rootsRes.data?.types || [],
frontendJWT,
};
} catch (error) {
return { types: [], entryModules: [], subtypeGroupsByTypeId: {}, error: "加载失败" };
console.error("加载一级文档类型失败:", error);
return { rootCategories: [], entryModules: [], error: "加载失败" };
}
}
export default function DocumentTypesIndex() {
const navigate = useNavigate();
const loaderData = useLoaderData<LoaderData>();
const [types, setTypes] = useState<DocumentType[]>(loaderData.types || []);
const entryModules = loaderData.entryModules || [];
const subtypeGroupsByTypeId = loaderData.subtypeGroupsByTypeId || {};
const [rootCategories, setRootCategories] = useState<DocumentTypeRoot[]>(loaderData.rootCategories || []);
useEffect(() => {
setTypes(loaderData.types || []);
}, [loaderData.types]);
const getEntryModuleName = (id: number | null) => {
if (!id) return "—";
return entryModules.find((m) => m.id === id)?.name || `#${id}`;
};
const getSubtypeGroups = (typeId: number) => subtypeGroupsByTypeId[typeId] || [];
const getRootGroupSummary = (typeId: number) => {
const groups = getSubtypeGroups(typeId);
const rootNames = Array.from(new Set(groups.map((group) => group.rootGroupName).filter(Boolean)));
if (rootNames.length > 0) {
return rootNames.join("、");
}
return "未映射一级分组";
};
const handleDelete = async (type: DocumentType) => {
const confirmed = window.confirm(`确定要删除文档类型「${type.name}」吗?`);
if (!confirmed) return;
const result = await deleteDocumentType(type.id, loaderData.frontendJWT ?? undefined);
if (result.success) {
toastService.success("文档类型已删除");
setTypes((prev) => prev.filter((t) => t.id !== type.id));
} else {
toastService.error(result.error || "删除失败");
}
};
setRootCategories(loaderData.rootCategories || []);
}, [loaderData.rootCategories]);
return (
<div className="document-types-page">
@@ -113,10 +61,10 @@ export default function DocumentTypesIndex() {
</div>
<Card>
{types.length === 0 ? (
{rootCategories.length === 0 ? (
<div className="empty-state">
<i className="ri-inbox-line"></i>
<p></p>
<p></p>
</div>
) : (
<table className="data-table">
@@ -124,48 +72,48 @@ export default function DocumentTypesIndex() {
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{types.map((type) => (
<tr key={type.id}>
<td><code>{type.code}</code></td>
{rootCategories.map((item) => (
<tr key={item.id}>
<td><code>{item.code}</code></td>
<td>
<div className="type-cell">
<strong>{type.name}</strong>
<span></span>
<strong>{item.name}</strong>
<span></span>
</div>
</td>
<td>
<div className="type-cell">
<strong>{getEntryModuleName(type.entryModuleId)}</strong>
<span>{getRootGroupSummary(type.id)}</span>
<strong>{item.entryModuleName || (item.entryModuleId ? `#${item.entryModuleId}` : "—")}</strong>
<span></span>
</div>
</td>
<td>
<div className="groups-container">
{getSubtypeGroups(type.id).length > 0 ? (
getSubtypeGroups(type.id).map((group) => (
<span key={group.id} className="type-badge" title={group.displayHint || group.code}>
{group.displayName || group.name}
</span>
))
) : (
<span className="subtle-text"></span>
)}
<div className="type-cell">
<strong>{item.name}</strong>
<span></span>
</div>
</td>
<td>
<span className="tag">{type.ruleSetIds?.length || 0} </span>
<div className="type-cell">
<strong>{item.childGroupCount} </strong>
<span>{item.childGroupCount > 0 ? "该大类下的运行子类型数量" : "尚未拆分二级分组"}</span>
</div>
</td>
<td>
<span className={`status-badge ${type.isEnabled ? "enabled" : "disabled"}`}>
{type.isEnabled ? "启用" : "禁用"}
<span className="tag">{item.ruleSetCount} </span>
</td>
<td>
<span className={`status-badge ${item.isEnabled ? "enabled" : "disabled"}`}>
{item.isEnabled ? "启用" : "禁用"}
</span>
</td>
<td>
@@ -173,17 +121,10 @@ export default function DocumentTypesIndex() {
<button
className="btn-icon"
title="编辑"
onClick={() => navigate(`/document-types/new?id=${type.id}`)}
onClick={() => navigate(`/document-types/new?id=${item.id}`)}
>
<i className="ri-edit-line"></i>
</button>
<button
className="btn-icon text-error"
title="删除"
onClick={() => handleDelete(type)}
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
</td>
</tr>
+144 -339
View File
@@ -1,22 +1,19 @@
import { useState, useEffect } from "react";
import { useNavigate, useLoaderData } from "@remix-run/react";
import { useEffect, useMemo, useState } from "react";
import { useLoaderData, useNavigate } from "@remix-run/react";
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { toastService } from "~/components/ui/Toast";
import {
getDocumentType,
createDocumentType,
updateDocumentType,
createDocumentTypeRoot,
getDocumentTypeRoot,
getEntryModules,
getRuleSets,
type DocumentType,
type DocumentTypeCreateDTO,
type DocumentTypeUpdateDTO,
updateDocumentTypeRoot,
type DocumentTypeRoot,
type EntryModuleOption,
type RuleSetOption,
} from "~/api/document-types/document-types";
import { getDocumentSubtypeGroupsMap, type DocumentSubtypeGroup } from "~/api/files/files-upload";
import newStyles from "~/styles/pages/document-types_new.css?url";
export function links() {
@@ -30,8 +27,7 @@ export const meta: MetaFunction = () => {
interface LoaderData {
entryModules: EntryModuleOption[];
ruleSets: RuleSetOption[];
editType: DocumentType | null;
runtimeSubtypeGroups: DocumentSubtypeGroup[];
editRoot: DocumentTypeRoot | null;
frontendJWT?: string | null;
}
@@ -41,132 +37,86 @@ export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const editId = url.searchParams.get("id");
const [modulesRes, setsRes] = await Promise.all([
const [modulesRes, ruleSetsRes] = await Promise.all([
getEntryModules(frontendJWT),
getRuleSets(frontendJWT),
]);
const editRoot = editId ? (await getDocumentTypeRoot(Number(editId), frontendJWT)).data || null : null;
let editType: DocumentType | null = null;
let runtimeSubtypeGroups: DocumentSubtypeGroup[] = [];
if (editId) {
const res = await getDocumentType(parseInt(editId), frontendJWT);
editType = res.data || null;
if (editType?.id) {
const groupsRes = await getDocumentSubtypeGroupsMap([editType.id], frontendJWT);
if ("data" in groupsRes && groupsRes.data) {
runtimeSubtypeGroups = groupsRes.data[editType.id] || [];
}
}
}
return { entryModules: modulesRes.data || [], ruleSets: setsRes.data || [], editType, runtimeSubtypeGroups, frontendJWT };
return {
entryModules: modulesRes.data || [],
ruleSets: ruleSetsRes.data || [],
editRoot,
frontendJWT,
};
}
export default function DocumentTypeNew() {
export default function DocumentTypeRootEditor() {
const navigate = useNavigate();
const loaderData = useLoaderData<LoaderData>();
const editType = loaderData.editType;
const isEdit = !!editType;
const editRoot = loaderData.editRoot;
const isEdit = !!editRoot;
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [entryModuleId, setEntryModuleId] = useState<number | null>(null);
const [selectedRuleSetIds, setSelectedRuleSetIds] = useState<number[]>([]);
const [ruleSetKeyword, setRuleSetKeyword] = useState("");
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const selectedModule = loaderData.entryModules.find((m) => m.id === entryModuleId);
const runtimeSubtypeGroups = loaderData.runtimeSubtypeGroups || [];
const runtimeRootGroups = Array.from(
new Map(
runtimeSubtypeGroups
.filter((group) => group.rootGroupId || group.rootGroupName)
.map((group) => [group.rootGroupId || group.rootGroupName || group.id, {
id: group.rootGroupId || null,
name: group.rootGroupName || "未归属一级分组",
}]),
).values(),
);
const ruleSetsReadonly = isEdit && runtimeSubtypeGroups.length > 0;
const selectedRuleSets = loaderData.ruleSets.filter((rs) => selectedRuleSetIds.includes(rs.id));
const selectedUnavailableRuleSets = selectedRuleSets.filter((rs) => !rs.hasUsableVersion);
const normalizedRuleSetKeyword = ruleSetKeyword.trim().toLowerCase();
const filteredRuleSets = loaderData.ruleSets.filter((rs) => {
if (!normalizedRuleSetKeyword) return true;
return [
rs.ruleName,
rs.ruleType,
rs.status,
String(rs.id),
String(rs.usableRuleCount || 0),
rs.currentVersionId ? String(rs.currentVersionId) : "",
rs.fallbackVersionId ? String(rs.fallbackVersionId) : "",
].some((value) => value.toLowerCase().includes(normalizedRuleSetKeyword));
});
const completionCount = [!!code.trim(), !!name.trim(), entryModuleId !== null, selectedRuleSetIds.length > 0]
.filter(Boolean).length;
useEffect(() => {
if (editType) {
setCode(editType.code || "");
setName(editType.name || "");
setDescription(editType.description || "");
setEntryModuleId(editType.entryModuleId);
setSelectedRuleSetIds(editType.ruleSetIds || []);
if (editRoot) {
setCode(editRoot.code || "");
setName(editRoot.name || "");
setDescription(editRoot.description || "");
setEntryModuleId(editRoot.entryModuleId);
}
}, [editType]);
}, [editRoot]);
const validate = (): boolean => {
const errs: Record<string, string> = {};
if (!code.trim()) errs.code = "编码不能为空";
else if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(code.trim())) errs.code = "编码格式:字母开头,可含字母数字._";
if (!name.trim()) errs.name = "名称不能为空";
if (!ruleSetsReadonly && selectedUnavailableRuleSets.length > 0) {
errs.ruleSetIds = "已选择的规则集中包含不可用于上传评查的项,请先确认可用规则数是否正常";
}
setErrors(errs);
return Object.keys(errs).length === 0;
const selectedModule = loaderData.entryModules.find((module) => module.id === entryModuleId);
const selectedRuleSets = useMemo(
() => loaderData.ruleSets.filter((ruleSet) => (editRoot?.ruleSetIds || []).includes(ruleSet.id)),
[editRoot, loaderData.ruleSets],
);
const completionCount = [!!code.trim(), !!name.trim(), entryModuleId !== null].filter(Boolean).length;
const validate = () => {
const nextErrors: Record<string, string> = {};
if (!code.trim()) nextErrors.code = "编码不能为空";
else if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(code.trim())) nextErrors.code = "编码格式:字母开头,可含字母数字._";
if (!name.trim()) nextErrors.name = "名称不能为空";
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const toggleRuleSet = (id: number) => {
setSelectedRuleSetIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!validate()) return;
const payload = {
code: code.trim(),
name: name.trim(),
description: description.trim() || null,
entryModuleId,
sortOrder: 0,
isEnabled: true,
};
setSaving(true);
try {
if (isEdit && editType) {
const dto: DocumentTypeUpdateDTO = {
name: name.trim(),
description: description.trim(),
entryModuleId,
...(ruleSetsReadonly ? {} : { ruleSetIds: selectedRuleSetIds }),
};
const res = await updateDocumentType(editType.id, dto, loaderData.frontendJWT ?? undefined);
if (res.error) { toastService.error(res.error); return; }
toastService.success("文档类型已更新");
if (isEdit && editRoot) {
const result = await updateDocumentTypeRoot(editRoot.id, payload, loaderData.frontendJWT);
if (result.error) throw new Error(result.error);
toastService.success("一级文档类型已更新");
} else {
const dto: DocumentTypeCreateDTO = {
code: code.trim(),
name: name.trim(),
description: description.trim(),
entryModuleId,
ruleSetIds: selectedRuleSetIds,
};
const res = await createDocumentType(dto, loaderData.frontendJWT ?? undefined);
if (res.error) { toastService.error(res.error); return; }
toastService.success("文档类型已创建");
const result = await createDocumentTypeRoot(payload, loaderData.frontendJWT);
if (result.error) throw new Error(result.error);
toastService.success("一级文档类型已创建");
}
navigate("/document-types");
} catch (error) {
toastService.error(error instanceof Error ? error.message : "保存失败");
} catch (error: any) {
const message = error?.response?.data?.message || error?.response?.data?.msg || error?.message || "保存失败";
toastService.error(message);
} finally {
setSaving(false);
}
@@ -176,13 +126,13 @@ export default function DocumentTypeNew() {
<div className="document-type-new-page">
<div className="page-header">
<div className="page-heading">
<span className="page-kicker">{isEdit ? "文档类型编辑" : "文档类型配置"}</span>
<span className="page-kicker">{isEdit ? "一级文档类型编辑" : "一级文档类型配置"}</span>
<h2 className="page-title">
<i className="ri-file-list-3-line"></i>
{isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"}
{isEdit ? `编辑一级文档类型 — ${editRoot?.code}` : "新建一级文档类型"}
</h2>
<p className="page-subtitle">
</p>
</div>
<div className="header-overview">
@@ -206,13 +156,13 @@ export default function DocumentTypeNew() {
<div className="hero-metrics">
<div className="hero-metric-card">
<span className="hero-metric-label"></span>
<strong>{completionCount}/4</strong>
<small></small>
<strong>{completionCount}/3</strong>
<small></small>
</div>
<div className="hero-metric-card">
<span className="hero-metric-label"></span>
<strong>{selectedRuleSetIds.length} </strong>
<small>{selectedRuleSetIds.length > 0 ? "已进入评查链路" : "建议按业务场景精确选择"}</small>
<span className="hero-metric-label"></span>
<strong>{editRoot?.childGroupCount || 0} </strong>
<small>{isEdit ? "运行子类型维护在分组管理页" : "创建后再去拆分子类型"}</small>
</div>
</div>
</div>
@@ -225,25 +175,9 @@ export default function DocumentTypeNew() {
<div className="section-heading">
<div>
<span className="section-kicker">Step 01</span>
<h3></h3>
</div>
<p></p>
</div>
<div className="section-intro-card">
<div className="section-intro-item">
<i className="ri-fingerprint-line"></i>
<div>
<strong></strong>
<span></span>
</div>
</div>
<div className="section-intro-item">
<i className="ri-text"></i>
<div>
<strong>使</strong>
<span></span>
</div>
<h3></h3>
</div>
<p></p>
</div>
<div className="form-row two-column">
@@ -252,27 +186,32 @@ export default function DocumentTypeNew() {
<input
type="text"
className={`form-input ${errors.code ? "error" : ""}`}
placeholder="如: contract.sale"
placeholder="如: root.contract"
value={code}
onChange={(e) => { setCode(e.target.value); setErrors({ ...errors, code: "" }); }}
onChange={(event) => {
setCode(event.target.value);
setErrors((current) => ({ ...current, code: "" }));
}}
disabled={isEdit}
/>
{errors.code && <span className="form-error">{errors.code}</span>}
{!errors.code && (
<span className="form-hint">{isEdit ? "编码创建后不可修改" : "建议使用业务域.场景名,便于接口与导航复用"}</span>
)}
{!errors.code && <span className="form-hint">{isEdit ? "一级编码创建后不可修改" : "建议使用 root.xxx 语义,明确它是一级大类"}</span>}
</div>
<div className="form-group">
<label className="required"></label>
<input
type="text"
className={`form-input ${errors.name ? "error" : ""}`}
placeholder="如: 通用买卖合同"
placeholder="如: 合同"
value={name}
onChange={(e) => { setName(e.target.value); setErrors({ ...errors, name: "" }); }}
onChange={(event) => {
setName(event.target.value);
setErrors((current) => ({ ...current, name: "" }));
}}
/>
{errors.name && <span className="form-error">{errors.name}</span>}
{!errors.name && <span className="form-hint"></span>}
{!errors.name && <span className="form-hint"></span>}
</div>
</div>
@@ -280,12 +219,12 @@ export default function DocumentTypeNew() {
<label></label>
<textarea
className="form-textarea"
placeholder="补充这个文档类型的适用业务场景、上传说明、抽取注意事项"
placeholder="补充这个一级文档类型覆盖的业务范围"
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={(event) => setDescription(event.target.value)}
rows={4}
/>
<span className="form-hint">便</span>
<span className="form-hint"></span>
</div>
</section>
@@ -293,9 +232,9 @@ export default function DocumentTypeNew() {
<div className="section-heading">
<div>
<span className="section-kicker">Step 02</span>
<h3></h3>
<h3></h3>
</div>
<p></p>
<p></p>
</div>
<div className="form-group">
@@ -303,206 +242,90 @@ export default function DocumentTypeNew() {
<select
className="form-select"
value={entryModuleId ?? ""}
onChange={(e) => setEntryModuleId(e.target.value ? parseInt(e.target.value) : null)}
onChange={(event) => setEntryModuleId(event.target.value ? Number(event.target.value) : null)}
>
<option value=""></option>
{loaderData.entryModules.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
{loaderData.entryModules.map((module) => (
<option key={module.id} value={module.id}>{module.name}</option>
))}
</select>
<span className="form-hint"></span>
<span className="form-hint"></span>
</div>
<div className="binding-preview">
<div className="binding-preview-label"></div>
<div className="binding-preview-label"></div>
<div className="binding-preview-value">
<i className="ri-route-line"></i>
<span>{selectedModule?.name || "未绑定入口模块,上传入口不会主动露出此类型"}</span>
<span>{selectedModule?.name || "未绑定入口模块"}</span>
</div>
</div>
<div className="binding-preview">
<div className="binding-preview-label"></div>
<div className="binding-preview-value">
<i className="ri-stack-line"></i>
<span>{name.trim() || "待填写一级文档类型名称"}</span>
</div>
</div>
<div className="rule-set-warning">
<i className="ri-git-merge-line"></i>
<i className="ri-information-line"></i>
<div>
<strong></strong>
<span>
/
-
</span>
<strong></strong>
<span>--</span>
</div>
</div>
{isEdit ? (
<div className="rule-set-warning">
<i className="ri-node-tree"></i>
<div>
<strong></strong>
<span>
{runtimeSubtypeGroups.length > 0
? `当前文档类型在评查点分组中已映射到 ${runtimeSubtypeGroups.length} 个运行子类型;上传时会先命中所属大类,再按所选子类型决定实际规则集。`
: "当前文档类型还没有在评查点分组中挂到任何二级分组,上传时无法形成完整的新链路。"}
</span>
</div>
</div>
) : null}
{isEdit && runtimeSubtypeGroups.length > 0 ? (
<div className="selected-rule-sets-panel">
<div className="selected-panel-header">
<div>
<strong></strong>
<span> / </span>
</div>
</div>
<div className="rule-set-warning">
<i className="ri-stack-line"></i>
<div>
<strong></strong>
<span>
{runtimeRootGroups.map((group) => group.name).join("、")}
{selectedModule?.name ? ` · 所属入口模块:${selectedModule.name}` : ""}
</span>
</div>
</div>
<div className="selected-rule-grid">
{runtimeSubtypeGroups.map((group) => (
<div key={group.id} className="selected-rule-card">
<div className="selected-rule-main">
<strong>{group.displayName || group.name}</strong>
<span>
{group.rootGroupName || "未归属一级分组"}
{group.entryModuleName ? ` · 入口模块:${group.entryModuleName}` : ""}
</span>
</div>
<span className="selected-rule-meta"> · {group.code}</span>
</div>
))}
</div>
</div>
) : null}
</section>
<section className="form-section">
<div className="section-heading">
<div>
<span className="section-kicker">Step 03</span>
<h3></h3>
<h3></h3>
</div>
<p></p>
<p></p>
</div>
<div className="rule-set-warning">
<i className={ruleSetsReadonly ? "ri-lock-line" : "ri-information-line"}></i>
<div className="readonly-guide-card">
<div>
<strong>{ruleSetsReadonly ? "当前已收口为只读汇总" : "这是汇总视图,不是最终运行绑定"}</strong>
<span>
{ruleSetsReadonly
? "当前文档类型已经接入评查点分组新链路。这里仅展示汇总结果,不再作为最终运行绑定编辑入口;请到“评查点分组管理”维护二级分组下的实际规则集。"
: "当一个文档类型下存在多个二级分组时,这里看到的是所有二级分组规则集的并集汇总;具体到某次上传,只会命中所选子类型下的规则集。"}
</span>
<strong></strong>
<span>/</span>
</div>
<Button type="default" onClick={() => navigate("/rule-groups")} disabled={saving}>
</Button>
</div>
<div className="rule-set-toolbar">
<div className="rule-set-toolbar-main">
<span className="rule-set-counter">
<i className="ri-checkbox-circle-line"></i>
{selectedRuleSetIds.length} / {loaderData.ruleSets.length}
</span>
<span className="form-hint">
{ruleSetsReadonly
? "当前只读展示的是文档类型层汇总结果;上传时将优先按入口模块下命中的二级分组执行评查。"
: "这里用于维护文档类型层的汇总结果;上传时若已拆分子类型,后端会优先按二级分组绑定执行评查。"}
</span>
</div>
<div className="rule-set-search">
<i className="ri-search-line"></i>
<input
type="text"
value={ruleSetKeyword}
onChange={(e) => setRuleSetKeyword(e.target.value)}
placeholder="搜索规则集名称、类型、ID、版本"
/>
</div>
</div>
{ruleSetsReadonly ? (
<div className="readonly-guide-card">
<div>
<strong></strong>
<span> / </span>
</div>
<Button type="default" onClick={() => navigate("/rule-groups")} disabled={saving}>
</Button>
</div>
) : null}
{selectedUnavailableRuleSets.length > 0 && (
<div className="rule-set-warning">
<i className="ri-error-warning-line"></i>
<div>
<strong></strong>
<span>
{selectedUnavailableRuleSets.map((item) => item.ruleName).join("、")}
{" "}
</span>
</div>
</div>
)}
<div className="rule-set-checklist">
{loaderData.ruleSets.length === 0 ? (
{!isEdit ? (
<div className="empty-rule-set">
<i className="ri-node-tree"></i>
<p></p>
<span></span>
</div>
) : selectedRuleSets.length === 0 ? (
<div className="empty-rule-set">
<i className="ri-inbox-archive-line"></i>
<p></p>
<span></span>
</div>
) : filteredRuleSets.length === 0 ? (
<div className="empty-rule-set compact">
<i className="ri-search-eye-line"></i>
<p></p>
<span></span>
<p></p>
<span></span>
</div>
) : (
filteredRuleSets.map((rs) => (
<label
key={rs.id}
className={`rule-set-item ${selectedRuleSetIds.includes(rs.id) ? "checked" : ""} ${rs.hasUsableVersion ? "" : "unavailable"}`}
>
<div className="rule-set-checkbox-wrap">
<input
type="checkbox"
checked={selectedRuleSetIds.includes(rs.id)}
onChange={() => toggleRuleSet(rs.id)}
disabled={ruleSetsReadonly}
/>
</div>
selectedRuleSets.map((ruleSet) => (
<div key={ruleSet.id} className="rule-set-item checked">
<div className="rule-set-content">
<div className="rule-set-topline">
<span className="rule-set-name">{rs.ruleName}</span>
<span className={`rule-set-status ${rs.status}`}>{rs.status}</span>
<span className="rule-set-name">{ruleSet.ruleName}</span>
<span className={`rule-set-status ${ruleSet.status}`}>{ruleSet.status}</span>
</div>
<div className="rule-set-meta">
<span className="rule-set-type">{rs.ruleType}</span>
<span className="rule-set-id"> ID #{rs.id}</span>
<span className={`rule-set-version-badge ${rs.hasUsableVersion ? "ok" : "missing"}`}>
{rs.hasUsableVersion
? `可用规则数:${rs.usableRuleCount || 0}`
: "可用规则数:0"}
<span className="rule-set-type">{ruleSet.ruleType}</span>
<span className="rule-set-id"> ID #{ruleSet.id}</span>
<span className={`rule-set-version-badge ${ruleSet.hasUsableVersion ? "ok" : "missing"}`}>
{ruleSet.hasUsableVersion ? `可用规则数:${ruleSet.usableRuleCount || 0}` : "可用规则数:0"}
</span>
</div>
{!rs.hasUsableVersion && (
<div className="rule-set-inline-warning">
<i className="ri-alarm-warning-line"></i>
<span></span>
</div>
)}
{rs.hasUsableVersion && !rs.currentVersionId && rs.fallbackVersionId && (
<div className="rule-set-inline-warning soft">
<i className="ri-information-line"></i>
<span>退</span>
</div>
)}
</div>
</label>
</div>
))
)}
</div>
@@ -528,7 +351,7 @@ export default function DocumentTypeNew() {
<div className="summary-grid">
<div className="summary-item">
<span className="summary-label"></span>
<strong>{isEdit ? "编辑既有类型" : "新建类"}</strong>
<strong>{isEdit ? "编辑一级大类" : "新建一级大类"}</strong>
</div>
<div className="summary-item">
<span className="summary-label"></span>
@@ -543,17 +366,17 @@ export default function DocumentTypeNew() {
<strong>{selectedModule?.name || "未绑定"}</strong>
</div>
<div className="summary-item full">
<span className="summary-label"></span>
<strong>{selectedRuleSetIds.length} </strong>
<span className="summary-label"></span>
<strong>{editRoot?.childGroupCount || 0} </strong>
</div>
</div>
<div className="summary-progress">
<div className="summary-progress-topline">
<span></span>
<strong>{completionCount * 25}%</strong>
<strong>{Math.round((completionCount / 3) * 100)}%</strong>
</div>
<div className="summary-progress-bar">
<span style={{ width: `${completionCount * 25}%` }}></span>
<span style={{ width: `${(completionCount / 3) * 100}%` }}></span>
</div>
</div>
</Card>
@@ -564,36 +387,18 @@ export default function DocumentTypeNew() {
<h3></h3>
</div>
<div className="selection-stack">
<div className="selection-item">
<span className="selection-label"></span>
<strong>{name.trim() || "暂未指定"}</strong>
</div>
<div className="selection-item">
<span className="selection-label"></span>
<strong>{selectedModule?.name || "暂未指定"}</strong>
</div>
<div className="selection-item">
<span className="selection-label"></span>
{selectedRuleSets.length > 0 ? (
<div className="selection-tags">
{selectedRuleSets.slice(0, 4).map((ruleSet) => (
<span
key={ruleSet.id}
className={`selection-tag ${ruleSet.hasUsableVersion ? "" : "warning"}`}
>
{ruleSet.ruleName}
</span>
))}
{selectedRuleSets.length > 4 && (
<span className="selection-tag muted">+{selectedRuleSets.length - 4}</span>
)}
</div>
) : (
<strong></strong>
)}
<span className="selection-label"></span>
<strong>{isEdit ? `${selectedRuleSets.length} 个汇总规则集` : "创建后由二级分组汇总"}</strong>
</div>
{selectedUnavailableRuleSets.length > 0 && (
<div className="selection-item danger">
<span className="selection-label"></span>
<strong></strong>
</div>
)}
</div>
</Card>
@@ -603,9 +408,9 @@ export default function DocumentTypeNew() {
<h3></h3>
</div>
<ul className="guide-list">
<li>便</li>
<li></li>
<li>{ruleSetsReadonly ? "当前类型的实际运行规则请统一在评查点分组页维护,避免这里和分组页双写冲突。" : "规则集宁可精简,也不要把无关规则打包给所有文档类型,避免误报过多。"}</li>
<li></li>
<li></li>
<li></li>
</ul>
</Card>
</aside>
-25
View File
@@ -1,25 +0,0 @@
import { redirect, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{ title: "规则管理 - 中国烟草AI合同及卷宗审核系统" },
{
name: "rules-sets",
content: "兼容旧版规则管理入口,自动跳转到新版规则维护页"
}
];
};
export const handle = {
hideBreadcrumb: true,
};
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const query = url.searchParams.toString();
throw redirect(query ? `/rulesTest/list?${query}` : "/rulesTest/list");
}
export default function RulesSetsRedirect() {
return null;
}
-8
View File
@@ -127,14 +127,6 @@ export const permissionRouteAliasGroups = [
{ input: '/rules/new', output: '/rules' },
],
},
{
source: '^/rules/sets(?=/|$)',
target: '/rules',
note: '旧版规则管理入口复用规则管理主菜单权限,并跳转到新版规则维护页。',
examples: [
{ input: '/rules/sets', output: '/rules' },
],
},
{
source: '^/documents/list(?=/|$)',
target: '/documents',