diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index 87a1f7a..630bd57 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -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) { diff --git a/app/api/document-types/document-types.ts b/app/api/document-types/document-types.ts index ad8cc64..2be192a 100644 --- a/app/api/document-types/document-types.ts +++ b/app/api/document-types/document-types.ts @@ -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 = {}; + 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(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(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(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(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 }; + } +} diff --git a/app/routes/document-types._index.tsx b/app/routes/document-types._index.tsx index 7819dc3..715b56d 100644 --- a/app/routes/document-types._index.tsx +++ b/app/routes/document-types._index.tsx @@ -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; + 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(); - const [types, setTypes] = useState(loaderData.types || []); - const entryModules = loaderData.entryModules || []; - const subtypeGroupsByTypeId = loaderData.subtypeGroupsByTypeId || {}; + const [rootCategories, setRootCategories] = useState(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 (
@@ -113,10 +61,10 @@ export default function DocumentTypesIndex() {
- {types.length === 0 ? ( + {rootCategories.length === 0 ? (
-

暂无文档类型

+

暂无一级文档类型

) : ( @@ -124,48 +72,48 @@ export default function DocumentTypesIndex() { - - + + + - {types.map((type) => ( - - + {rootCategories.map((item) => ( + + + diff --git a/app/routes/document-types.new.tsx b/app/routes/document-types.new.tsx index 93c199a..8f6a2bb 100644 --- a/app/routes/document-types.new.tsx +++ b/app/routes/document-types.new.tsx @@ -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(); - 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(null); - const [selectedRuleSetIds, setSelectedRuleSetIds] = useState([]); - const [ruleSetKeyword, setRuleSetKeyword] = useState(""); const [saving, setSaving] = useState(false); const [errors, setErrors] = useState>({}); - 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 = {}; - 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 = {}; + 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() {
- {isEdit ? "文档类型编辑" : "文档类型配置"} + {isEdit ? "一级文档类型编辑" : "一级文档类型配置"}

- {isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"} + {isEdit ? `编辑一级文档类型 — ${editRoot?.code}` : "新建一级文档类型"}

- 为上传入口绑定清晰的文档语义、汇总规则集和处理流向;实际运行时仍以评查点分组页中的“二级分组 → 规则集”绑定为准。 + 这里维护的是文档类型的大类层级,也就是入口模块下的一级分组;具体业务子类型和规则集绑定统一在评查点分组管理里维护。

@@ -206,13 +156,13 @@ export default function DocumentTypeNew() {
配置完成度 - {completionCount}/4 - 基础信息、入口、规则集逐步补齐 + {completionCount}/3 + 基础信息与入口模块逐步补齐
- 已关联规则集 - {selectedRuleSetIds.length} 个 - {selectedRuleSetIds.length > 0 ? "已进入评查链路" : "建议按业务场景精确选择"} + 下属二级分组 + {editRoot?.childGroupCount || 0} 个 + {isEdit ? "运行子类型维护在分组管理页" : "创建后再去拆分子类型"}
@@ -225,25 +175,9 @@ export default function DocumentTypeNew() {
Step 01 -

基础标识

-
-

先定义业务识别码与名称,确保后续抽取、归档、评查引用一致。

-
-
-
- -
- 编码保持稳定 - 编码创建后建议长期复用,避免和展示文案耦合。 -
-
-
- -
- 名称面向使用者 - 名称会直接出现在上传入口与评查流程,尽量让业务人员一眼看懂。 -
+

一级大类标识

+

先定义一级文档类型的编码与名称,例如合同、行政卷宗、后续新增业务大类。

@@ -252,27 +186,32 @@ export default function DocumentTypeNew() { { setCode(e.target.value); setErrors({ ...errors, code: "" }); }} + onChange={(event) => { + setCode(event.target.value); + setErrors((current) => ({ ...current, code: "" })); + }} disabled={isEdit} /> {errors.code && {errors.code}} - {!errors.code && ( - {isEdit ? "编码创建后不可修改" : "建议使用业务域.场景名,便于接口与导航复用"} - )} + {!errors.code && {isEdit ? "一级编码创建后不可修改" : "建议使用 root.xxx 语义,明确它是一级大类"}}
+
{ setName(e.target.value); setErrors({ ...errors, name: "" }); }} + onChange={(event) => { + setName(event.target.value); + setErrors((current) => ({ ...current, name: "" })); + }} /> {errors.name && {errors.name}} - {!errors.name && 名称会直接出现在上传入口、文档详情和评查流程里} + {!errors.name && 名称就是前端入口里看到的一级业务大类}
@@ -280,12 +219,12 @@ export default function DocumentTypeNew() {
编码 名称所属大类运行子类型映射入口模块一级分组二级分组 汇总规则集 状态 操作
{type.code}
{item.code}
- {type.name} - 文档类型 + {item.name} + 一级文档类型
- {getEntryModuleName(type.entryModuleId)} - {getRootGroupSummary(type.id)} + {item.entryModuleName || (item.entryModuleId ? `#${item.entryModuleId}` : "—")} + 入口模块
-
- {getSubtypeGroups(type.id).length > 0 ? ( - getSubtypeGroups(type.id).map((group) => ( - - {group.displayName || group.name} - - )) - ) : ( - 未映射运行子类型 - )} +
+ {item.name} + 文档类型归属的大类
- {type.ruleSetIds?.length || 0} 个规则集 +
+ {item.childGroupCount} 个 + {item.childGroupCount > 0 ? "该大类下的运行子类型数量" : "尚未拆分二级分组"} +
- - {type.isEnabled ? "启用" : "禁用"} + {item.ruleSetCount} 个规则集 + + + {item.isEnabled ? "启用" : "禁用"} @@ -173,17 +121,10 @@ export default function DocumentTypesIndex() { -