feat: stabilize document type and upload flows
This commit is contained in:
@@ -40,6 +40,9 @@ export interface RuleSetOption {
|
|||||||
ruleType: string;
|
ruleType: string;
|
||||||
ruleName: string;
|
ruleName: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
currentVersionId?: number | null;
|
||||||
|
fallbackVersionId?: number | null;
|
||||||
|
hasUsableVersion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntryModuleOption {
|
export interface EntryModuleOption {
|
||||||
@@ -224,11 +227,19 @@ export async function getEntryModules(
|
|||||||
): Promise<{ data?: EntryModuleOption[]; error?: string }> {
|
): Promise<{ data?: EntryModuleOption[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_BASE_URL}/api/v3/entry-modules`, {
|
const response = await axios.get(`${API_BASE_URL}/api/v3/entry-modules`, {
|
||||||
|
params: { page: 1, page_size: 200 },
|
||||||
headers: authHeaders(token),
|
headers: authHeaders(token),
|
||||||
});
|
});
|
||||||
const items = extractData<any[]>(response) || [];
|
const payload = extractData<any>(response);
|
||||||
|
const items = Array.isArray(payload)
|
||||||
|
? payload
|
||||||
|
: Array.isArray(payload?.items)
|
||||||
|
? payload.items
|
||||||
|
: [];
|
||||||
|
|
||||||
return { data: items.map((m: any) => ({ id: m.id, name: m.name })) };
|
return { data: items.map((m: any) => ({ id: m.id, name: m.name })) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("获取入口模块失败:", error);
|
||||||
return { error: error instanceof Error ? error.message : "获取入口模块失败" };
|
return { error: error instanceof Error ? error.message : "获取入口模块失败" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +261,9 @@ export async function getRuleSets(
|
|||||||
ruleType: r.ruleType,
|
ruleType: r.ruleType,
|
||||||
ruleName: r.ruleName,
|
ruleName: r.ruleName,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
|
currentVersionId: r.currentVersionId ?? null,
|
||||||
|
fallbackVersionId: r.fallbackVersionId ?? null,
|
||||||
|
hasUsableVersion: !!r.hasUsableVersion,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+201
-127
@@ -1,4 +1,5 @@
|
|||||||
import { postgrestGet, postgrestDelete, postgrestPut, postgrestPost } from '../postgrest-client';
|
import axios from 'axios';
|
||||||
|
import { postgrestGet, postgrestDelete } from '../postgrest-client';
|
||||||
import { getDocumentTypes } from '../document-types/document-types';
|
import { getDocumentTypes } from '../document-types/document-types';
|
||||||
import { formatDate } from '../../utils';
|
import { formatDate } from '../../utils';
|
||||||
import { API_BASE_URL } from '~/config/api-config';
|
import { API_BASE_URL } from '~/config/api-config';
|
||||||
@@ -181,6 +182,21 @@ interface LeauditListPage {
|
|||||||
documents: LeauditListItem[];
|
documents: LeauditListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LeauditDocumentDetail extends LeauditListItem {
|
||||||
|
documentNumber?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
isTestDocument?: boolean | null;
|
||||||
|
auditStatus?: number | null;
|
||||||
|
pageCount?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentMetadataUpdateDTO {
|
||||||
|
documentNumber?: string;
|
||||||
|
auditStatus?: number;
|
||||||
|
isTestDocument?: boolean;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件扩展名
|
* 获取文件扩展名
|
||||||
* @param filename 文件名
|
* @param filename 文件名
|
||||||
@@ -246,6 +262,92 @@ function buildDocumentNumber(doc: LeauditListItem | LeauditHistoryVersion): stri
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUnsupportedNewDocumentCrud(error: unknown): boolean {
|
||||||
|
return axios.isAxiosError(error) && [404, 405, 501].includes(error.response?.status || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
return error.response?.data?.message || error.response?.data?.detail || error.message || fallback;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHistoryVersionToUI(history: LeauditHistoryVersion, source: LeauditListItem): DocumentVersionUI {
|
||||||
|
return {
|
||||||
|
id: history.documentId,
|
||||||
|
name: history.fileName || source.fileName || source.normalizedName || '未命名文档',
|
||||||
|
documentNumber: buildDocumentNumber(history),
|
||||||
|
type: source.typeId?.toString() || '',
|
||||||
|
typeName: typeNameFromCode(source.typeCode),
|
||||||
|
size: 0,
|
||||||
|
auditStatus: mapLeauditDocToAuditStatus({
|
||||||
|
processingStatus: history.processingStatus,
|
||||||
|
runStatus: history.runStatus,
|
||||||
|
passedCount: null,
|
||||||
|
failedCount: null,
|
||||||
|
}),
|
||||||
|
fileStatus: mapProcessingStatusToFileStatus(history.processingStatus),
|
||||||
|
issues: null,
|
||||||
|
uploadTime: formatDate(history.updatedAt || ''),
|
||||||
|
fileType: history.fileExt || getFileExtension(history.fileName || source.fileName || ''),
|
||||||
|
path: '',
|
||||||
|
isTest: false,
|
||||||
|
updatedAt: formatDate(history.updatedAt || ''),
|
||||||
|
pageCount: 0,
|
||||||
|
ocrResult: undefined,
|
||||||
|
versionNumber: history.versionNo,
|
||||||
|
pass_count: null,
|
||||||
|
warning_count: 0,
|
||||||
|
error_count: null,
|
||||||
|
manual_count: null,
|
||||||
|
previous_pass_count: null,
|
||||||
|
previous_warning_count: null,
|
||||||
|
previous_error_count: null,
|
||||||
|
previous_manual_count: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLeauditDocumentToUI(doc: LeauditListItem | LeauditDocumentDetail): DocumentUI {
|
||||||
|
const historyVersions = (doc.historyVersions || []).map((history) => mapHistoryVersionToUI(history, doc));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.documentId,
|
||||||
|
name: doc.fileName || doc.normalizedName || '未命名文档',
|
||||||
|
documentNumber: ('documentNumber' in doc && doc.documentNumber) ? doc.documentNumber : buildDocumentNumber(doc),
|
||||||
|
type: doc.typeId?.toString() || '',
|
||||||
|
typeName: typeNameFromCode(doc.typeCode),
|
||||||
|
size: doc.fileSize || 0,
|
||||||
|
auditStatus: ('auditStatus' in doc && doc.auditStatus !== null && doc.auditStatus !== undefined)
|
||||||
|
? doc.auditStatus
|
||||||
|
: mapLeauditDocToAuditStatus(doc),
|
||||||
|
fileStatus: mapProcessingStatusToFileStatus(doc.processingStatus),
|
||||||
|
issues: doc.failedCount ?? null,
|
||||||
|
uploadTime: formatDate(doc.updatedAt || ''),
|
||||||
|
fileType: doc.fileExt || getFileExtension(doc.fileName || ''),
|
||||||
|
path: doc.ossUrl || '',
|
||||||
|
isTest: Boolean(('isTestDocument' in doc && doc.isTestDocument) || false),
|
||||||
|
remark: 'remark' in doc ? (doc.remark || '') : '',
|
||||||
|
updatedAt: formatDate(doc.updatedAt || ''),
|
||||||
|
pageCount: ('pageCount' in doc ? (doc.pageCount || 0) : 0),
|
||||||
|
ocrResult: undefined,
|
||||||
|
pass_count: doc.passedCount ?? null,
|
||||||
|
warning_count: 0,
|
||||||
|
error_count: doc.failedCount ?? null,
|
||||||
|
manual_count: doc.skippedCount ?? null,
|
||||||
|
warning_messages: [],
|
||||||
|
error_messages: [],
|
||||||
|
manual_messages: [],
|
||||||
|
historyCount: Math.max(0, (doc.totalVersions || 1) - 1),
|
||||||
|
previousIssues: historyVersions[0]?.issues ?? null,
|
||||||
|
previous_pass_count: historyVersions[0]?.pass_count ?? null,
|
||||||
|
previous_warning_count: historyVersions[0]?.warning_count ?? null,
|
||||||
|
previous_error_count: historyVersions[0]?.error_count ?? null,
|
||||||
|
previous_manual_count: historyVersions[0]?.manual_count ?? null,
|
||||||
|
historyVersions: historyVersions.length > 0 ? historyVersions : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取评查结果
|
* 获取评查结果
|
||||||
* @param id 评查结果ID
|
* @param id 评查结果ID
|
||||||
@@ -360,16 +462,31 @@ export async function deleteDocument(id: string, userId: string, token?: string)
|
|||||||
return { error: '用户身份验证失败', status: 401 };
|
return { error: '用户身份验证失败', status: 401 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await postgrestDelete(
|
try {
|
||||||
'/api/postgrest/proxy/documents',
|
// 新后端接口应基于 JWT 在服务端做数据隔离:
|
||||||
{
|
// - provincial_admin: 全量
|
||||||
filter: {
|
// - admin: 本地市
|
||||||
'id': `eq.${id}`,
|
// - common: 自己上传的文档
|
||||||
'user_id': `eq.${userId}` // 确保只能删除自己的文档
|
await axios.delete(`${API_BASE_URL}/api/documents/${id}`, {
|
||||||
},
|
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined
|
||||||
token
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnsupportedNewDocumentCrud(error)) {
|
||||||
|
return {
|
||||||
|
error: getErrorMessage(error, '删除文档失败'),
|
||||||
|
status: axios.isAxiosError(error) ? error.response?.status : 500
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const response = await postgrestDelete('/api/postgrest/proxy/documents', {
|
||||||
|
filter: {
|
||||||
|
'id': `eq.${id}`,
|
||||||
|
'user_id': `eq.${userId}` // 旧链路仅允许删除自己的文档
|
||||||
|
},
|
||||||
|
token
|
||||||
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
return { error: response.error, status: response.status };
|
return { error: response.error, status: response.status };
|
||||||
@@ -404,17 +521,34 @@ export async function getDocument(id: string, userId: string, frontendJWT?: stri
|
|||||||
return { error: '用户身份验证失败', status: 401 };
|
return { error: '用户身份验证失败', status: 401 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await postgrestGet<Document[]>(
|
try {
|
||||||
'/api/postgrest/proxy/documents',
|
const response = await axios.get(`${API_BASE_URL}/api/documents/${id}`, {
|
||||||
{
|
headers: frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : undefined
|
||||||
filter: {
|
});
|
||||||
'id': `eq.${id}`,
|
|
||||||
'user_id': `eq.${userId}`
|
const detail = extractApiData<LeauditDocumentDetail>(response.data);
|
||||||
},
|
if (!detail) {
|
||||||
limit: 1,
|
return { error: '文档不存在', status: 404 };
|
||||||
token: frontendJWT
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return { data: mapLeauditDocumentToUI(detail) };
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnsupportedNewDocumentCrud(error)) {
|
||||||
|
return {
|
||||||
|
error: getErrorMessage(error, '获取文档详情失败'),
|
||||||
|
status: axios.isAxiosError(error) ? error.response?.status : 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await postgrestGet<Document[]>('/api/postgrest/proxy/documents', {
|
||||||
|
filter: {
|
||||||
|
'id': `eq.${id}`,
|
||||||
|
'user_id': `eq.${userId}`
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
token: frontendJWT
|
||||||
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
return { error: response.error, status: response.status };
|
return { error: response.error, status: response.status };
|
||||||
@@ -555,54 +689,68 @@ export async function updateDocument(id: string, document: Partial<DocumentUI> &
|
|||||||
return { error: '用户身份验证失败', status: 401 };
|
return { error: '用户身份验证失败', status: 401 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 准备API数据 - 将UI数据转换为API格式
|
const apiDocument: DocumentMetadataUpdateDTO = {};
|
||||||
// 根据文档,可更新字段:document_number, audit_status, is_test_document, remark
|
|
||||||
const apiDocument: Partial<Document> = {};
|
|
||||||
|
|
||||||
if (document.documentNumber !== undefined) {
|
if (document.documentNumber !== undefined) {
|
||||||
apiDocument.document_number = document.documentNumber;
|
apiDocument.documentNumber = document.documentNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.auditStatus !== undefined) {
|
if (document.auditStatus !== undefined) {
|
||||||
apiDocument.audit_status = document.auditStatus;
|
apiDocument.auditStatus = document.auditStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.isTest !== undefined) {
|
if (document.isTest !== undefined) {
|
||||||
apiDocument.is_test_document = document.isTest;
|
apiDocument.isTestDocument = document.isTest;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.remark !== undefined) {
|
if (document.remark !== undefined) {
|
||||||
apiDocument.remark = document.remark;
|
apiDocument.remark = document.remark;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('📤 [updateDocument] 更新文档API数据:', apiDocument);
|
try {
|
||||||
|
await axios.put(`${API_BASE_URL}/api/documents/${id}`, apiDocument, {
|
||||||
// 使用 axios-client 的 apiRequest 方法(支持自定义 headers)
|
|
||||||
// 接口路径: /api/postgrest/proxy/documents?id=eq.{id}
|
|
||||||
// 后端会自动注入 user_id 过滤条件(根据JWT中的用户信息)
|
|
||||||
const { apiRequest } = await import('../axios-client');
|
|
||||||
const response = await apiRequest<Document[]>(
|
|
||||||
`/api/postgrest/proxy/documents?id=eq.${id}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
data: apiDocument,
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : {})
|
...(frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : {})
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnsupportedNewDocumentCrud(error)) {
|
||||||
|
console.error('❌ [updateDocument] 更新文档API错误:', error);
|
||||||
|
return {
|
||||||
|
error: getErrorMessage(error, '更新文档信息失败'),
|
||||||
|
status: axios.isAxiosError(error) ? error.response?.status : 500
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (response.error) {
|
// 旧链路回退:仅允许修改自己的文档;新链路上线后应由后端基于地区/角色做数据隔离。
|
||||||
console.error('❌ [updateDocument] 更新文档API错误:', response.error);
|
const { apiRequest } = await import('../axios-client');
|
||||||
return { error: response.error, status: response.status };
|
const response = await apiRequest<Document[]>(
|
||||||
}
|
`/api/postgrest/proxy/documents?id=eq.${id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
data: {
|
||||||
|
...(document.documentNumber !== undefined ? { document_number: document.documentNumber } : {}),
|
||||||
|
...(document.auditStatus !== undefined ? { audit_status: document.auditStatus } : {}),
|
||||||
|
...(document.isTest !== undefined ? { is_test_document: document.isTest } : {}),
|
||||||
|
...(document.remark !== undefined ? { remark: document.remark } : {}),
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 检查返回数据
|
if (response.error) {
|
||||||
// 成功时返回更新后的文档数组,空数组表示文档不存在或无权访问
|
console.error('❌ [updateDocument] 更新文档API错误:', response.error);
|
||||||
const responseData = response.data;
|
return { error: response.error, status: response.status };
|
||||||
if (!responseData || (Array.isArray(responseData) && responseData.length === 0)) {
|
}
|
||||||
return { error: '文档不存在或无权访问', status: 404 };
|
|
||||||
|
const responseData = response.data;
|
||||||
|
if (!responseData || (Array.isArray(responseData) && responseData.length === 0)) {
|
||||||
|
return { error: '文档不存在或无权访问', status: 404 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取更新后的完整文档数据(包含关联的文档类型信息)
|
// 获取更新后的完整文档数据(包含关联的文档类型信息)
|
||||||
@@ -672,26 +820,18 @@ export async function getDocumentsListFromAPI(searchParams: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (documentTypeIds && documentTypeIds.length === 1) {
|
if (documentTypeIds && documentTypeIds.length > 0) {
|
||||||
const typeResponse = await getDocumentTypes({ ids: documentTypeIds, page: 1, pageSize: 10 }, token);
|
params.type_ids = documentTypeIds.join(',');
|
||||||
const matchedType = typeResponse.data?.types?.[0];
|
|
||||||
if (matchedType?.code) {
|
|
||||||
params.typeCode = matchedType.code;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下面几个旧筛选项在新系统版列表接口里暂未一一对齐:
|
// 下面几个旧筛选项暂未完全对齐:
|
||||||
// - documentNumber
|
// - documentNumber
|
||||||
// - auditStatus
|
// - auditStatus
|
||||||
// - dateFrom/dateTo
|
|
||||||
// - 多个 documentTypeIds 的组合筛选
|
|
||||||
// 先保留参数签名,后续再单独接新后端的类型/状态体系。
|
|
||||||
void documentNumber;
|
void documentNumber;
|
||||||
void auditStatus;
|
void auditStatus;
|
||||||
void dateFrom;
|
if (dateFrom) params.dateFrom = dateFrom;
|
||||||
void dateTo;
|
if (dateTo) params.dateTo = dateTo;
|
||||||
|
|
||||||
const axios = await import('axios').then(m => m.default);
|
|
||||||
const response = await axios.get(`${API_BASE_URL}/api/documents/list`, {
|
const response = await axios.get(`${API_BASE_URL}/api/documents/list`, {
|
||||||
params,
|
params,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -709,73 +849,7 @@ export async function getDocumentsListFromAPI(searchParams: {
|
|||||||
const totalCount = pageData.total || 0;
|
const totalCount = pageData.total || 0;
|
||||||
const totalPages = pageData.totalPages || Math.ceil(totalCount / pageSize) || 0;
|
const totalPages = pageData.totalPages || Math.ceil(totalCount / pageSize) || 0;
|
||||||
|
|
||||||
const convertedDocuments: DocumentUI[] = backendDocuments.map((doc) => {
|
const convertedDocuments: DocumentUI[] = backendDocuments.map((doc) => mapLeauditDocumentToUI(doc));
|
||||||
const historyVersions: DocumentVersionUI[] = (doc.historyVersions || []).map((hv) => ({
|
|
||||||
id: hv.documentId,
|
|
||||||
name: hv.fileName || doc.fileName || doc.normalizedName || '未命名文档',
|
|
||||||
documentNumber: buildDocumentNumber(hv),
|
|
||||||
type: doc.typeId?.toString() || '',
|
|
||||||
typeName: typeNameFromCode(doc.typeCode),
|
|
||||||
size: 0,
|
|
||||||
auditStatus: mapLeauditDocToAuditStatus({
|
|
||||||
processingStatus: hv.processingStatus,
|
|
||||||
runStatus: hv.runStatus,
|
|
||||||
passedCount: null,
|
|
||||||
failedCount: null,
|
|
||||||
}),
|
|
||||||
fileStatus: mapProcessingStatusToFileStatus(hv.processingStatus),
|
|
||||||
issues: null,
|
|
||||||
uploadTime: formatDate(hv.updatedAt || ''),
|
|
||||||
fileType: hv.fileExt || getFileExtension(hv.fileName || doc.fileName || ''),
|
|
||||||
path: '',
|
|
||||||
isTest: false,
|
|
||||||
updatedAt: formatDate(hv.updatedAt || ''),
|
|
||||||
pageCount: 0,
|
|
||||||
ocrResult: undefined,
|
|
||||||
versionNumber: hv.versionNo,
|
|
||||||
pass_count: null,
|
|
||||||
warning_count: 0,
|
|
||||||
error_count: null,
|
|
||||||
manual_count: null,
|
|
||||||
previous_pass_count: null,
|
|
||||||
previous_warning_count: null,
|
|
||||||
previous_error_count: null,
|
|
||||||
previous_manual_count: null
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: doc.documentId,
|
|
||||||
name: doc.fileName || doc.normalizedName || '未命名文档',
|
|
||||||
documentNumber: buildDocumentNumber(doc),
|
|
||||||
type: doc.typeId?.toString() || '',
|
|
||||||
typeName: typeNameFromCode(doc.typeCode),
|
|
||||||
size: doc.fileSize || 0,
|
|
||||||
auditStatus: mapLeauditDocToAuditStatus(doc),
|
|
||||||
fileStatus: mapProcessingStatusToFileStatus(doc.processingStatus),
|
|
||||||
issues: doc.failedCount ?? null,
|
|
||||||
uploadTime: formatDate(doc.updatedAt || ''),
|
|
||||||
fileType: doc.fileExt || getFileExtension(doc.fileName || ''),
|
|
||||||
path: doc.ossUrl || '',
|
|
||||||
isTest: false,
|
|
||||||
updatedAt: formatDate(doc.updatedAt || ''),
|
|
||||||
pageCount: 0,
|
|
||||||
ocrResult: undefined,
|
|
||||||
pass_count: doc.passedCount ?? null,
|
|
||||||
warning_count: 0,
|
|
||||||
error_count: doc.failedCount ?? null,
|
|
||||||
manual_count: doc.skippedCount ?? null,
|
|
||||||
warning_messages: [],
|
|
||||||
error_messages: [],
|
|
||||||
manual_messages: [],
|
|
||||||
historyCount: Math.max(0, (doc.totalVersions || 1) - 1),
|
|
||||||
previousIssues: historyVersions[0]?.issues ?? null,
|
|
||||||
previous_pass_count: historyVersions[0]?.pass_count ?? null,
|
|
||||||
previous_warning_count: historyVersions[0]?.warning_count ?? null,
|
|
||||||
previous_error_count: historyVersions[0]?.error_count ?? null,
|
|
||||||
previous_manual_count: historyVersions[0]?.manual_count ?? null,
|
|
||||||
historyVersions: historyVersions.length > 0 ? historyVersions : undefined
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
+296
-45
@@ -110,6 +110,25 @@ function getDocumentTypeIdsFromSession(): number[] | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedModuleIdFromSession(): number | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleId = sessionStorage.getItem('selectedModuleId');
|
||||||
|
if (!moduleId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(moduleId);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [getSelectedModuleId] 解析 selectedModuleId 失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 文档状态枚举
|
// 文档状态枚举
|
||||||
export enum DocumentStatus {
|
export enum DocumentStatus {
|
||||||
waiting = 'waiting',
|
waiting = 'waiting',
|
||||||
@@ -132,6 +151,15 @@ export interface DocumentType {
|
|||||||
ruleSetIds?: number[];
|
ruleSetIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadErrorDetails {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
detailLines: string[];
|
||||||
|
actionLines: string[];
|
||||||
|
rawMessage: string;
|
||||||
|
category: 'rule_binding' | 'rule_version' | 'entry_module' | 'permission' | 'file' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
// 提取结果接口
|
// 提取结果接口
|
||||||
interface ExtractedResult {
|
interface ExtractedResult {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -216,6 +244,223 @@ interface NewUploadResponse {
|
|||||||
autoRunTriggered: boolean;
|
autoRunTriggered: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadErrorPayload = Record<string, unknown> | null | undefined;
|
||||||
|
|
||||||
|
function readPayloadValue(payload: UploadErrorPayload, keys: string[]): unknown {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key in payload && payload[key] !== undefined && payload[key] !== null && payload[key] !== '') {
|
||||||
|
return payload[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedData = payload.data;
|
||||||
|
if (nestedData && typeof nestedData === 'object') {
|
||||||
|
const nestedRecord = nestedData as Record<string, unknown>;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key in nestedRecord && nestedRecord[key] !== undefined && nestedRecord[key] !== null && nestedRecord[key] !== '') {
|
||||||
|
return nestedRecord[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyIdList(values?: number[]): string | null {
|
||||||
|
if (!values || values.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUploadErrorDetails(
|
||||||
|
rawMessage: string,
|
||||||
|
options?: {
|
||||||
|
documentType?: DocumentType | null;
|
||||||
|
status?: number;
|
||||||
|
payload?: UploadErrorPayload;
|
||||||
|
}
|
||||||
|
): UploadErrorDetails {
|
||||||
|
const documentType = options?.documentType ?? null;
|
||||||
|
const status = options?.status;
|
||||||
|
const payload = options?.payload;
|
||||||
|
const message = (rawMessage || '上传失败').trim();
|
||||||
|
const normalizedMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
const payloadRuleSetName = readPayloadValue(payload, ['ruleSetName', 'ruleName', 'ruleSet', 'rule_type']);
|
||||||
|
const payloadRuleSetId = readPayloadValue(payload, ['ruleSetId', 'rule_set_id']);
|
||||||
|
const payloadEntryModuleName = readPayloadValue(payload, ['entryModuleName', 'moduleName', 'entryModule']);
|
||||||
|
const payloadEntryModuleId = readPayloadValue(payload, ['entryModuleId', 'entry_module_id']);
|
||||||
|
|
||||||
|
const ruleSetDisplayName = payloadRuleSetName
|
||||||
|
? String(payloadRuleSetName)
|
||||||
|
: documentType?.ruleSetIds?.length === 1
|
||||||
|
? `规则集 ID ${documentType.ruleSetIds[0]}`
|
||||||
|
: null;
|
||||||
|
const entryModuleDisplayName = payloadEntryModuleName
|
||||||
|
? String(payloadEntryModuleName)
|
||||||
|
: payloadEntryModuleId
|
||||||
|
? `入口模块 ID ${payloadEntryModuleId}`
|
||||||
|
: documentType?.entryModuleId
|
||||||
|
? `入口模块 ID ${documentType.entryModuleId}`
|
||||||
|
: null;
|
||||||
|
const documentTypeDisplayName = documentType
|
||||||
|
? `${documentType.name}${documentType.code ? `(${documentType.code})` : ''}`
|
||||||
|
: '当前文档类型';
|
||||||
|
|
||||||
|
if (status === 401 || normalizedMessage.includes('unauthorized') || message.includes('未登录') || message.includes('token')) {
|
||||||
|
return {
|
||||||
|
title: '登录状态已失效',
|
||||||
|
summary: '当前登录状态已失效,系统无法继续上传文件。',
|
||||||
|
detailLines: [
|
||||||
|
'请先重新登录,再重新选择文件上传。',
|
||||||
|
`后端返回:${message}`,
|
||||||
|
],
|
||||||
|
actionLines: ['刷新页面并重新登录后重试。'],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'permission',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403 || message.includes('无权限') || message.includes('权限') || normalizedMessage.includes('forbidden')) {
|
||||||
|
return {
|
||||||
|
title: '当前账号没有上传权限',
|
||||||
|
summary: '当前账号没有执行该上传操作的权限,文件尚未进入审核流程。',
|
||||||
|
detailLines: [
|
||||||
|
`文档类型:${documentTypeDisplayName}`,
|
||||||
|
`后端返回:${message}`,
|
||||||
|
],
|
||||||
|
actionLines: ['请联系管理员检查当前账号的上传权限或入口模块授权。'],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'permission',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
message.includes('未绑定可用规则版本') ||
|
||||||
|
message.includes('未绑定规则版本') ||
|
||||||
|
message.includes('没有可用规则版本') ||
|
||||||
|
message.includes('未找到可用规则版本')
|
||||||
|
) {
|
||||||
|
const detailLines = [
|
||||||
|
`文档类型:${documentTypeDisplayName}`,
|
||||||
|
documentType?.ruleSetIds?.length
|
||||||
|
? `已绑定规则集:${stringifyIdList(documentType.ruleSetIds)}`
|
||||||
|
: '当前文档类型还没有绑定任何规则集。',
|
||||||
|
entryModuleDisplayName
|
||||||
|
? `归属入口:${entryModuleDisplayName}`
|
||||||
|
: '当前文档类型还没有配置入口模块,上传入口配置也需要一起检查。',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ruleSetDisplayName || payloadRuleSetId) {
|
||||||
|
detailLines.push(`疑似异常规则集:${ruleSetDisplayName || `规则集 ID ${payloadRuleSetId}`}`);
|
||||||
|
} else if (documentType?.ruleSetIds && documentType.ruleSetIds.length > 1) {
|
||||||
|
detailLines.push('该文档类型绑定了多个规则集,需要逐个确认是否都已发布可用版本。');
|
||||||
|
} else {
|
||||||
|
detailLines.push('后端没有返回具体规则集名称,建议优先检查该文档类型绑定的规则集是否已发布版本。');
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLines.push(`后端返回:${message}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '审核规则未配置完整',
|
||||||
|
summary: `${documentTypeDisplayName} 目前没有可用的审核规则版本,所以系统无法接收本次上传。`,
|
||||||
|
detailLines,
|
||||||
|
actionLines: [
|
||||||
|
'到“系统设置 / 文档类型管理”检查该文档类型是否绑定了正确的规则集。',
|
||||||
|
'到“规则管理 / 规则集管理”确认对应规则集至少有一个已发布、可用的版本。',
|
||||||
|
'如果首页入口也异常,请同时到“系统设置 / 入口模块管理”检查入口模块绑定。',
|
||||||
|
],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'rule_binding',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('规则集不存在') || message.includes('规则版本不存在') || message.includes('未发布')) {
|
||||||
|
const detailLines = [
|
||||||
|
`文档类型:${documentTypeDisplayName}`,
|
||||||
|
ruleSetDisplayName
|
||||||
|
? `相关规则集:${ruleSetDisplayName}`
|
||||||
|
: '后端未返回明确的规则集名称。',
|
||||||
|
`后端返回:${message}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '规则集版本不可用',
|
||||||
|
summary: '当前上传入口关联的规则集或规则版本不可用,文件无法开始审核。',
|
||||||
|
detailLines,
|
||||||
|
actionLines: [
|
||||||
|
'到“规则管理 / 规则集管理”检查对应规则集是否存在、是否已发布版本。',
|
||||||
|
'如文档类型绑定了错误的规则集,请到“系统设置 / 文档类型管理”修正绑定关系。',
|
||||||
|
],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'rule_version',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('入口模块')) {
|
||||||
|
return {
|
||||||
|
title: '入口模块配置异常',
|
||||||
|
summary: '当前文档类型关联的入口模块配置异常,导致上传链路无法正常工作。',
|
||||||
|
detailLines: [
|
||||||
|
`文档类型:${documentTypeDisplayName}`,
|
||||||
|
entryModuleDisplayName
|
||||||
|
? `相关入口模块:${entryModuleDisplayName}`
|
||||||
|
: '后端未返回明确的入口模块名称或编号。',
|
||||||
|
`后端返回:${message}`,
|
||||||
|
],
|
||||||
|
actionLines: [
|
||||||
|
'到“系统设置 / 入口模块管理”检查目标入口模块是否存在、是否启用、跳转路径是否正确。',
|
||||||
|
'到“系统设置 / 文档类型管理”确认该文档类型绑定到了正确的入口模块。',
|
||||||
|
],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'entry_module',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 413 || message.includes('文件过大') || message.includes('too large')) {
|
||||||
|
return {
|
||||||
|
title: '文件过大,上传被拒绝',
|
||||||
|
summary: '当前文件大小超出系统允许范围,服务器未接收该文件。',
|
||||||
|
detailLines: [`后端返回:${message}`],
|
||||||
|
actionLines: ['请压缩文件体积或拆分后重新上传。'],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('文件类型') || message.includes('格式') || status === 415) {
|
||||||
|
return {
|
||||||
|
title: '文件格式不支持',
|
||||||
|
summary: '当前文件格式不符合上传要求,服务器拒绝处理。',
|
||||||
|
detailLines: [`后端返回:${message}`],
|
||||||
|
actionLines: ['请确认上传的是 PDF 或 Word 文件,并检查文件是否损坏。'],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '上传失败',
|
||||||
|
summary: '文件未能成功上传到服务器,请根据下方信息检查原因。',
|
||||||
|
detailLines: [
|
||||||
|
`文档类型:${documentTypeDisplayName}`,
|
||||||
|
`后端返回:${message}`,
|
||||||
|
],
|
||||||
|
actionLines: [
|
||||||
|
'如果是规则或入口配置问题,请先检查文档类型、规则集、入口模块三处配置。',
|
||||||
|
'如暂时无法定位,可将下方后端原始提示发给后端同学继续排查。',
|
||||||
|
],
|
||||||
|
rawMessage: message,
|
||||||
|
category: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将文件转换为二进制数据
|
* 将文件转换为二进制数据
|
||||||
*/
|
*/
|
||||||
@@ -406,7 +651,7 @@ export async function uploadDocumentToServer(
|
|||||||
autoRun: boolean = true,
|
autoRun: boolean = true,
|
||||||
speed: string = "normal",
|
speed: string = "normal",
|
||||||
jwtToken?: string,
|
jwtToken?: string,
|
||||||
): Promise<{ data: UploadResult } | { error: string; status?: number }> {
|
): Promise<{ data: UploadResult } | { error: string; status?: number; payload?: UploadErrorPayload }> {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const blob = new Blob([binaryData], { type: fileType });
|
const blob = new Blob([binaryData], { type: fileType });
|
||||||
@@ -433,7 +678,7 @@ export async function uploadDocumentToServer(
|
|||||||
// Result<DocumentUploadVO> envelope
|
// Result<DocumentUploadVO> envelope
|
||||||
const uploadData: NewUploadResponse | undefined = body?.data;
|
const uploadData: NewUploadResponse | undefined = body?.data;
|
||||||
if (!uploadData || !uploadData.documentId) {
|
if (!uploadData || !uploadData.documentId) {
|
||||||
return { error: body?.message || "上传响应解析失败", status: response.status };
|
return { error: body?.message || body?.msg || "上传响应解析失败", status: response.status, payload: body };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -454,10 +699,12 @@ export async function uploadDocumentToServer(
|
|||||||
if (axios.isAxiosError(axiosError)) {
|
if (axios.isAxiosError(axiosError)) {
|
||||||
const serverMessage =
|
const serverMessage =
|
||||||
(axiosError.response?.data as any)?.message ||
|
(axiosError.response?.data as any)?.message ||
|
||||||
(axiosError.response?.data as any)?.msg;
|
(axiosError.response?.data as any)?.msg ||
|
||||||
|
(axiosError.response?.data as any)?.error;
|
||||||
return {
|
return {
|
||||||
error: serverMessage || `上传失败 (HTTP ${axiosError.response?.status || "unknown"})`,
|
error: serverMessage || `上传失败 (HTTP ${axiosError.response?.status || "unknown"})`,
|
||||||
status: axiosError.response?.status,
|
status: axiosError.response?.status,
|
||||||
|
payload: axiosError.response?.data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { error: axiosError instanceof Error ? axiosError.message : "上传失败" };
|
return { error: axiosError instanceof Error ? axiosError.message : "上传失败" };
|
||||||
@@ -489,8 +736,11 @@ export async function getTodayDocuments(
|
|||||||
dateFrom: today,
|
dateFrom: today,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (documentTypeIds && documentTypeIds.length > 0) {
|
const selectedModuleId = getSelectedModuleIdFromSession();
|
||||||
params.typeCode = ""; // 后续可按 typeId→typeCode 映射
|
if (selectedModuleId) {
|
||||||
|
params.entry_module_id = selectedModuleId;
|
||||||
|
} else if (documentTypeIds && documentTypeIds.length > 0) {
|
||||||
|
params.type_ids = documentTypeIds.join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@@ -532,8 +782,11 @@ export async function getTodayDocuments(
|
|||||||
export async function getDocumentTypes(token?: string): Promise<{data: DocumentType[]; error?: never} | {data?: never; error: string; status?: number}> {
|
export async function getDocumentTypes(token?: string): Promise<{data: DocumentType[]; error?: never} | {data?: never; error: string; status?: number}> {
|
||||||
try {
|
try {
|
||||||
const documentTypeIds = getDocumentTypeIdsFromSession();
|
const documentTypeIds = getDocumentTypeIdsFromSession();
|
||||||
|
const selectedModuleId = getSelectedModuleIdFromSession();
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (documentTypeIds && documentTypeIds.length > 0) {
|
if (selectedModuleId) {
|
||||||
|
params.entry_module_id = String(selectedModuleId);
|
||||||
|
} else if (documentTypeIds && documentTypeIds.length > 0) {
|
||||||
params.ids = documentTypeIds.join(",");
|
params.ids = documentTypeIds.join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,23 +835,37 @@ export async function getDocumentsStatus(
|
|||||||
if ((!documentIds || documentIds.length === 0) && (!attachmentIds || attachmentIds.length === 0)) {
|
if ((!documentIds || documentIds.length === 0) && (!attachmentIds || attachmentIds.length === 0)) {
|
||||||
return { data: [] };
|
return { data: [] };
|
||||||
}
|
}
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
// 查询主文档状态
|
if (token) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
let documentsResponse: any = { data: [], error: undefined, status: undefined };
|
}
|
||||||
if (documentIds && documentIds.length > 0) {
|
|
||||||
const documentsParams: PostgrestParams = {
|
const normalizedIds = Array.from(new Set(documentIds.filter(id => Number.isFinite(id) && id > 0)));
|
||||||
select: 'id, status',
|
const allData: Document[] = [];
|
||||||
filter: {
|
|
||||||
'id': `in.(${documentIds.join(',')})`
|
if (normalizedIds.length > 0) {
|
||||||
}
|
const response = await axios.get(`${API_BASE_URL}/api/documents/status`, {
|
||||||
};
|
params: { ids: normalizedIds.join(",") },
|
||||||
documentsResponse = await postgrestGet<Document[]>('/api/postgrest/proxy/documents', { ...documentsParams, token });
|
headers,
|
||||||
|
});
|
||||||
|
const statusItems = extractApiData<Array<{
|
||||||
|
documentId: number;
|
||||||
|
processingStatus?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}>>(response.data) || [];
|
||||||
|
|
||||||
|
statusItems.forEach(item => {
|
||||||
|
allData.push({
|
||||||
|
id: item.documentId,
|
||||||
|
name: `文档_${item.documentId}`,
|
||||||
|
type_id: 0,
|
||||||
|
file_size: 0,
|
||||||
|
status: (item.processingStatus as DocumentStatus) || DocumentStatus.waiting,
|
||||||
|
created_at: item.updatedAt || "",
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询合同附件状态
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let attachmentResponse: any = { data: [], error: undefined, status: undefined };
|
|
||||||
if (attachmentIds && attachmentIds.length > 0) {
|
if (attachmentIds && attachmentIds.length > 0) {
|
||||||
const attachmentParams: PostgrestParams = {
|
const attachmentParams: PostgrestParams = {
|
||||||
select: 'id, status',
|
select: 'id, status',
|
||||||
@@ -606,38 +873,22 @@ export async function getDocumentsStatus(
|
|||||||
'id': `in.(${attachmentIds.join(',')})`
|
'id': `in.(${attachmentIds.join(',')})`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
attachmentResponse = await postgrestGet<ContractStructureComparison[]>('/api/postgrest/proxy/contract_structure_comparison', { ...attachmentParams, token });
|
const attachmentResponse = await postgrestGet<ContractStructureComparison[]>('/api/postgrest/proxy/contract_structure_comparison', { ...attachmentParams, token });
|
||||||
}
|
if (attachmentResponse.error) {
|
||||||
|
return { error: attachmentResponse.error, status: attachmentResponse.status };
|
||||||
if (documentsResponse.error && attachmentResponse.error) {
|
|
||||||
return { error: documentsResponse.error || attachmentResponse.error, status: documentsResponse.status || attachmentResponse.status };
|
|
||||||
}
|
|
||||||
|
|
||||||
let allData: Document[] = [];
|
|
||||||
|
|
||||||
// 处理主文档数据
|
|
||||||
if (!documentsResponse.error && documentsResponse.data) {
|
|
||||||
const extractedDocuments = extractApiData<Document[]>(documentsResponse.data);
|
|
||||||
if (extractedDocuments) {
|
|
||||||
allData = [...allData, ...extractedDocuments];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 处理合同附件数据
|
const extractedAttachments = extractApiData<ContractStructureComparison[]>(attachmentResponse.data) || [];
|
||||||
if (!attachmentResponse.error && attachmentResponse.data) {
|
extractedAttachments.forEach(item => {
|
||||||
const extractedAttachments = extractApiData<ContractStructureComparison[]>(attachmentResponse.data);
|
allData.push({
|
||||||
if (extractedAttachments) {
|
|
||||||
// 将ContractStructureComparison转换为Document格式
|
|
||||||
const convertedAttachments: Document[] = extractedAttachments.map(item => ({
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.template_contract_name || `合同结构比较记录_${item.id}`,
|
name: item.template_contract_name || `合同结构比较记录_${item.id}`,
|
||||||
type_id: 1,
|
type_id: 1,
|
||||||
file_size: item.file_size || 0,
|
file_size: item.file_size || 0,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
created_at: item.created_at
|
created_at: item.created_at
|
||||||
}));
|
});
|
||||||
allData = [...allData, ...convertedAttachments];
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: allData };
|
return { data: allData };
|
||||||
|
|||||||
+12
-2
@@ -4,6 +4,12 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { API_BASE_URL } from '../config/api-config';
|
import { API_BASE_URL } from '../config/api-config';
|
||||||
|
|
||||||
|
interface ApiResult<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 队列状态响应接口
|
* 队列状态响应接口
|
||||||
*/
|
*/
|
||||||
@@ -43,12 +49,16 @@ export async function getQueueStatus(): Promise<{ data?: QueueStatus; error?: st
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get<QueueStatus>(
|
const response = await axios.get<ApiResult<QueueStatus>>(
|
||||||
`${API_BASE_URL}/api/v2/system/queue/status`,
|
`${API_BASE_URL}/api/v2/system/queue/status`,
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
|
|
||||||
return { data: response.data };
|
if (response.data?.data) {
|
||||||
|
return { data: response.data.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: response.data?.message || '队列状态响应格式错误' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 队列接口暂未迁移,404 时返回空状态不报错
|
// 队列接口暂未迁移,404 时返回空状态不报错
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
|||||||
@@ -516,7 +516,8 @@ export async function getRoleRoutesWithPermissions(roleId: number): Promise<{
|
|||||||
*/
|
*/
|
||||||
export async function saveRoleApiPermissions(
|
export async function saveRoleApiPermissions(
|
||||||
roleId: number,
|
roleId: number,
|
||||||
permissionIds: number[]
|
permissionIds: number[],
|
||||||
|
scopePermissionIds: number[] = []
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
// 构建权限配置
|
// 构建权限配置
|
||||||
@@ -530,7 +531,8 @@ export async function saveRoleApiPermissions(
|
|||||||
const response = await post<any>('/api/v3/rbac/role-permissions', {
|
const response = await post<any>('/api/v3/rbac/role-permissions', {
|
||||||
role_id: roleId,
|
role_id: roleId,
|
||||||
permissions,
|
permissions,
|
||||||
replace: true // 替换模式:先删除现有权限,再插入新权限
|
replace: true, // 替换模式:仅替换当前页面涉及的权限范围
|
||||||
|
replace_scope_permission_ids: scopePermissionIds
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
@@ -555,6 +557,52 @@ export async function saveRoleApiPermissions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子保存角色的菜单权限与 API 权限
|
||||||
|
* @param roleId 角色ID
|
||||||
|
* @param routeIds 路由ID数组
|
||||||
|
* @param permissionIds 权限ID数组
|
||||||
|
* @param scopePermissionIds 本页允许被替换清理的权限范围
|
||||||
|
*/
|
||||||
|
export async function saveRoleAccess(
|
||||||
|
roleId: number,
|
||||||
|
routeIds: number[],
|
||||||
|
permissionIds: number[],
|
||||||
|
scopePermissionIds: number[] = []
|
||||||
|
): Promise<{ success: boolean; message: string; code?: number }> {
|
||||||
|
try {
|
||||||
|
const response = await post<any>(`/api/v3/rbac/roles/${roleId}/access`, {
|
||||||
|
route_ids: routeIds,
|
||||||
|
permission_ids: permissionIds,
|
||||||
|
route_permission: 'RW',
|
||||||
|
replace_scope_permission_ids: scopePermissionIds
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(response.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data?.code && response.data.code !== 200) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: response.data.message || '角色权限保存失败',
|
||||||
|
code: response.data.code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: response.data?.message || '角色权限保存成功'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [saveRoleAccess] 保存角色联合权限失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '角色权限保存失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新角色的路由权限 - v3.2更新
|
* 更新角色的路由权限 - v3.2更新
|
||||||
* @param roleId 角色ID
|
* @param roleId 角色ID
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ export const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
// documentUrl: 'http://nas.7bm.co:8096/docauditai/',
|
// documentUrl: 'http://nas.7bm.co:8096/docauditai/',
|
||||||
// uploadUrl: 'http://nas.7bm.co:8096/api/v2/documents',
|
// uploadUrl: 'http://nas.7bm.co:8096/api/v2/documents',
|
||||||
// leaudit-platform 联调地址
|
// leaudit-platform 联调地址
|
||||||
baseUrl: 'http://172.16.0.59:8096',
|
baseUrl: 'http://nas.7bm.co:8096',
|
||||||
documentUrl: 'http://172.16.0.59:8096/docauditai/',
|
documentUrl: 'http://nas.7bm.co:8096/docauditai/',
|
||||||
uploadUrl: 'http://172.16.0.59:8096/api/upload',
|
uploadUrl: 'http://nas.7bm.co:8096/api/upload',
|
||||||
collaboraUrl: 'http://172.16.0.58:9980',
|
collaboraUrl: 'http://172.16.0.58:9980',
|
||||||
appUrl: 'http://172.16.0.34:51703',
|
appUrl: 'http://172.16.0.34:51703',
|
||||||
|
|
||||||
@@ -89,9 +89,9 @@ export const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
// collaboraUrl: 'http://172.16.0.81:9980',
|
// collaboraUrl: 'http://172.16.0.81:9980',
|
||||||
// appUrl: 'http://172.16.0.34:51704',
|
// appUrl: 'http://172.16.0.34:51704',
|
||||||
|
|
||||||
baseUrl: 'http://172.16.0.59:8096', // FastAPI后端(包含/dify代理)
|
baseUrl: 'http://nas.7bm.co:8096', // FastAPI后端(包含/dify代理)
|
||||||
documentUrl: 'http://172.16.0.59:8096/docauditai/',
|
documentUrl: 'http://nas.7bm.co:8096/docauditai/',
|
||||||
uploadUrl: 'http://172.16.0.59:8096/api/v2/documents',
|
uploadUrl: 'http://nas.7bm.co:8096/api/v2/documents',
|
||||||
collaboraUrl: 'http://172.16.0.58:9980',
|
collaboraUrl: 'http://172.16.0.58:9980',
|
||||||
appUrl: 'http://172.16.0.34:51703',
|
appUrl: 'http://172.16.0.34:51703',
|
||||||
|
|
||||||
@@ -173,9 +173,9 @@ const configs: Record<string, ApiConfig> = {
|
|||||||
// documentUrl: 'http://172.16.0.59:8096/docauditai/',
|
// documentUrl: 'http://172.16.0.59:8096/docauditai/',
|
||||||
// uploadUrl: 'http://172.16.0.59:8096/api/v2/documents',
|
// uploadUrl: 'http://172.16.0.59:8096/api/v2/documents',
|
||||||
// leaudit-platform 联调地址
|
// leaudit-platform 联调地址
|
||||||
baseUrl: 'http://172.16.0.59:8096',
|
baseUrl: 'http://nas.7bm.co:8096',
|
||||||
documentUrl: 'http://172.16.0.59:8096/docauditai/',
|
documentUrl: 'http://nas.7bm.co:8096/docauditai/',
|
||||||
uploadUrl: 'http://172.16.0.59:8096/api/upload',
|
uploadUrl: 'http://nas.7bm.co:8096/api/upload',
|
||||||
// baseUrl: 'http://172.16.0.84:8073', // FastAPI后端(包含/dify代理)
|
// baseUrl: 'http://172.16.0.84:8073', // FastAPI后端(包含/dify代理)
|
||||||
// documentUrl: 'http://172.16.0.84:8073/docauditai/',
|
// documentUrl: 'http://172.16.0.84:8073/docauditai/',
|
||||||
// uploadUrl: 'http://172.16.0.84:8073/api/v2/documents',
|
// uploadUrl: 'http://172.16.0.84:8073/api/v2/documents',
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export const MINIMAL_MENU_PREFIXES = [
|
|||||||
'/documents',
|
'/documents',
|
||||||
'/settings',
|
'/settings',
|
||||||
'/entry-modules',
|
'/entry-modules',
|
||||||
'/role-permissions'
|
'/role-permissions',
|
||||||
|
'/document-types'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const MINIMAL_HOME_TARGETS = [
|
export const MINIMAL_HOME_TARGETS = [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useSearchParams, useLoaderData } from "@remix-run/react";
|
import { useNavigate, useLoaderData } from "@remix-run/react";
|
||||||
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||||
import { Card } from "~/components/ui/Card";
|
import { Card } from "~/components/ui/Card";
|
||||||
import { Button } from "~/components/ui/Button";
|
import { Button } from "~/components/ui/Button";
|
||||||
@@ -64,9 +64,28 @@ export default function DocumentTypeNew() {
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [entryModuleId, setEntryModuleId] = useState<number | null>(null);
|
const [entryModuleId, setEntryModuleId] = useState<number | null>(null);
|
||||||
const [selectedRuleSetIds, setSelectedRuleSetIds] = useState<number[]>([]);
|
const [selectedRuleSetIds, setSelectedRuleSetIds] = useState<number[]>([]);
|
||||||
|
const [ruleSetKeyword, setRuleSetKeyword] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const selectedModule = loaderData.entryModules.find((m) => m.id === entryModuleId);
|
||||||
|
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),
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (editType) {
|
if (editType) {
|
||||||
setCode(editType.code || "");
|
setCode(editType.code || "");
|
||||||
@@ -82,6 +101,9 @@ export default function DocumentTypeNew() {
|
|||||||
if (!code.trim()) errs.code = "编码不能为空";
|
if (!code.trim()) errs.code = "编码不能为空";
|
||||||
else if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(code.trim())) errs.code = "编码格式:字母开头,可含字母数字._";
|
else if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(code.trim())) errs.code = "编码格式:字母开头,可含字母数字._";
|
||||||
if (!name.trim()) errs.name = "名称不能为空";
|
if (!name.trim()) errs.name = "名称不能为空";
|
||||||
|
if (selectedUnavailableRuleSets.length > 0) {
|
||||||
|
errs.ruleSetIds = "已选择的规则集中包含不可用于上传评查的项,请先发布/回滚可用版本";
|
||||||
|
}
|
||||||
setErrors(errs);
|
setErrors(errs);
|
||||||
return Object.keys(errs).length === 0;
|
return Object.keys(errs).length === 0;
|
||||||
};
|
};
|
||||||
@@ -131,99 +153,354 @@ export default function DocumentTypeNew() {
|
|||||||
return (
|
return (
|
||||||
<div className="document-type-new-page">
|
<div className="document-type-new-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h2 className="page-title">
|
<div className="page-heading">
|
||||||
<i className="ri-file-list-3-line"></i>
|
<span className="page-kicker">{isEdit ? "文档类型编辑" : "文档类型配置"}</span>
|
||||||
{isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"}
|
<h2 className="page-title">
|
||||||
</h2>
|
<i className="ri-file-list-3-line"></i>
|
||||||
|
{isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"}
|
||||||
|
</h2>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
为上传入口绑定清晰的文档语义、规则集和处理流向,减少后续抽取与评查配置分散的问题。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="header-overview">
|
||||||
|
<div className="header-badges">
|
||||||
|
<span className={`status-pill ${isEdit ? "edit" : "create"}`}>
|
||||||
|
<i className={isEdit ? "ri-edit-2-line" : "ri-magic-line"}></i>
|
||||||
|
{isEdit ? "编辑模式" : "创建模式"}
|
||||||
|
</span>
|
||||||
|
{entryModuleId ? (
|
||||||
|
<span className="status-pill linked">
|
||||||
|
<i className="ri-links-line"></i>
|
||||||
|
已绑定入口模块
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-pill muted">
|
||||||
|
<i className="ri-focus-3-line"></i>
|
||||||
|
尚未绑定入口模块
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-metrics">
|
||||||
|
<div className="hero-metric-card">
|
||||||
|
<span className="hero-metric-label">配置完成度</span>
|
||||||
|
<strong>{completionCount}/4</strong>
|
||||||
|
<small>基础信息、入口、规则集逐步补齐</small>
|
||||||
|
</div>
|
||||||
|
<div className="hero-metric-card">
|
||||||
|
<span className="hero-metric-label">已关联规则集</span>
|
||||||
|
<strong>{selectedRuleSetIds.length} 个</strong>
|
||||||
|
<small>{selectedRuleSetIds.length > 0 ? "已进入评查链路" : "建议按业务场景精确选择"}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="editor-shell">
|
||||||
<form className="doc-type-form" onSubmit={handleSubmit}>
|
<Card className="editor-main-card">
|
||||||
<div className="form-row">
|
<form className="doc-type-form" onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<section className="form-section">
|
||||||
<label className="required">类型编码</label>
|
<div className="section-heading">
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<span className="section-kicker">Step 01</span>
|
||||||
className={`form-input ${errors.code ? "error" : ""}`}
|
<h3>基础标识</h3>
|
||||||
placeholder="如: contract.sale"
|
</div>
|
||||||
value={code}
|
<p>先定义业务识别码与名称,确保后续抽取、归档、评查引用一致。</p>
|
||||||
onChange={(e) => { setCode(e.target.value); setErrors({ ...errors, code: "" }); }}
|
</div>
|
||||||
disabled={isEdit}
|
<div className="section-intro-card">
|
||||||
/>
|
<div className="section-intro-item">
|
||||||
{errors.code && <span className="form-error">{errors.code}</span>}
|
<i className="ri-fingerprint-line"></i>
|
||||||
{isEdit && <span className="form-hint">编码创建后不可修改</span>}
|
<div>
|
||||||
|
<strong>编码保持稳定</strong>
|
||||||
|
<span>编码创建后建议长期复用,避免和展示文案耦合。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="section-intro-item">
|
||||||
|
<i className="ri-text"></i>
|
||||||
|
<div>
|
||||||
|
<strong>名称面向使用者</strong>
|
||||||
|
<span>名称会直接出现在上传入口与评查流程,尽量让业务人员一眼看懂。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row two-column">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="required">类型编码</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-input ${errors.code ? "error" : ""}`}
|
||||||
|
placeholder="如: contract.sale"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => { setCode(e.target.value); setErrors({ ...errors, code: "" }); }}
|
||||||
|
disabled={isEdit}
|
||||||
|
/>
|
||||||
|
{errors.code && <span className="form-error">{errors.code}</span>}
|
||||||
|
{!errors.code && (
|
||||||
|
<span className="form-hint">{isEdit ? "编码创建后不可修改" : "建议使用业务域.场景名,便于接口与导航复用"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="required">类型名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-input ${errors.name ? "error" : ""}`}
|
||||||
|
placeholder="如: 通用买卖合同"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => { setName(e.target.value); setErrors({ ...errors, name: "" }); }}
|
||||||
|
/>
|
||||||
|
{errors.name && <span className="form-error">{errors.name}</span>}
|
||||||
|
{!errors.name && <span className="form-hint">名称会直接出现在上传入口、文档详情和评查流程里</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>描述</label>
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
placeholder="补充这个文档类型的适用业务场景、上传说明、抽取注意事项"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">建议写清这个类型的用途,方便后续运营和规则维护人员理解。</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="form-section">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Step 02</span>
|
||||||
|
<h3>入口与流程绑定</h3>
|
||||||
|
</div>
|
||||||
|
<p>把文档类型挂到正确入口,让前端导航、上传动作和后端处理链条保持一致。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>入口模块</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={entryModuleId ?? ""}
|
||||||
|
onChange={(e) => setEntryModuleId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
>
|
||||||
|
<option value="">不关联</option>
|
||||||
|
{loaderData.entryModules.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="form-hint">如果绑定入口模块,用户会在对应入口中更自然地发现该文档类型。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="binding-preview">
|
||||||
|
<div className="binding-preview-label">当前路由预览</div>
|
||||||
|
<div className="binding-preview-value">
|
||||||
|
<i className="ri-route-line"></i>
|
||||||
|
<span>{selectedModule?.name || "未绑定入口模块,上传入口不会主动露出此类型"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="form-section">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Step 03</span>
|
||||||
|
<h3>关联规则集</h3>
|
||||||
|
</div>
|
||||||
|
<p>规则集会直接影响上传后的评查范围,建议按业务场景精确绑定,不要泛滥勾选。</p>
|
||||||
|
</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">勾选后,该文档类型上传时会自动加载对应规则集执行评查</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>
|
||||||
|
{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 ? (
|
||||||
|
<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>
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</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.currentVersionId || rs.fallbackVersionId}`
|
||||||
|
: "无可用版本"}
|
||||||
|
</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>当前会回退使用最近可用版本 #{rs.fallbackVersionId},建议在规则管理中补齐当前版本指针。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="default" onClick={() => navigate("/document-types")} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? "保存中..." : isEdit ? "保存修改" : "创建"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
</form>
|
||||||
<label className="required">类型名称</label>
|
</Card>
|
||||||
<input
|
|
||||||
type="text"
|
<aside className="editor-sidebar">
|
||||||
className={`form-input ${errors.name ? "error" : ""}`}
|
<Card className="summary-card">
|
||||||
placeholder="如: 通用买卖合同"
|
<div className="summary-card-header">
|
||||||
value={name}
|
<span className="summary-kicker">实时概览</span>
|
||||||
onChange={(e) => { setName(e.target.value); setErrors({ ...errors, name: "" }); }}
|
<h3>配置概览</h3>
|
||||||
/>
|
|
||||||
{errors.name && <span className="form-error">{errors.name}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="summary-grid">
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">当前模式</span>
|
||||||
|
<strong>{isEdit ? "编辑既有类型" : "新建类型"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">类型编码</span>
|
||||||
|
<strong>{code.trim() || "待填写"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">类型名称</span>
|
||||||
|
<strong>{name.trim() || "待填写"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">入口模块</span>
|
||||||
|
<strong>{selectedModule?.name || "未绑定"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item full">
|
||||||
|
<span className="summary-label">已关联规则集</span>
|
||||||
|
<strong>{selectedRuleSetIds.length} 个</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-progress">
|
||||||
|
<div className="summary-progress-topline">
|
||||||
|
<span>表单完成度</span>
|
||||||
|
<strong>{completionCount * 25}%</strong>
|
||||||
|
</div>
|
||||||
|
<div className="summary-progress-bar">
|
||||||
|
<span style={{ width: `${completionCount * 25}%` }}></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="form-group">
|
<Card className="selection-card">
|
||||||
<label>描述</label>
|
<div className="summary-card-header">
|
||||||
<textarea
|
<span className="summary-kicker">即时结果</span>
|
||||||
className="form-textarea"
|
<h3>绑定结果预览</h3>
|
||||||
placeholder="文档类型描述"
|
</div>
|
||||||
value={description}
|
<div className="selection-stack">
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<div className="selection-item">
|
||||||
rows={3}
|
<span className="selection-label">入口归属</span>
|
||||||
/>
|
<strong>{selectedModule?.name || "暂未指定"}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="selection-item">
|
||||||
<div className="form-group">
|
<span className="selection-label">规则集清单</span>
|
||||||
<label>入口模块</label>
|
{selectedRuleSets.length > 0 ? (
|
||||||
<select
|
<div className="selection-tags">
|
||||||
className="form-select"
|
{selectedRuleSets.slice(0, 4).map((ruleSet) => (
|
||||||
value={entryModuleId ?? ""}
|
<span
|
||||||
onChange={(e) => setEntryModuleId(e.target.value ? parseInt(e.target.value) : null)}
|
key={ruleSet.id}
|
||||||
>
|
className={`selection-tag ${ruleSet.hasUsableVersion ? "" : "warning"}`}
|
||||||
<option value="">不关联</option>
|
>
|
||||||
{loaderData.entryModules.map((m) => (
|
{ruleSet.ruleName}
|
||||||
<option key={m.id} value={m.id}>{m.name}</option>
|
</span>
|
||||||
))}
|
))}
|
||||||
</select>
|
{selectedRuleSets.length > 4 && (
|
||||||
</div>
|
<span className="selection-tag muted">+{selectedRuleSets.length - 4}</span>
|
||||||
|
)}
|
||||||
<div className="form-group">
|
</div>
|
||||||
<label>关联规则集</label>
|
) : (
|
||||||
<span className="form-hint">勾选后,该文档类型上传时会自动加载对应规则集执行评查</span>
|
<strong>暂未选择</strong>
|
||||||
<div className="rule-set-checklist">
|
)}
|
||||||
{loaderData.ruleSets.length === 0 ? (
|
</div>
|
||||||
<p className="form-hint">暂无可选规则集</p>
|
{selectedUnavailableRuleSets.length > 0 && (
|
||||||
) : (
|
<div className="selection-item danger">
|
||||||
loaderData.ruleSets.map((rs) => (
|
<span className="selection-label">上传风险</span>
|
||||||
<label key={rs.id} className={`rule-set-item ${selectedRuleSetIds.includes(rs.id) ? "checked" : ""}`}>
|
<strong>当前绑定无法直接触发评查</strong>
|
||||||
<input
|
</div>
|
||||||
type="checkbox"
|
|
||||||
checked={selectedRuleSetIds.includes(rs.id)}
|
|
||||||
onChange={() => toggleRuleSet(rs.id)}
|
|
||||||
/>
|
|
||||||
<span className="rule-set-name">{rs.ruleName}</span>
|
|
||||||
<span className="rule-set-type">{rs.ruleType}</span>
|
|
||||||
<span className={`rule-set-status ${rs.status}`}>{rs.status}</span>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="form-actions">
|
<Card className="guide-card">
|
||||||
<Button type="default" onClick={() => navigate("/document-types")} disabled={saving}>
|
<div className="summary-card-header">
|
||||||
取消
|
<span className="summary-kicker">配置建议</span>
|
||||||
</Button>
|
<h3>建议做法</h3>
|
||||||
<Button type="primary" onClick={handleSubmit} disabled={saving}>
|
</div>
|
||||||
{saving ? "保存中..." : isEdit ? "保存修改" : "创建"}
|
<ul className="guide-list">
|
||||||
</Button>
|
<li>编码保持稳定,不要混入显示文案,便于后端接口与权限配置长期复用。</li>
|
||||||
</div>
|
<li>描述尽量写业务边界,例如“主合同”“补充协议”“付款附件”等,避免上传误选。</li>
|
||||||
</form>
|
<li>规则集宁可精简,也不要把无关规则打包给所有文档类型,避免误报过多。</li>
|
||||||
</Card>
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+169
-38
@@ -11,6 +11,7 @@ import uploadStyles from "~/styles/pages/files_upload.css?url";
|
|||||||
import { messageService } from "~/components/ui/MessageModal";
|
import { messageService } from "~/components/ui/MessageModal";
|
||||||
import { toastService } from "~/components/ui/Toast";
|
import { toastService } from "~/components/ui/Toast";
|
||||||
import {
|
import {
|
||||||
|
buildUploadErrorDetails,
|
||||||
getTodayDocuments,
|
getTodayDocuments,
|
||||||
getDocumentTypes,
|
getDocumentTypes,
|
||||||
getDocumentsStatus,
|
getDocumentsStatus,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
checkDocumentDuplicate,
|
checkDocumentDuplicate,
|
||||||
type Document,
|
type Document,
|
||||||
type DocumentType,
|
type DocumentType,
|
||||||
|
type UploadErrorDetails,
|
||||||
type UploadResult,
|
type UploadResult,
|
||||||
DocumentStatus
|
DocumentStatus
|
||||||
} from "~/api/files/files-upload";
|
} from "~/api/files/files-upload";
|
||||||
@@ -119,6 +121,11 @@ export interface UploadedFile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadRequestError = Error & {
|
||||||
|
status?: number;
|
||||||
|
payload?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
// 修改文件上传函数部分,解决类型问题
|
// 修改文件上传函数部分,解决类型问题
|
||||||
async function handleFileUpload(
|
async function handleFileUpload(
|
||||||
binaryData: ArrayBuffer,
|
binaryData: ArrayBuffer,
|
||||||
@@ -147,7 +154,14 @@ async function handleFileUpload(
|
|||||||
if ("error" in response || !response.data) {
|
if ("error" in response || !response.data) {
|
||||||
const errMsg = "error" in response ? response.error : "上传响应为空";
|
const errMsg = "error" in response ? response.error : "上传响应为空";
|
||||||
console.error("上传失败:", errMsg);
|
console.error("上传失败:", errMsg);
|
||||||
throw new Error(errMsg || "上传失败");
|
const uploadError = new Error(errMsg || "上传失败") as UploadRequestError;
|
||||||
|
if ("status" in response) {
|
||||||
|
uploadError.status = response.status;
|
||||||
|
}
|
||||||
|
if ("payload" in response) {
|
||||||
|
uploadError.payload = response.payload;
|
||||||
|
}
|
||||||
|
throw uploadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -369,41 +383,123 @@ export default function FilesUpload() {
|
|||||||
// 队列文件状态
|
// 队列文件状态
|
||||||
const [queueFiles, setQueueFiles] = useState<Document[]>([]);
|
const [queueFiles, setQueueFiles] = useState<Document[]>([]);
|
||||||
const [documentTypesState, setDocumentTypesState] = useState<DocumentType[]>([]);
|
const [documentTypesState, setDocumentTypesState] = useState<DocumentType[]>([]);
|
||||||
|
const [uploadErrorDetails, setUploadErrorDetails] = useState<UploadErrorDetails | null>(null);
|
||||||
|
|
||||||
// 全局队列状态(用于显示排队统计)
|
// 全局队列状态(用于显示排队统计)
|
||||||
const [globalQueueStatus, setGlobalQueueStatus] = useState<QueueStatus | null>(null);
|
const [globalQueueStatus, setGlobalQueueStatus] = useState<QueueStatus | null>(null);
|
||||||
|
|
||||||
|
const getSelectedDocumentType = () =>
|
||||||
|
documentTypesState.find(type => type.id.toString() === fileType) ||
|
||||||
|
loaderData.documentTypes.find(type => type.id.toString() === fileType) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const clearUploadErrorDetails = () => {
|
||||||
|
setUploadErrorDetails(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showFriendlyUploadError = (
|
||||||
|
error: unknown,
|
||||||
|
options?: {
|
||||||
|
titlePrefix?: string;
|
||||||
|
resetAfterClose?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const uploadError = error instanceof Error ? (error as UploadRequestError) : null;
|
||||||
|
const rawMessage = uploadError?.message || (typeof error === 'string' ? error : '未知错误');
|
||||||
|
const details = buildUploadErrorDetails(rawMessage, {
|
||||||
|
documentType: getSelectedDocumentType(),
|
||||||
|
status: uploadError?.status,
|
||||||
|
payload: (uploadError?.payload as Record<string, unknown> | null | undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadErrorDetails(details);
|
||||||
|
|
||||||
|
const detailPreview = details.detailLines.slice(0, 2).join('\n');
|
||||||
|
const actionPreview = details.actionLines[0] ? `\n\n建议:${details.actionLines[0]}` : '';
|
||||||
|
const modalMessage = `${details.summary}${detailPreview ? `\n\n${detailPreview}` : ''}${actionPreview}`;
|
||||||
|
|
||||||
|
messageService.error(modalMessage, {
|
||||||
|
title: options?.titlePrefix ? `${options.titlePrefix} - ${details.title}` : details.title,
|
||||||
|
confirmText: '确定',
|
||||||
|
cancelText: '',
|
||||||
|
onConfirm: options?.resetAfterClose ? () => resetUpload() : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 在组件挂载时从 sessionStorage 获取 documentTypeIds
|
// 在组件挂载时从 sessionStorage 获取 documentTypeIds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
let cancelled = false;
|
||||||
// 在客户端环境中执行
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
|
||||||
const typeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
|
||||||
setDocumentTypeIds(typeIds);
|
|
||||||
// 根据 documentTypeIds 过滤文档类型和文档列表
|
|
||||||
filterDocumentTypes(typeIds, loaderData.documentTypes);
|
|
||||||
filterDocuments(typeIds);
|
|
||||||
|
|
||||||
// 如果包含合同类型(ID=1),自动选择合同文档类型
|
try {
|
||||||
if (typeIds && typeIds.includes(1)) {
|
if (typeof window === 'undefined') {
|
||||||
setIsContractType(true);
|
return;
|
||||||
// 查找ID为1的合同文档类型
|
}
|
||||||
const contractType = loaderData.documentTypes.find(type => type.id === 1);
|
|
||||||
if (contractType) {
|
const bootstrapUploadScope = async () => {
|
||||||
setFileType(contractType.id.toString());
|
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||||
// 清除可能存在的文件类型错误
|
const cachedTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||||
setFileTypeError(null);
|
const selectedModuleIdStr = sessionStorage.getItem('selectedModuleId');
|
||||||
|
const nextSelectedModuleId = selectedModuleIdStr ? Number(selectedModuleIdStr) : null;
|
||||||
|
const normalizedModuleId = Number.isFinite(nextSelectedModuleId) && (nextSelectedModuleId || 0) > 0
|
||||||
|
? nextSelectedModuleId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scopedTypes = loaderData.documentTypes;
|
||||||
|
let effectiveTypeIds = cachedTypeIds;
|
||||||
|
|
||||||
|
// 客户端可读取 sessionStorage,因此这里重新按入口模块拉一次,避免 SSR 阶段拿到全量类型后继续走旧缓存。
|
||||||
|
if (normalizedModuleId) {
|
||||||
|
const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined);
|
||||||
|
if (!cancelled && !scopedTypesResponse.error && scopedTypesResponse.data) {
|
||||||
|
scopedTypes = scopedTypesResponse.data;
|
||||||
|
effectiveTypeIds = scopedTypes.map(type => type.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocumentTypeIds(effectiveTypeIds);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsContractType(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void bootstrapUploadScope().catch(error => {
|
||||||
|
console.error('初始化上传页入口模块作用域失败:', error);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
|
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [loaderData]);
|
}, [loaderData]);
|
||||||
|
|
||||||
// 过滤文档类型列表
|
// 过滤文档类型列表
|
||||||
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[]) => {
|
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[], selectedModuleId?: number | null) => {
|
||||||
|
if (selectedModuleId) {
|
||||||
|
// 已通过 entry_module_id 从后端取过当前入口模块的文档类型,前端不再二次用旧缓存裁剪
|
||||||
|
setDocumentTypesState(types);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!documentTypeIds || documentTypeIds.length === 0) {
|
if (!documentTypeIds || documentTypeIds.length === 0) {
|
||||||
// 如果没有特定的 documentTypeIds,使用原始数据
|
// 如果没有特定的 documentTypeIds,使用原始数据
|
||||||
setDocumentTypesState(types);
|
setDocumentTypesState(types);
|
||||||
@@ -439,7 +535,7 @@ export default function FilesUpload() {
|
|||||||
setQueueFiles(loaderData.documents);
|
setQueueFiles(loaderData.documents);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const documents = response.data || [];
|
const documents = (response.data || []).filter(doc => documentTypeIds.includes(doc.type_id));
|
||||||
console.log('过滤文档列表成功:', documents);
|
console.log('过滤文档列表成功:', documents);
|
||||||
setQueueFiles(documents);
|
setQueueFiles(documents);
|
||||||
|
|
||||||
@@ -591,11 +687,6 @@ export default function FilesUpload() {
|
|||||||
try {
|
try {
|
||||||
// console.log('开始检查队列状态,当前队列文件:', files);
|
// console.log('开始检查队列状态,当前队列文件:', files);
|
||||||
|
|
||||||
// 直接从sessionStorage读取documentTypeIds,避免异步状态更新问题
|
|
||||||
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
|
|
||||||
const currentDocumentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
|
||||||
// console.log('从sessionStorage读取的documentTypeIds:', currentDocumentTypeIds);
|
|
||||||
|
|
||||||
// 获取所有未完成的文档
|
// 获取所有未完成的文档
|
||||||
const incompleteFiles = files.filter(file =>
|
const incompleteFiles = files.filter(file =>
|
||||||
file.status !== DocumentStatus.PROCESSED && file.id
|
file.status !== DocumentStatus.PROCESSED && file.id
|
||||||
@@ -609,7 +700,6 @@ export default function FilesUpload() {
|
|||||||
let statusResponse;
|
let statusResponse;
|
||||||
|
|
||||||
// 如果是合同类型(ID=1),需要分类处理
|
// 如果是合同类型(ID=1),需要分类处理
|
||||||
// console.log('当前documentTypeIds:', currentDocumentTypeIds);
|
|
||||||
// if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) {
|
// if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) {
|
||||||
// // 分类文档ID
|
// // 分类文档ID
|
||||||
// const mainDocumentIds: number[] = [];
|
// const mainDocumentIds: number[] = [];
|
||||||
@@ -666,6 +756,7 @@ export default function FilesUpload() {
|
|||||||
// 处理文件选择
|
// 处理文件选择
|
||||||
const handleFilesSelected = (files: FileList) => {
|
const handleFilesSelected = (files: FileList) => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
|
clearUploadErrorDetails();
|
||||||
// 验证文件类型,支持PDF和Word文件
|
// 验证文件类型,支持PDF和Word文件
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
let hasInvalidFiles = false;
|
let hasInvalidFiles = false;
|
||||||
@@ -708,6 +799,7 @@ export default function FilesUpload() {
|
|||||||
// 处理文件类型变化
|
// 处理文件类型变化
|
||||||
const handleFileTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleFileTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
|
clearUploadErrorDetails();
|
||||||
// 确保只有选择了有效的文件类型才进行设置
|
// 确保只有选择了有效的文件类型才进行设置
|
||||||
if (value) {
|
if (value) {
|
||||||
// console.log('【调试-handleFileTypeChange】文件类型变更为:', value);
|
// console.log('【调试-handleFileTypeChange】文件类型变更为:', value);
|
||||||
@@ -1102,6 +1194,7 @@ export default function FilesUpload() {
|
|||||||
// 合同专用:首传即合并的上传链路
|
// 合同专用:首传即合并的上传链路
|
||||||
const startContractUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
|
const startContractUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
|
||||||
try {
|
try {
|
||||||
|
clearUploadErrorDetails();
|
||||||
// 只允许一个主文件
|
// 只允许一个主文件
|
||||||
const mainFile = mainFiles[0];
|
const mainFile = mainFiles[0];
|
||||||
|
|
||||||
@@ -1220,7 +1313,6 @@ export default function FilesUpload() {
|
|||||||
await filterDocuments(documentTypeIds);
|
await filterDocuments(documentTypeIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('合同首传上传失败:', error);
|
console.error('合同首传上传失败:', error);
|
||||||
messageService.error(`合同上传失败:${error instanceof Error ? error.message : '未知错误'}`);
|
|
||||||
if (uploadProgressIntervalRef.current) {
|
if (uploadProgressIntervalRef.current) {
|
||||||
clearInterval(uploadProgressIntervalRef.current);
|
clearInterval(uploadProgressIntervalRef.current);
|
||||||
uploadProgressIntervalRef.current = null;
|
uploadProgressIntervalRef.current = null;
|
||||||
@@ -1230,6 +1322,7 @@ export default function FilesUpload() {
|
|||||||
setContractTemplateFiles([]);
|
setContractTemplateFiles([]);
|
||||||
console.log('【合同上传失败】已清空合同模板文件缓存');
|
console.log('【合同上传失败】已清空合同模板文件缓存');
|
||||||
|
|
||||||
|
showFriendlyUploadError(error, { titlePrefix: '合同上传失败' });
|
||||||
resetUpload();
|
resetUpload();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1237,6 +1330,7 @@ export default function FilesUpload() {
|
|||||||
// 检查并准备上传
|
// 检查并准备上传
|
||||||
const checkAndPrepareUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
|
const checkAndPrepareUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
|
||||||
try {
|
try {
|
||||||
|
clearUploadErrorDetails();
|
||||||
// console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', {
|
// console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', {
|
||||||
// mainFilesCount: mainFiles.length,
|
// mainFilesCount: mainFiles.length,
|
||||||
// attachmentFilesCount: attachmentFiles.length,
|
// attachmentFilesCount: attachmentFiles.length,
|
||||||
@@ -1362,6 +1456,7 @@ export default function FilesUpload() {
|
|||||||
// 开始上传文件
|
// 开始上传文件
|
||||||
const startUpload = async (files: File[]) => {
|
const startUpload = async (files: File[]) => {
|
||||||
try {
|
try {
|
||||||
|
clearUploadErrorDetails();
|
||||||
console.log('【调试-startUpload】开始上传过程,文件数量:', files.length);
|
console.log('【调试-startUpload】开始上传过程,文件数量:', files.length);
|
||||||
|
|
||||||
// 检查组件是否已卸载
|
// 检查组件是否已卸载
|
||||||
@@ -1630,15 +1725,7 @@ export default function FilesUpload() {
|
|||||||
clearInterval(uploadProgressIntervalRef.current);
|
clearInterval(uploadProgressIntervalRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示错误提示
|
showFriendlyUploadError(error, { titlePrefix: '文件上传失败' });
|
||||||
messageService.error(`文件上传失败:${error instanceof Error ? error.message : '未知错误'}`, {
|
|
||||||
title: '文件上传失败',
|
|
||||||
confirmText: '确定',
|
|
||||||
cancelText: '',
|
|
||||||
onConfirm: () => {
|
|
||||||
resetUpload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
resetUpload();
|
resetUpload();
|
||||||
|
|
||||||
// 抛出错误,让React错误边界捕获并显示
|
// 抛出错误,让React错误边界捕获并显示
|
||||||
@@ -2331,6 +2418,50 @@ export default function FilesUpload() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{uploadErrorDetails && (
|
||||||
|
<Card className="mb-4 upload-error-card">
|
||||||
|
<div className="upload-error-banner">
|
||||||
|
<div className="upload-error-banner__header">
|
||||||
|
<div className="upload-error-banner__title-wrap">
|
||||||
|
<i className="ri-error-warning-line upload-error-banner__icon"></i>
|
||||||
|
<div>
|
||||||
|
<div className="upload-error-banner__title">{uploadErrorDetails.title}</div>
|
||||||
|
<p className="upload-error-banner__summary">{uploadErrorDetails.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="upload-error-banner__dismiss"
|
||||||
|
onClick={clearUploadErrorDetails}
|
||||||
|
aria-label="关闭上传失败提示"
|
||||||
|
>
|
||||||
|
<i className="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="upload-error-banner__content">
|
||||||
|
<div>
|
||||||
|
<div className="upload-error-banner__label">定位信息</div>
|
||||||
|
<ul className="upload-error-banner__list">
|
||||||
|
{uploadErrorDetails.detailLines.map((line, index) => (
|
||||||
|
<li key={`detail-${index}`}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="upload-error-banner__label">建议处理</div>
|
||||||
|
<ol className="upload-error-banner__list upload-error-banner__list--ordered">
|
||||||
|
{uploadErrorDetails.actionLines.map((line, index) => (
|
||||||
|
<li key={`action-${index}`}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 文件上传区域 */}
|
{/* 文件上传区域 */}
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
{/* 自定义标题栏 */}
|
{/* 自定义标题栏 */}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { Card } from "~/components/ui/Card";
|
|||||||
import { Button } from "~/components/ui/Button";
|
import { Button } from "~/components/ui/Button";
|
||||||
import { Modal } from "~/components/ui/Modal";
|
import { Modal } from "~/components/ui/Modal";
|
||||||
import { toastService } from "~/components/ui/Toast";
|
import { toastService } from "~/components/ui/Toast";
|
||||||
|
import { usePermission } from "~/hooks/usePermission";
|
||||||
import {
|
import {
|
||||||
getRoles,
|
getRoles,
|
||||||
getAllRoutes,
|
getAllRoutes,
|
||||||
getRoleRoutePermissions,
|
|
||||||
updateRoleRoutePermissions,
|
updateRoleRoutePermissions,
|
||||||
getRoleRoutesWithPermissions,
|
getRoleRoutesWithPermissions,
|
||||||
saveRoleApiPermissions,
|
saveRoleAccess,
|
||||||
getRolePermissions,
|
getRolePermissions,
|
||||||
getRoleUsers,
|
getRoleUsers,
|
||||||
getUsersWithRoles,
|
getUsersWithRoles,
|
||||||
@@ -938,6 +938,7 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
|
|||||||
|
|
||||||
// 主组件
|
// 主组件
|
||||||
export default function RolePermissions() {
|
export default function RolePermissions() {
|
||||||
|
const { hasPermission, userRole, userArea } = usePermission();
|
||||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||||
const [routes, setRoutes] = useState<RouteInfo[]>([]);
|
const [routes, setRoutes] = useState<RouteInfo[]>([]);
|
||||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||||
@@ -946,9 +947,7 @@ export default function RolePermissions() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// v3.3: 检查当前用户角色和地区
|
// v3.3: 检查当前用户角色和地区
|
||||||
const [currentUserRole, setCurrentUserRole] = useState('');
|
|
||||||
const [currentUserArea, setCurrentUserArea] = useState('');
|
const [currentUserArea, setCurrentUserArea] = useState('');
|
||||||
const [canEdit, setCanEdit] = useState(false);
|
|
||||||
const [isCityAdmin, setIsCityAdmin] = useState(false);
|
const [isCityAdmin, setIsCityAdmin] = useState(false);
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
@@ -999,10 +998,23 @@ export default function RolePermissions() {
|
|||||||
// v3.8: 路由ID到路由信息的映射(用于显示通用权限关联的路由名称)
|
// v3.8: 路由ID到路由信息的映射(用于显示通用权限关联的路由名称)
|
||||||
const [routeIdToInfoMap, setRouteIdToInfoMap] = useState<Map<number, { title: string; path: string }>>(new Map());
|
const [routeIdToInfoMap, setRouteIdToInfoMap] = useState<Map<number, { title: string; path: string }>>(new Map());
|
||||||
|
|
||||||
|
const canCreateRole = hasPermission('rbac:roles:create');
|
||||||
|
const canUpdateRole = hasPermission('rbac:roles:update');
|
||||||
|
const canDeleteRole = hasPermission('rbac:roles:delete');
|
||||||
|
const canAssignUsers = hasPermission('rbac:user_roles:write');
|
||||||
|
const canSaveRoutePermissions = hasPermission('rbac:role_routes:write');
|
||||||
|
const canSaveApiPermissions = hasPermission('rbac:role_permissions:write');
|
||||||
|
const canSavePermissions = canSaveRoutePermissions && canSaveApiPermissions;
|
||||||
|
|
||||||
// 加载初始数据
|
// 加载初始数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, [canAssignUsers, canCreateRole, canDeleteRole, canSaveApiPermissions, canSaveRoutePermissions, canUpdateRole, userArea, userRole]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentUserArea(userArea || '');
|
||||||
|
setIsCityAdmin(userRole === 'admin');
|
||||||
|
}, [userArea, userRole]);
|
||||||
|
|
||||||
// 删除确认倒计时
|
// 删除确认倒计时
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1018,31 +1030,16 @@ export default function RolePermissions() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// v3.3: 检查当前用户角色和地区
|
console.log('🔑 [RolePermissions] 当前用户权限:', {
|
||||||
if (typeof window !== 'undefined') {
|
role: userRole,
|
||||||
const userInfoStr = localStorage.getItem('user_info');
|
area: userArea,
|
||||||
if (userInfoStr) {
|
canCreateRole,
|
||||||
try {
|
canUpdateRole,
|
||||||
const userInfo = JSON.parse(userInfoStr);
|
canDeleteRole,
|
||||||
const userRole = userInfo.user_role || '';
|
canAssignUsers,
|
||||||
const userArea = userInfo.area || ''; // v3.3: 使用 area 字段进行地区隔离
|
canSaveRoutePermissions,
|
||||||
|
canSaveApiPermissions
|
||||||
setCurrentUserRole(userRole);
|
});
|
||||||
setCurrentUserArea(userArea);
|
|
||||||
setCanEdit((userRole === 'provincial_admin' || userRole === 'admin'));
|
|
||||||
setIsCityAdmin(userRole === 'admin');
|
|
||||||
|
|
||||||
console.log('🔑 [RolePermissions v3.3] 当前用户信息:', {
|
|
||||||
role: userRole,
|
|
||||||
area: userArea,
|
|
||||||
canEdit: (userRole === 'provincial_admin' || userRole === 'admin'),
|
|
||||||
isCityAdmin: userRole === 'admin'
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ [RolePermissions] 解析用户信息失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [rolesData, routesData, usersData] = await Promise.all([
|
const [rolesData, routesData, usersData] = await Promise.all([
|
||||||
getRoles(),
|
getRoles(),
|
||||||
@@ -1472,12 +1469,21 @@ export default function RolePermissions() {
|
|||||||
|
|
||||||
// 编辑角色
|
// 编辑角色
|
||||||
const handleEditRole = (role: RoleInfo) => {
|
const handleEditRole = (role: RoleInfo) => {
|
||||||
|
if (!canUpdateRole) {
|
||||||
|
toastService.error('权限不足:当前账号不能编辑角色');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRoleToEdit(role);
|
setRoleToEdit(role);
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除角色 - 显示确认Modal
|
// 删除角色 - 显示确认Modal
|
||||||
const handleDeleteRole = async (role: RoleInfo) => {
|
const handleDeleteRole = async (role: RoleInfo) => {
|
||||||
|
if (!canDeleteRole) {
|
||||||
|
toastService.error('权限不足:当前账号不能删除角色');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 系统角色禁止删除
|
// 系统角色禁止删除
|
||||||
if (role.is_system_role) {
|
if (role.is_system_role) {
|
||||||
toastService.error('系统角色不能删除');
|
toastService.error('系统角色不能删除');
|
||||||
@@ -1538,6 +1544,10 @@ export default function RolePermissions() {
|
|||||||
// 移除用户角色 - 显示确认Modal
|
// 移除用户角色 - 显示确认Modal
|
||||||
const handleRemoveUserRole = (user: UserInfo) => {
|
const handleRemoveUserRole = (user: UserInfo) => {
|
||||||
if (!selectedRole) return;
|
if (!selectedRole) return;
|
||||||
|
if (!canAssignUsers) {
|
||||||
|
toastService.error('权限不足:当前账号不能移除用户角色');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 打开确认删除Modal
|
// 打开确认删除Modal
|
||||||
setDeleteTarget({ type: 'userRole', role: selectedRole, user });
|
setDeleteTarget({ type: 'userRole', role: selectedRole, user });
|
||||||
@@ -1570,66 +1580,41 @@ export default function RolePermissions() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存权限 - 省级管理员和地区管理员可操作
|
// 保存权限:路由与 API 权限联合提交
|
||||||
// v3.5: 增加事务性操作和回滚机制
|
// v3.5: 增加事务性操作和回滚机制
|
||||||
const handleSavePermissions = async () => {
|
const handleSavePermissions = async () => {
|
||||||
if (!selectedRole) return;
|
if (!selectedRole) return;
|
||||||
|
|
||||||
// 前置权限检查(省级管理员和地区管理员)
|
// 菜单和 API 权限会联合保存,必须同时具备两个写权限
|
||||||
if (!canEdit) {
|
if (!canSavePermissions) {
|
||||||
toastService.error('权限不足:仅省级管理员和地区管理员可以修改角色路由权限');
|
toastService.error('权限不足:当前账号缺少菜单保存或接口权限保存能力');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavingPermissions(true);
|
setSavingPermissions(true);
|
||||||
|
|
||||||
// v3.5: 开始事务性操作,保存原始状态以便回滚
|
|
||||||
const originalRouteIds = [...selectedRouteIds];
|
|
||||||
const originalPermissionIds = [...selectedPermissionIds];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 保存路由权限
|
const scopedPermissionIds = originalAllPermissions.map(permission => permission.id);
|
||||||
const routeResult = await updateRoleRoutePermissions(selectedRole.id, selectedRouteIds);
|
const result = await saveRoleAccess(
|
||||||
|
selectedRole.id,
|
||||||
|
selectedRouteIds,
|
||||||
|
selectedPermissionIds,
|
||||||
|
scopedPermissionIds
|
||||||
|
);
|
||||||
|
|
||||||
// v3.3: 处理权限不足错误
|
if (!result.success) {
|
||||||
if (!routeResult.success) {
|
if (result.code === 4003) {
|
||||||
if (routeResult.code === 4003) {
|
toastService.error('权限不足:当前账号缺少菜单保存或接口权限保存能力');
|
||||||
toastService.error('权限不足:仅省级管理员和地区管理员可以修改角色路由权限');
|
|
||||||
} else {
|
} else {
|
||||||
toastService.error(routeResult.message);
|
toastService.error(result.message);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3.5: 只有在路由权限保存成功后才保存API权限
|
toastService.success(result.message);
|
||||||
// 2. 保存API权限(如果有选中的权限)
|
|
||||||
let permResult;
|
|
||||||
if (selectedPermissionIds.length > 0) {
|
|
||||||
permResult = await saveRoleApiPermissions(selectedRole.id, selectedPermissionIds);
|
|
||||||
} else {
|
|
||||||
// 没有选中API权限时,清空该角色的所有API权限
|
|
||||||
permResult = await saveRoleApiPermissions(selectedRole.id, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// v3.5: 处理API权限保存失败的情况
|
|
||||||
if (!permResult.success) {
|
|
||||||
console.error('API权限保存失败,正在回滚路由权限...');
|
|
||||||
// 回滚路由权限到原始状态
|
|
||||||
await updateRoleRoutePermissions(selectedRole.id, originalRouteIds);
|
|
||||||
toastService.error('权限保存失败,已自动回滚到原始状态');
|
|
||||||
// 恢复前端状态
|
|
||||||
setSelectedRouteIds(originalRouteIds);
|
|
||||||
setSelectedPermissionIds(originalPermissionIds);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toastService.success(`路由权限:${routeResult.message} | API权限:${permResult.message}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("保存权限失败:", error);
|
console.error("保存权限失败:", error);
|
||||||
toastService.error("保存权限失败,已自动回滚到原始状态");
|
toastService.error("保存权限失败");
|
||||||
// 发生异常时回滚到原始状态
|
|
||||||
setSelectedRouteIds(originalRouteIds);
|
|
||||||
setSelectedPermissionIds(originalPermissionIds);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPermissions(false);
|
setSavingPermissions(false);
|
||||||
}
|
}
|
||||||
@@ -1742,7 +1727,7 @@ export default function RolePermissions() {
|
|||||||
checked={selectedPermissionIds.includes(permission.id)}
|
checked={selectedPermissionIds.includes(permission.id)}
|
||||||
onChange={(e) => handleTogglePermission(permission, e.target.checked)}
|
onChange={(e) => handleTogglePermission(permission, e.target.checked)}
|
||||||
style={{ margin: '3px 0 0 0', flexShrink: 0 }}
|
style={{ margin: '3px 0 0 0', flexShrink: 0 }}
|
||||||
disabled={!canEdit}
|
disabled={!canSavePermissions}
|
||||||
/>
|
/>
|
||||||
{isShared && (
|
{isShared && (
|
||||||
<span
|
<span
|
||||||
@@ -1832,7 +1817,7 @@ export default function RolePermissions() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="route-checkbox"
|
className="route-checkbox"
|
||||||
disabled={!canEdit}
|
disabled={!canSavePermissions}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`route-${route.id}`} className="route-label">
|
<label htmlFor={`route-${route.id}`} className="route-label">
|
||||||
{route.icon && <i className={`${route.icon} route-icon`}></i>}
|
{route.icon && <i className={`${route.icon} route-icon`}></i>}
|
||||||
@@ -1878,7 +1863,7 @@ export default function RolePermissions() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="route-checkbox"
|
className="route-checkbox"
|
||||||
disabled={!canEdit}
|
disabled={!canSavePermissions}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`route-${route.id}`} className="route-label">
|
<label htmlFor={`route-${route.id}`} className="route-label">
|
||||||
{route.icon && <i className={`${route.icon} route-icon`}></i>}
|
{route.icon && <i className={`${route.icon} route-icon`}></i>}
|
||||||
@@ -1964,7 +1949,7 @@ export default function RolePermissions() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon="ri-add-line"
|
icon="ri-add-line"
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
disabled={!canEdit}
|
disabled={!canCreateRole}
|
||||||
>
|
>
|
||||||
新建角色
|
新建角色
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2010,6 +1995,7 @@ export default function RolePermissions() {
|
|||||||
handleEditRole(role);
|
handleEditRole(role);
|
||||||
}}
|
}}
|
||||||
title="编辑"
|
title="编辑"
|
||||||
|
disabled={!canUpdateRole}
|
||||||
>
|
>
|
||||||
<i className="ri-edit-line"></i>
|
<i className="ri-edit-line"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -2020,6 +2006,7 @@ export default function RolePermissions() {
|
|||||||
handleDeleteRole(role);
|
handleDeleteRole(role);
|
||||||
}}
|
}}
|
||||||
title="删除"
|
title="删除"
|
||||||
|
disabled={!canDeleteRole}
|
||||||
>
|
>
|
||||||
<i className="ri-delete-bin-line"></i>
|
<i className="ri-delete-bin-line"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -2059,11 +2046,11 @@ export default function RolePermissions() {
|
|||||||
<div className="permissions-tab">
|
<div className="permissions-tab">
|
||||||
{/* v3.8: 固定头部区域 */}
|
{/* v3.8: 固定头部区域 */}
|
||||||
<div className="permissions-tab-header">
|
<div className="permissions-tab-header">
|
||||||
{/* 权限提示(省级管理员和地区管理员可修改) */}
|
{/* 权限提示:当前账号缺少联合保存所需写权限时仅可查看 */}
|
||||||
{!canEdit && (
|
{!canSavePermissions && (
|
||||||
<div className="form-notice warning" style={{ marginBottom: '12px' }}>
|
<div className="form-notice warning" style={{ marginBottom: '12px' }}>
|
||||||
<i className="ri-information-line"></i>
|
<i className="ri-information-line"></i>
|
||||||
<span>您当前为只读模式,仅省级管理员和地区管理员可以修改角色路由权限</span>
|
<span>您当前为只读模式,缺少菜单保存或接口权限保存能力,无法提交本页改动</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="permissions-header">
|
<div className="permissions-header">
|
||||||
@@ -2098,7 +2085,7 @@ export default function RolePermissions() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={savingPermissions ? "ri-loader-4-line spin" : "ri-save-line"}
|
icon={savingPermissions ? "ri-loader-4-line spin" : "ri-save-line"}
|
||||||
onClick={handleSavePermissions}
|
onClick={handleSavePermissions}
|
||||||
disabled={!canEdit || savingPermissions}
|
disabled={!canSavePermissions || savingPermissions}
|
||||||
>
|
>
|
||||||
{savingPermissions ? '保存中...' : '保存菜单与接口权限'}
|
{savingPermissions ? '保存中...' : '保存菜单与接口权限'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2162,6 +2149,7 @@ export default function RolePermissions() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon="ri-user-add-line"
|
icon="ri-user-add-line"
|
||||||
onClick={() => setShowAssignUserModal(true)}
|
onClick={() => setShowAssignUserModal(true)}
|
||||||
|
disabled={!canAssignUsers}
|
||||||
>
|
>
|
||||||
分配用户
|
分配用户
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2214,6 +2202,7 @@ export default function RolePermissions() {
|
|||||||
className="btn-icon text-error"
|
className="btn-icon text-error"
|
||||||
onClick={() => handleRemoveUserRole(user)}
|
onClick={() => handleRemoveUserRole(user)}
|
||||||
title="移除角色"
|
title="移除角色"
|
||||||
|
disabled={!canAssignUsers}
|
||||||
>
|
>
|
||||||
<i className="ri-user-unfollow-line"></i>
|
<i className="ri-user-unfollow-line"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -2330,7 +2319,7 @@ export default function RolePermissions() {
|
|||||||
marginTop: '24px'
|
marginTop: '24px'
|
||||||
}}>
|
}}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
type="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -2339,7 +2328,7 @@ export default function RolePermissions() {
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
type="danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (deleteTarget?.type === 'role') {
|
if (deleteTarget?.type === 'role') {
|
||||||
confirmDeleteRole();
|
confirmDeleteRole();
|
||||||
@@ -2378,7 +2367,7 @@ export default function RolePermissions() {
|
|||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
type="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowPermissionWarning(false);
|
setShowPermissionWarning(false);
|
||||||
setPendingRouteChange(null);
|
setPendingRouteChange(null);
|
||||||
@@ -2387,7 +2376,7 @@ export default function RolePermissions() {
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
type="danger"
|
||||||
onClick={confirmRemovePermissionRoute}
|
onClick={confirmRemovePermissionRoute}
|
||||||
>
|
>
|
||||||
确认取消勾选
|
确认取消勾选
|
||||||
|
|||||||
@@ -10,6 +10,46 @@
|
|||||||
@apply text-xl font-medium;
|
@apply text-xl font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-types-page .empty-state {
|
||||||
|
@apply flex flex-col items-center justify-center py-12 text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .empty-state i {
|
||||||
|
@apply text-3xl mb-3 text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .data-table {
|
||||||
|
@apply w-full border-collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .data-table thead th {
|
||||||
|
@apply text-left text-sm font-medium text-gray-600 px-4 py-3 border-b border-gray-200 bg-gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .data-table tbody td {
|
||||||
|
@apply px-4 py-3 border-b border-gray-100 align-middle text-sm text-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .data-table tbody tr:hover {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .tag {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-amber-50 text-amber-700 border border-amber-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .status-badge {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .status-badge.enabled {
|
||||||
|
@apply bg-emerald-50 text-emerald-700 border-emerald-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .status-badge.disabled {
|
||||||
|
@apply bg-gray-100 text-gray-600 border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
.document-types-page .type-badge {
|
.document-types-page .type-badge {
|
||||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-primary-50 text-primary-600 mr-1 mb-1;
|
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-primary-50 text-primary-600 mr-1 mb-1;
|
||||||
}
|
}
|
||||||
@@ -30,6 +70,14 @@
|
|||||||
@apply mr-1;
|
@apply mr-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-types-page .row-actions {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-types-page .btn-icon {
|
||||||
|
@apply inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
.document-types-page .text-primary {
|
.document-types-page .text-primary {
|
||||||
@apply text-primary-600;
|
@apply text-primary-600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +1,538 @@
|
|||||||
.document-type-new-page {
|
.document-type-new-page {
|
||||||
@apply w-full;
|
@apply w-full p-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .page-header {
|
.document-type-new-page .page-header {
|
||||||
@apply flex justify-between items-center mb-4;
|
@apply mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .page-heading {
|
||||||
|
@apply max-w-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .page-kicker {
|
||||||
|
@apply inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold tracking-[0.14em];
|
||||||
|
border-color: rgba(0, 104, 74, 0.18);
|
||||||
|
background: rgba(0, 104, 74, 0.06);
|
||||||
|
color: #00684a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .page-title {
|
.document-type-new-page .page-title {
|
||||||
@apply text-xl font-medium;
|
@apply mt-4 flex items-center gap-3 text-[28px] font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .page-title i {
|
||||||
|
@apply inline-flex h-11 w-11 items-center justify-center rounded-lg text-xl text-white;
|
||||||
|
background: #00684a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .page-subtitle {
|
||||||
|
@apply mt-2 max-w-2xl text-sm leading-6 text-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .header-overview {
|
||||||
|
@apply mt-5 flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .header-badges {
|
||||||
|
@apply flex flex-wrap gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .status-pill {
|
||||||
|
@apply inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .status-pill.create {
|
||||||
|
border-color: rgba(0, 104, 74, 0.18);
|
||||||
|
background: rgba(0, 104, 74, 0.06);
|
||||||
|
color: #00684a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .status-pill.edit {
|
||||||
|
@apply border-blue-200 bg-blue-50 text-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .status-pill.linked {
|
||||||
|
@apply border-gray-200 bg-gray-50 text-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .status-pill.muted {
|
||||||
|
@apply border-amber-200 bg-amber-50 text-amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .hero-metrics {
|
||||||
|
@apply grid gap-3 md:grid-cols-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .hero-metric-card {
|
||||||
|
@apply rounded-lg border border-gray-200 bg-gray-50 p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .hero-metric-label {
|
||||||
|
@apply block text-xs font-semibold tracking-[0.12em] text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .hero-metric-card strong {
|
||||||
|
@apply mt-2 block text-2xl font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .hero-metric-card small {
|
||||||
|
@apply mt-1 block text-xs leading-5 text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .editor-shell {
|
||||||
|
@apply grid gap-6 xl:grid-cols-[minmax(0,1.65fr)_minmax(280px,0.75fr)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .editor-main-card,
|
||||||
|
.document-type-new-page .summary-card,
|
||||||
|
.document-type-new-page .selection-card,
|
||||||
|
.document-type-new-page .guide-card {
|
||||||
|
@apply overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .editor-main-card {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .doc-type-form {
|
||||||
|
@apply flex flex-col gap-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-section {
|
||||||
|
@apply border-b border-gray-100 px-6 py-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-section:last-of-type {
|
||||||
|
@apply border-b-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-heading {
|
||||||
|
@apply flex flex-col gap-3 md:flex-row md:items-end md:justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-kicker,
|
||||||
|
.document-type-new-page .summary-kicker {
|
||||||
|
@apply inline-flex rounded-full bg-gray-100 px-3 py-1 text-[11px] font-semibold tracking-[0.12em] text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-heading h3 {
|
||||||
|
@apply mt-3 text-xl font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-heading p {
|
||||||
|
@apply max-w-xl text-sm leading-6 text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-intro-card {
|
||||||
|
@apply mt-5 grid gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4 md:grid-cols-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-intro-item {
|
||||||
|
@apply flex gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-intro-item i {
|
||||||
|
@apply mt-0.5 text-lg;
|
||||||
|
color: #00684a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-intro-item strong {
|
||||||
|
@apply block text-sm font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .section-intro-item span {
|
||||||
|
@apply mt-1 block text-xs leading-5 text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-row.two-column {
|
||||||
|
@apply mt-5 grid gap-4 lg:grid-cols-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .form-group {
|
.document-type-new-page .form-group {
|
||||||
@apply mb-4;
|
@apply mb-0 mt-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .form-label {
|
.document-type-new-page .form-group label {
|
||||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
@apply mb-2 block text-sm font-medium text-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-group label.required::after {
|
||||||
|
content: " *";
|
||||||
|
@apply text-red-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .form-input,
|
.document-type-new-page .form-input,
|
||||||
.document-type-new-page .form-textarea,
|
.document-type-new-page .form-textarea,
|
||||||
.document-type-new-page .form-select {
|
.document-type-new-page .form-select {
|
||||||
@apply w-full rounded-md border border-gray-300 shadow-sm px-3 py-2 transition-all duration-300;
|
@apply w-full rounded-md border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-700 transition-all duration-200 placeholder:text-gray-400;
|
||||||
@apply focus:outline-none focus:border-[var(--primary-color)] focus:shadow-[0_0_0_2px_rgba(0,104,74,0.2)];
|
@apply focus:outline-none focus:ring-1;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-input:focus,
|
||||||
|
.document-type-new-page .form-textarea:focus,
|
||||||
|
.document-type-new-page .form-select:focus,
|
||||||
|
.document-type-new-page .rule-set-search:focus-within {
|
||||||
|
border-color: #00684a;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 104, 74, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-input:hover,
|
||||||
|
.document-type-new-page .form-textarea:hover,
|
||||||
|
.document-type-new-page .form-select:hover {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-input:disabled {
|
||||||
|
@apply cursor-not-allowed bg-gray-100 text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-input.error,
|
||||||
|
.document-type-new-page .form-textarea.error,
|
||||||
|
.document-type-new-page .form-select.error {
|
||||||
|
@apply border-red-300 bg-red-50/70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .form-textarea {
|
.document-type-new-page .form-textarea {
|
||||||
@apply resize-none;
|
@apply min-h-[120px] resize-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .form-tip {
|
.document-type-new-page .form-hint {
|
||||||
@apply text-xs text-gray-500 mt-1;
|
@apply mt-2 block text-xs leading-5 text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .input-error {
|
.document-type-new-page .form-error {
|
||||||
@apply border-red-500;
|
@apply mt-2 block text-xs font-medium text-red-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .error-message {
|
.document-type-new-page .binding-preview {
|
||||||
@apply text-sm text-red-600 mt-1;
|
@apply mt-5 rounded-lg border border-dashed p-4;
|
||||||
display: flex;
|
border-color: rgba(0, 104, 74, 0.25);
|
||||||
align-items: center;
|
background: rgba(0, 104, 74, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .document-type-new-page .error-message::before {
|
.document-type-new-page .binding-preview-label {
|
||||||
content: "⚠️";
|
@apply text-xs font-semibold tracking-[0.12em];
|
||||||
margin-right: 0.25rem;
|
color: #00684a;
|
||||||
} */
|
|
||||||
|
|
||||||
|
|
||||||
.document-type-new-page .general-error {
|
|
||||||
@apply flex items-center p-3 mb-4 bg-red-50 rounded-md text-red-600 text-sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .general-error i {
|
.document-type-new-page .binding-preview-value {
|
||||||
@apply mr-2;
|
@apply mt-3 flex items-start gap-3 text-sm leading-6 text-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .group-error {
|
.document-type-new-page .binding-preview-value i {
|
||||||
@apply border border-red-500 rounded-md;
|
@apply mt-1 text-base;
|
||||||
|
color: #00684a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 复选框样式 */
|
.document-type-new-page .rule-set-toolbar {
|
||||||
.document-type-new-page .checkbox-group {
|
@apply mt-5 flex flex-col gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3;
|
||||||
@apply flex flex-col gap-2 mt-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .checkbox-item {
|
.document-type-new-page .rule-set-toolbar-main {
|
||||||
@apply flex items-center p-2 border border-gray-200 rounded cursor-pointer transition-all;
|
@apply flex flex-col gap-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .checkbox-item:hover {
|
.document-type-new-page .rule-set-counter {
|
||||||
@apply border-[var(--primary-color)] bg-[rgba(0,104,74,0.15)];
|
@apply inline-flex items-center gap-2 text-sm font-semibold text-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .checkbox-item.checked {
|
.document-type-new-page .rule-set-counter i {
|
||||||
@apply border-[var(--primary-color)] bg-[rgba(0,104,74,0.25)];
|
color: #00684a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 父子级分组样式 */
|
.document-type-new-page .rule-set-search {
|
||||||
.document-type-new-page .parent-checkbox-item {
|
@apply flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2;
|
||||||
@apply bg-gray-50 font-medium;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .child-checkbox-item {
|
.document-type-new-page .rule-set-search i {
|
||||||
@apply ml-8 border-l-2 border-l-[var(--primary-color)];
|
@apply text-sm text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .expand-icon {
|
.document-type-new-page .rule-set-search input {
|
||||||
@apply w-6 h-6 flex items-center justify-center rounded hover:bg-[rgba(0,104,74,0.15)] mr-2 cursor-pointer;
|
@apply w-full border-0 bg-transparent p-0 text-sm text-gray-700 placeholder:text-gray-400 focus:outline-none focus:ring-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .group-badge {
|
.document-type-new-page .rule-set-warning {
|
||||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs ml-2;
|
@apply mt-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .parent-badge {
|
.document-type-new-page .rule-set-warning i {
|
||||||
@apply bg-[rgba(0,104,74,1)] text-white;
|
@apply mt-0.5 text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .child-badge {
|
.document-type-new-page .rule-set-warning strong {
|
||||||
@apply bg-[rgba(0,104,1,0.71)] text-white;
|
@apply block font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 添加checkbox-input样式,使用视觉上更美观的自定义复选框样式 */
|
.document-type-new-page .rule-set-warning span {
|
||||||
.document-type-new-page .checkbox-input {
|
@apply mt-1 block text-xs leading-5 text-amber-700;
|
||||||
@apply mr-2 h-4 w-4 text-primary-600 border-gray-300 rounded;
|
}
|
||||||
@apply focus:ring-primary-500 cursor-pointer;
|
|
||||||
|
.document-type-new-page .rule-set-checklist {
|
||||||
|
@apply mt-5 grid max-h-[520px] gap-3 overflow-y-auto pr-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-checklist::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-checklist::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-item {
|
||||||
|
@apply flex items-start gap-4 rounded-lg border border-gray-200 bg-white px-4 py-4 transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-item:hover {
|
||||||
|
border-color: rgba(0, 104, 74, 0.3);
|
||||||
|
background: rgba(0, 104, 74, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-item.checked {
|
||||||
|
border-color: rgba(0, 104, 74, 0.35);
|
||||||
|
background: rgba(0, 104, 74, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-item.unavailable {
|
||||||
|
@apply border-amber-200 bg-amber-50/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-checkbox-wrap {
|
||||||
|
@apply pt-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-checkbox-wrap input {
|
||||||
|
@apply h-4 w-4 cursor-pointer rounded border-gray-300;
|
||||||
accent-color: #00684a;
|
accent-color: #00684a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 复选框选中状态 */
|
.document-type-new-page .rule-set-content {
|
||||||
.document-type-new-page .checkbox-input:checked {
|
@apply min-w-0 flex-1;
|
||||||
background-color: #00684a;
|
|
||||||
border-color: #00684a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .checkbox-label {
|
.document-type-new-page .rule-set-topline {
|
||||||
@apply text-gray-700 font-normal cursor-pointer flex-1 flex items-center;
|
@apply flex flex-col gap-2 md:flex-row md:items-center md:justify-between;
|
||||||
@apply bg-transparent border-none p-0 text-left appearance-none;
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .checkbox-label:focus {
|
.document-type-new-page .rule-set-name {
|
||||||
@apply outline-none ring-2 ring-primary-300 rounded;
|
@apply text-sm font-semibold text-gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .checkbox-item.checked .checkbox-label {
|
.document-type-new-page .rule-set-status {
|
||||||
@apply text-[var(--primary-color)] font-medium;
|
@apply inline-flex w-fit rounded-full px-2.5 py-1 text-xs font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-type-new-page .radio-input {
|
.document-type-new-page .rule-set-status.enabled,
|
||||||
@apply mr-1 h-4 w-4 text-primary-600 border-gray-300 rounded;
|
.document-type-new-page .rule-set-status.active,
|
||||||
@apply focus:ring-primary-500 cursor-pointer;
|
.document-type-new-page .rule-set-status.published {
|
||||||
accent-color: #00684a;
|
@apply bg-emerald-100 text-emerald-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 更完整的自定义单选按钮样式(可选) */
|
.document-type-new-page .rule-set-status.disabled,
|
||||||
.document-type-new-page .radio-input:checked {
|
.document-type-new-page .rule-set-status.inactive {
|
||||||
background-color: #00684a;
|
@apply bg-gray-100 text-gray-500;
|
||||||
border-color: #00684a;
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-meta {
|
||||||
|
@apply mt-2 flex flex-wrap gap-2 text-xs text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-type,
|
||||||
|
.document-type-new-page .rule-set-id,
|
||||||
|
.document-type-new-page .rule-set-version-badge {
|
||||||
|
@apply rounded-full bg-gray-100 px-2.5 py-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-version-badge.ok {
|
||||||
|
color: #00684a;
|
||||||
|
background: rgba(0, 104, 74, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-version-badge.missing {
|
||||||
|
@apply bg-amber-100 text-amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-inline-warning {
|
||||||
|
@apply mt-3 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-inline-warning.soft {
|
||||||
|
@apply border-sky-200 bg-sky-50 text-sky-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-inline-warning i {
|
||||||
|
@apply mt-[2px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .empty-rule-set {
|
||||||
|
@apply flex flex-col items-center rounded-lg border border-dashed border-gray-200 bg-gray-50 px-4 py-10 text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .empty-rule-set.compact {
|
||||||
|
@apply py-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .empty-rule-set i {
|
||||||
|
@apply text-3xl text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .empty-rule-set p {
|
||||||
|
@apply mt-3 text-sm font-semibold text-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .empty-rule-set span {
|
||||||
|
@apply mt-2 max-w-sm text-xs leading-5 text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .form-actions {
|
||||||
|
@apply flex flex-col-reverse gap-3 px-6 py-5 sm:flex-row sm:justify-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .editor-sidebar {
|
||||||
|
@apply flex flex-col gap-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-card,
|
||||||
|
.document-type-new-page .selection-card,
|
||||||
|
.document-type-new-page .guide-card {
|
||||||
|
@apply p-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-card {
|
||||||
|
@apply xl:sticky xl:top-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-card-header {
|
||||||
|
@apply flex items-center justify-between gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-card-header h3 {
|
||||||
|
@apply text-lg font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-grid {
|
||||||
|
@apply mt-5 grid gap-3 sm:grid-cols-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-item {
|
||||||
|
@apply rounded-lg border border-gray-200 bg-gray-50 px-4 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-item.full {
|
||||||
|
@apply sm:col-span-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-label,
|
||||||
|
.document-type-new-page .selection-label {
|
||||||
|
@apply block text-xs font-medium tracking-[0.08em] text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-item strong,
|
||||||
|
.document-type-new-page .selection-item strong {
|
||||||
|
@apply mt-2 block break-words text-sm font-semibold leading-6 text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-progress {
|
||||||
|
@apply mt-5 rounded-lg border border-gray-200 bg-white p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-progress-topline {
|
||||||
|
@apply flex items-center justify-between text-sm font-medium text-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-progress-bar {
|
||||||
|
@apply mt-3 h-2 overflow-hidden rounded-full bg-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-progress-bar span {
|
||||||
|
@apply block h-full rounded-full transition-all duration-300;
|
||||||
|
background: #00684a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .selection-stack {
|
||||||
|
@apply mt-5 flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .selection-item {
|
||||||
|
@apply rounded-lg border border-gray-200 bg-gray-50 px-4 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .selection-item.danger {
|
||||||
|
@apply border-amber-200 bg-amber-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .selection-tags {
|
||||||
|
@apply mt-3 flex flex-wrap gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .selection-tag {
|
||||||
|
@apply inline-flex rounded-full px-3 py-1 text-xs font-medium;
|
||||||
|
color: #00684a;
|
||||||
|
background: rgba(0, 104, 74, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .selection-tag.warning {
|
||||||
|
@apply bg-amber-100 text-amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .selection-tag.muted {
|
||||||
|
@apply bg-gray-100 text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .guide-list {
|
||||||
|
@apply mt-5 flex list-none flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .guide-list li {
|
||||||
|
@apply relative rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 pl-10 text-sm leading-7 text-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .guide-list li::before {
|
||||||
|
content: "";
|
||||||
|
@apply absolute left-4 top-5 h-2.5 w-2.5 rounded-full;
|
||||||
|
background: #00684a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.document-type-new-page .summary-card {
|
||||||
|
@apply static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.document-type-new-page {
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .page-header,
|
||||||
|
.document-type-new-page .form-section,
|
||||||
|
.document-type-new-page .form-actions {
|
||||||
|
@apply px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .page-title {
|
||||||
|
@apply text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .page-title i {
|
||||||
|
@apply h-10 w-10 text-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .summary-card,
|
||||||
|
.document-type-new-page .selection-card,
|
||||||
|
.document-type-new-page .guide-card {
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-item {
|
||||||
|
@apply gap-3 px-3 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-new-page .rule-set-checklist {
|
||||||
|
@apply max-h-[420px];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,54 @@
|
|||||||
@apply border-[#00684a] shadow-[0_0_0_2px_rgba(0,104,74,0.2)] outline-none;
|
@apply border-[#00684a] shadow-[0_0_0_2px_rgba(0,104,74,0.2)] outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-card {
|
||||||
|
@apply border border-amber-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner {
|
||||||
|
@apply rounded-lg border border-amber-200 bg-amber-50 px-4 py-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__header {
|
||||||
|
@apply flex items-start justify-between gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__title-wrap {
|
||||||
|
@apply flex items-start gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__icon {
|
||||||
|
@apply text-2xl text-amber-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__title {
|
||||||
|
@apply text-base font-semibold text-amber-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__summary {
|
||||||
|
@apply mt-1 text-sm leading-6 text-amber-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__dismiss {
|
||||||
|
@apply inline-flex h-8 w-8 items-center justify-center rounded-full text-amber-700 transition-colors duration-200 hover:bg-amber-100 hover:text-amber-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__content {
|
||||||
|
@apply mt-4 grid gap-4 md:grid-cols-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__label {
|
||||||
|
@apply mb-2 text-sm font-medium text-amber-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__list {
|
||||||
|
@apply m-0 list-disc space-y-2 pl-5 text-sm leading-6 text-amber-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-page .upload-error-banner__list--ordered {
|
||||||
|
@apply list-decimal;
|
||||||
|
}
|
||||||
|
|
||||||
/* 上传区域样式 */
|
/* 上传区域样式 */
|
||||||
.file-upload-page .upload-area {
|
.file-upload-page .upload-area {
|
||||||
@apply border-2 border-dashed border-gray-300 rounded-lg p-10 text-center bg-gray-50 cursor-pointer transition-all duration-300;
|
@apply border-2 border-dashed border-gray-300 rounded-lg p-10 text-center bg-gray-50 cursor-pointer transition-all duration-300;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* 路由权限别名配置
|
||||||
|
*
|
||||||
|
* 目标:
|
||||||
|
* 1. 把“功能子页”统一映射到实际受控的父菜单/父页面
|
||||||
|
* 2. 让 root loader、usePermission、服务端鉴权复用同一套规则
|
||||||
|
* 3. 给测试脚本一个稳定的数据源,避免后续继续手工补漏
|
||||||
|
*
|
||||||
|
* 维护约束文档:
|
||||||
|
* - docs/route-alias-guidelines.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* source: string;
|
||||||
|
* target: string;
|
||||||
|
* note: string;
|
||||||
|
* examples: Array<{ input: string; output: string }>;
|
||||||
|
* }} RouteAliasEntry
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* group: string;
|
||||||
|
* note: string;
|
||||||
|
* entries: RouteAliasEntry[];
|
||||||
|
* }} RouteAliasGroup
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {RouteAliasGroup[]} */
|
||||||
|
export const permissionRouteAliasGroups = [
|
||||||
|
{
|
||||||
|
group: 'legacy-compat',
|
||||||
|
note: '兼容历史遗留路由,避免新旧页面并存时出现权限断层。',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
source: '^/reviewsTest(?=/|$)',
|
||||||
|
target: '/reviews',
|
||||||
|
note: '旧版评查测试页复用正式评查页权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/reviewsTest', output: '/reviews' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/rulesTest/list(?=/|$)',
|
||||||
|
target: '/rules/list',
|
||||||
|
note: '旧版规则列表页复用新版规则列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/rulesTest/list', output: '/rules/list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/rulesTest/detail(?=/|$)',
|
||||||
|
target: '/rules/list',
|
||||||
|
note: '旧版规则详情页归入规则列表权限体系。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/rulesTest/detail', output: '/rules/list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'editor-pages',
|
||||||
|
note: '新建/编辑/复制页统一依赖父列表页权限,避免为固定子路由重复配菜单。',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
source: '^/entry-modules/new(?=/|$)',
|
||||||
|
target: '/entry-modules',
|
||||||
|
note: '入口模块新建/编辑页复用入口模块列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/entry-modules/new', output: '/entry-modules' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/document-types/new(?=/|$)',
|
||||||
|
target: '/document-types',
|
||||||
|
note: '文档类型新建/编辑页复用文档类型列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/document-types/new', output: '/document-types' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/config-lists/new(?=/|$)',
|
||||||
|
target: '/config-lists',
|
||||||
|
note: '配置列表新建/编辑页复用配置列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/config-lists/new', output: '/config-lists' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/prompts/new(?=/|$)',
|
||||||
|
target: '/prompts',
|
||||||
|
note: '提示词新建/编辑/克隆页复用提示词列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/prompts/new', output: '/prompts' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/rule-groups/new(?=/|$)',
|
||||||
|
target: '/rule-groups',
|
||||||
|
note: '评查点分组新建/编辑页复用分组列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/rule-groups/new', output: '/rule-groups' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/rules/new(?=/|$)',
|
||||||
|
target: '/rules/list',
|
||||||
|
note: '评查点新建/编辑/复制页复用评查点列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/rules/new', output: '/rules/list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/documents/edit(?=/|$)',
|
||||||
|
target: '/documents',
|
||||||
|
note: '文档编辑页复用文档列表权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/documents/edit', output: '/documents' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'module-subpages',
|
||||||
|
note: '模块内部的功能页归属到模块入口或父查询页,以维持导航和权限的一致性。',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
source: '^/contract-template/search/results(?=/|$)',
|
||||||
|
target: '/contract-template/search',
|
||||||
|
note: '搜索结果页复用合同模板搜索页权限。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/contract-template/search/results', output: '/contract-template/search' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/contract-template/detail(?=/|$)',
|
||||||
|
target: '/contract-template',
|
||||||
|
note: '合同模板详情页归属到合同模板模块。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/contract-template/detail/123', output: '/contract-template/123' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '^/contract-draft(?=/|$)',
|
||||||
|
target: '/contract-template',
|
||||||
|
note: '合同起草页作为合同模板模块的延伸能力。',
|
||||||
|
examples: [
|
||||||
|
{ input: '/contract-draft/1', output: '/contract-template/1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function listPermissionRouteAliases() {
|
||||||
|
return permissionRouteAliasGroups.flatMap(group =>
|
||||||
|
group.entries.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
group: group.group,
|
||||||
|
groupNote: group.note,
|
||||||
|
pattern: new RegExp(entry.source),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRoutePathForPermission(pathname) {
|
||||||
|
for (const entry of listPermissionRouteAliases()) {
|
||||||
|
if (entry.pattern.test(pathname)) {
|
||||||
|
return pathname.replace(entry.pattern, entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
const permissionRouteAliases: Array<[RegExp, string]> = [
|
export {
|
||||||
[/^\/reviewsTest(?=\/|$)/, '/reviews'],
|
listPermissionRouteAliases,
|
||||||
[/^\/rulesTest\/list(?=\/|$)/, '/rules/list'],
|
normalizeRoutePathForPermission,
|
||||||
[/^\/rulesTest\/detail(?=\/|$)/, '/rules/new'],
|
permissionRouteAliasGroups,
|
||||||
[/^\/entry-modules\/new(?=\/|$)/, '/entry-modules'],
|
} from './route-alias.shared';
|
||||||
];
|
|
||||||
|
|
||||||
export function normalizeRoutePathForPermission(pathname: string): string {
|
|
||||||
for (const [pattern, replacement] of permissionRouteAliases) {
|
|
||||||
if (pattern.test(pathname)) {
|
|
||||||
return pathname.replace(pattern, replacement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Route Alias 约束文档
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
`app/utils/route-alias.shared.js` 只解决一类问题:
|
||||||
|
“功能子页没有独立菜单入口,但它在权限上应明确复用某个父页面/父模块的能力”。
|
||||||
|
|
||||||
|
它不是通用兜底,也不应该替代真正的菜单建模。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么时候应该加 alias
|
||||||
|
|
||||||
|
满足以下条件时,优先加 alias:
|
||||||
|
|
||||||
|
1. 页面是父页面的派生操作页
|
||||||
|
- 例如:`/document-types/new`、`/rules/new`、`/documents/edit`
|
||||||
|
- 这类页面本质上是列表页的“新建 / 编辑 / 复制 / 查看详情”能力延伸
|
||||||
|
|
||||||
|
2. 页面不需要在侧边栏单独展示
|
||||||
|
- 用户不需要从菜单直接点击进入
|
||||||
|
- 通常只会从父列表页按钮跳转进入
|
||||||
|
|
||||||
|
3. 页面权限应该与父页面完全一致
|
||||||
|
- 例如“能看列表的人才能进编辑页”
|
||||||
|
- 不需要单独拆出新的菜单权限或模块权限
|
||||||
|
|
||||||
|
4. 页面属于同一业务模块内部流转
|
||||||
|
- 例如:搜索结果页、详情页、起草页,统一归属合同模板模块
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么时候不应该加 alias,而应该直接补菜单路由
|
||||||
|
|
||||||
|
出现以下任一情况时,不要加 alias,要去补菜单路由 / 后端路由定义 / permissionMap:
|
||||||
|
|
||||||
|
1. 页面本身应该在导航中独立出现
|
||||||
|
- 例如:`/cross-checking/upload`
|
||||||
|
- 用户需要从侧边栏或首页快捷入口直接访问
|
||||||
|
|
||||||
|
2. 页面权限和父页面不一致
|
||||||
|
- 例如列表可看,但新建需要更高权限
|
||||||
|
- 这时必须单独建权限点,不能简单复用父页
|
||||||
|
|
||||||
|
3. 页面是稳定的一级/二级业务页面
|
||||||
|
- 不是“新建 / 编辑 / 详情”这类派生页
|
||||||
|
- 而是一个真实存在、长期运营的功能节点
|
||||||
|
|
||||||
|
4. 页面需要独立的按钮权限、接口权限或审计语义
|
||||||
|
- 如果未来要单独授权、单独统计、单独审计,就不该藏在 alias 里
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 判断原则
|
||||||
|
|
||||||
|
可以用一句话判断:
|
||||||
|
|
||||||
|
- 如果这是“父页的一个动作结果页”,加 alias
|
||||||
|
- 如果这是“系统里一个真正独立的功能页”,补菜单路由
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前已覆盖的典型场景
|
||||||
|
|
||||||
|
- 新建/编辑类
|
||||||
|
- `/entry-modules/new` -> `/entry-modules`
|
||||||
|
- `/document-types/new` -> `/document-types`
|
||||||
|
- `/config-lists/new` -> `/config-lists`
|
||||||
|
- `/prompts/new` -> `/prompts`
|
||||||
|
- `/rule-groups/new` -> `/rule-groups`
|
||||||
|
- `/rules/new` -> `/rules/list`
|
||||||
|
- `/documents/edit` -> `/documents`
|
||||||
|
|
||||||
|
- 模块内子页
|
||||||
|
- `/contract-template/search/results` -> `/contract-template/search`
|
||||||
|
- `/contract-template/detail/:id` -> `/contract-template`
|
||||||
|
- `/contract-draft/:id` -> `/contract-template`
|
||||||
|
|
||||||
|
- 历史兼容页
|
||||||
|
- `/reviewsTest` -> `/reviews`
|
||||||
|
- `/rulesTest/list` -> `/rules/list`
|
||||||
|
- `/rulesTest/detail` -> `/rules/list`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新增 alias 时必须同步做的事
|
||||||
|
|
||||||
|
1. 在 `app/utils/route-alias.shared.js` 添加规则
|
||||||
|
2. 写清 `note`
|
||||||
|
3. 为该规则添加 `examples`
|
||||||
|
4. 执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd new_doc_review
|
||||||
|
npm run test:route-aliases
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反例
|
||||||
|
|
||||||
|
以下情况不要用 alias 硬压过去:
|
||||||
|
|
||||||
|
- 访问 `/some-page/create` 被拒,就直接映射到 `/some-page`
|
||||||
|
- 访问 `/settings/xxx` 被拒,就全部映射到 `/settings`
|
||||||
|
|
||||||
|
如果这个子页未来可能独立授权、独立显示、独立审计,那就应该补真实路由,而不是继续堆 alias。
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"start:pm2:multi": "npm run build:test:multi && pm2 start ecosystemDev.config.cjs --env testing",
|
"start:pm2:multi": "npm run build:test:multi && pm2 start ecosystemDev.config.cjs --env testing",
|
||||||
"start:pm2:production:multi": "npm run build:production:multi && pm2 start ecosystem.config.cjs --env production",
|
"start:pm2:production:multi": "npm run build:production:multi && pm2 start ecosystem.config.cjs --env production",
|
||||||
"typecheck": "tsc",
|
"typecheck": "tsc",
|
||||||
|
"test:route-aliases": "node scripts/test-route-aliases.mjs",
|
||||||
"generate:jwt-secret": "node scripts/generate-jwt-secret.js"
|
"generate:jwt-secret": "node scripts/generate-jwt-secret.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
listPermissionRouteAliases,
|
||||||
|
normalizeRoutePathForPermission,
|
||||||
|
permissionRouteAliasGroups,
|
||||||
|
} from '../app/utils/route-alias.shared.js';
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAliasExampleChecks() {
|
||||||
|
const seenInputs = new Set();
|
||||||
|
|
||||||
|
for (const group of permissionRouteAliasGroups) {
|
||||||
|
assert(group.entries.length > 0, `alias group "${group.group}" 不能为空`);
|
||||||
|
|
||||||
|
for (const entry of group.entries) {
|
||||||
|
assert(entry.examples.length > 0, `alias "${entry.source}" 必须提供至少一个 example`);
|
||||||
|
|
||||||
|
for (const example of entry.examples) {
|
||||||
|
assert(!seenInputs.has(example.input), `重复的 alias example 输入: ${example.input}`);
|
||||||
|
seenInputs.add(example.input);
|
||||||
|
|
||||||
|
const actual = normalizeRoutePathForPermission(example.input);
|
||||||
|
assert(
|
||||||
|
actual === example.output,
|
||||||
|
`alias example 校验失败: ${example.input} -> ${actual},预期 ${example.output}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNoopChecks() {
|
||||||
|
const unaffectedPaths = [
|
||||||
|
'/documents',
|
||||||
|
'/files/upload',
|
||||||
|
'/cross-checking/upload',
|
||||||
|
'/document-types',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of unaffectedPaths) {
|
||||||
|
const actual = normalizeRoutePathForPermission(path);
|
||||||
|
assert(actual === path, `不应被改写的路径发生了变化: ${path} -> ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMetadataChecks() {
|
||||||
|
const aliases = listPermissionRouteAliases();
|
||||||
|
const signatureSet = new Set();
|
||||||
|
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const signature = `${alias.source}=>${alias.target}`;
|
||||||
|
assert(!signatureSet.has(signature), `重复的 alias 定义: ${signature}`);
|
||||||
|
signatureSet.add(signature);
|
||||||
|
assert(alias.note.trim().length > 0, `alias "${alias.source}" 缺少 note`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
runAliasExampleChecks();
|
||||||
|
runNoopChecks();
|
||||||
|
runMetadataChecks();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`route alias checks passed: ${permissionRouteAliasGroups.length} groups, ${listPermissionRouteAliases().length} aliases`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`route alias checks failed: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user