feat: 1. 完善起草合同页面的逻辑交互,对接minio的接口操作

This commit is contained in:
2025-12-05 20:17:37 +08:00
parent 3d1dbb3f97
commit 91b7518c99
21 changed files with 1249 additions and 1057 deletions
+9 -9
View File
@@ -82,7 +82,7 @@ export async function getContractCategories(jwt?: string) {
token: jwt
};
const response = await postgrestGet<ContractCategory[]>('contract_categories', params);
const response = await postgrestGet<ContractCategory[]>('/api/postgrest/proxy/contract_categories', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
@@ -107,7 +107,7 @@ export async function getContractCategories(jwt?: string) {
export async function getContractCategoriesWithCount(jwt?: string) {
try {
// 获取所有分类
const categoriesResponse = await postgrestGet<ContractCategory[]>('contract_categories', {
const categoriesResponse = await postgrestGet<ContractCategory[]>('/api/postgrest/proxy/contract_categories', {
select: '*',
order: 'sort_order.asc,name.asc',
token: jwt
@@ -124,7 +124,7 @@ export async function getContractCategoriesWithCount(jwt?: string) {
categories.map(async (category) => {
try {
// 简化方案:获取该分类下的所有模板ID,然后计算数量
const countResponse = await postgrestGet<{ id: number }[]>('contract_templates', {
const countResponse = await postgrestGet<{ id: number }[]>('/api/postgrest/proxy/contract_templates', {
select: 'id',
filter: { 'category_id': `eq.${category.id}` },
token: jwt
@@ -214,7 +214,7 @@ export async function getContractTemplates(searchParams: TemplateSearchParams =
// 先查询匹配的分类ID
let matchingCategoryIds: number[] = [];
try {
const categoryResponse = await postgrestGet<ContractCategory[]>('contract_categories', {
const categoryResponse = await postgrestGet<ContractCategory[]>('/api/postgrest/proxy/contract_categories', {
select: 'id',
filter: { 'name': `ilike.*${cleanKeyword}*` },
token
@@ -245,7 +245,7 @@ export async function getContractTemplates(searchParams: TemplateSearchParams =
// 如果有分类名称,需要先获取分类ID
if (category && !category_id) {
const categoryResponse = await postgrestGet<ContractCategory[]>('contract_categories', {
const categoryResponse = await postgrestGet<ContractCategory[]>('/api/postgrest/proxy/contract_categories', {
select: 'id',
filter: { 'name': `eq.${category}` },
token
@@ -264,7 +264,7 @@ export async function getContractTemplates(searchParams: TemplateSearchParams =
}
// 执行查询
const response = await postgrestGet<ContractTemplate[]>('contract_templates', params);
const response = await postgrestGet<ContractTemplate[]>('/api/postgrest/proxy/contract_templates', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
@@ -280,7 +280,7 @@ export async function getContractTemplates(searchParams: TemplateSearchParams =
token
};
const countResponse = await postgrestGet<{ id: number }[]>('contract_templates', countParams);
const countResponse = await postgrestGet<{ id: number }[]>('/api/postgrest/proxy/contract_templates', countParams);
let total = 0;
if (!countResponse.error && countResponse.data) {
const countData = extractApiData<{ id: number }[]>(countResponse.data) || [];
@@ -318,7 +318,7 @@ export async function getContractTemplate(id: string | number, jwt?: string) {
token: jwt
};
const response = await postgrestGet<ContractTemplate[]>('contract_templates', params);
const response = await postgrestGet<ContractTemplate[]>('/api/postgrest/proxy/contract_templates', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
@@ -355,7 +355,7 @@ export async function getFeaturedTemplates(limit: number = 6, jwt?: string) {
token: jwt
};
const response = await postgrestGet<ContractTemplate[]>('contract_templates', params);
const response = await postgrestGet<ContractTemplate[]>('/api/postgrest/proxy/contract_templates', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
-131
View File
@@ -80,134 +80,3 @@ export async function copyMinioFile(
}
}
/**
* 创建起草合同记录
* @param request 创建请求
* @param userId 用户ID
* @param draftFilePath 可选:草稿文件路径(如果不提供,使用模板路径)
* @returns 创建的记录
*
* 使用场景:
* 1. 不传 draftFilePath:直接使用模板文件路径,在原模板上编辑
* 2. 传 draftFilePath:使用复制后的文件路径(由文件复制接口提供)
*/
export async function createDraftContract(
request: CreateDraftRequest,
userId: number,
draftFilePath?: string,
jwt?: string
): Promise<DraftedContract> {
try {
// 1. 查询模板信息
const templateResponse = await postgrestGet('contract_templates', {
select: 'id,file_path',
filter: { id: `eq.${request.templateId}` },
token: jwt
});
if (!templateResponse.data || (Array.isArray(templateResponse.data) && templateResponse.data.length === 0)) {
throw new Error('模板不存在');
}
const template = Array.isArray(templateResponse.data) ? templateResponse.data[0] : templateResponse.data;
// 2. 确定使用的文件路径
// 如果没有提供草稿路径,直接使用模板路径(适合直接编辑模板的场景)
// 如果提供了草稿路径,使用复制后的文件路径
const finalFilePath = draftFilePath || template.file_path;
console.log('[Draft Service] 创建草稿:', {
templateId: request.templateId,
templatePath: template.file_path,
draftPath: draftFilePath,
finalPath: finalFilePath,
mode: draftFilePath ? '使用复制文件' : '直接使用模板文件'
});
// 3. 创建草稿记录
const insertResponse = await postgrestPost('drafted_contracts', {
body: {
template_id: request.templateId,
file_path: finalFilePath,
title: request.title,
placeholder_values: {},
status: 'draft',
created_by: userId
},
select: '*',
token: jwt
});
if (!insertResponse.data) {
throw new Error('创建草稿记录失败');
}
const draft = Array.isArray(insertResponse.data) ? insertResponse.data[0] : insertResponse.data;
return draft as DraftedContract;
} catch (error) {
console.error('[Draft Service] 创建草稿失败:', error);
throw error;
}
}
/**
* 删除草稿记录
* @param draftId 草稿ID
* @param userId 用户ID
* @param jwt JWT token
*/
export async function deleteDraft(
draftId: number,
userId: number,
jwt?: string
): Promise<void> {
try {
const response = await postgrestDelete('drafted_contracts', {
filter: {
id: `eq.${draftId}`,
created_by: `eq.${userId}` // 确保只能删除自己的草稿
},
token: jwt
});
console.log('[Draft Service] 草稿已删除:', draftId);
} catch (error) {
console.error('[Draft Service] 删除草稿失败:', error);
throw error;
}
}
/**
* 获取草稿详情
* @param draftId 草稿ID
* @param userId 用户ID
* @returns 草稿记录
*/
export async function getDraftById(
draftId: number,
userId: number,
jwt?: string
): Promise<DraftedContract | null> {
try {
const response = await postgrestGet('drafted_contracts', {
select: '*',
filter: {
id: `eq.${draftId}`,
created_by: `eq.${userId}`
},
token: jwt
});
if (!response.data || (Array.isArray(response.data) && response.data.length === 0)) {
return null;
}
const draft = Array.isArray(response.data) ? response.data[0] : response.data;
return draft as DraftedContract;
} catch (error) {
console.error('[Draft Service] 获取草稿失败:', error);
return null;
}
}
+2 -2
View File
@@ -90,7 +90,7 @@ async function safeGetJWT(jwtToken?: string): Promise<string> {
*/
export async function findIsProposer(taskId: string | number, userId: number | undefined, frontendJWT?: string): Promise<boolean> {
// 通过postgrest的get请求去cross_examination_tasks表中进行查找assignee_id是否等于userId
const response = await postgrestGet(`cross_examination_tasks`, {
const response = await postgrestGet(`/api/postgrest/proxy/cross_examination_tasks`, {
select: 'assigner_id',
filter: {
id: `eq.${taskId}`
@@ -399,7 +399,7 @@ export async function confirmReviewResults(
): Promise<{data?: unknown, error?: string, status?: number}> {
try {
// 通过postgrest的post请求去documents表中进行查找id等于documentId的数据,更新documents表的audit_status为1
const response = await postgrestPut(`documents`, {
const response = await postgrestPut(`/api/postgrest/proxy/documents`, {
audit_status: 1
}, {
id: documentId
+2 -2
View File
@@ -494,7 +494,7 @@ export async function updateDocumentAuditStatus(id: string, auditStatus: number,
}
const response = await postgrestPut<TaskDocument, Partial<TaskDocument>>(
'documents',
'/api/postgrest/proxy/documents',
{ audit_status: auditStatus },
{
id: parseInt(id)
@@ -526,7 +526,7 @@ export async function getCrossCheckingDocumentTypes(jwtToken?: string): Promise<
try {
// console.log('[getCrossCheckingDocumentTypes] 开始获取交叉评查文档类型');
const response = await postgrestGet<DocumentType>('document_types',{
const response = await postgrestGet<DocumentType>('/api/postgrest/proxy/document_types',{
select: 'id,name,code,evaluation_point_groups_ids',
filter: {
evaluation_point_groups_ids: 'not.is.null'
@@ -34,7 +34,7 @@ export async function verifyDocumentAccess(
try {
// 1. 检查文档是否属于该任务(通过 cross_task_document_mapping 表)
const documentMappingResponse = await postgrestGet('cross_task_document_mapping', {
const documentMappingResponse = await postgrestGet('/api/postgrest/proxy/cross_task_document_mapping', {
select: 'task_id,document_id',
filter: {
task_id: `eq.${taskId}`,
@@ -66,7 +66,7 @@ export async function verifyDocumentAccess(
}
// 2. 检查用户是否是该任务的参与者
const taskResponse = await postgrestGet('cross_examination_tasks', {
const taskResponse = await postgrestGet('/api/postgrest/proxy/cross_examination_tasks', {
select: 'assigner_id,assignee_ids',
filter: {
id: `eq.${taskId}`
+12 -11
View File
@@ -158,7 +158,7 @@ export async function getReviewPoints(fileId: string, request: Request) {
limit: 1,
token: frontendJWT
};
const contractStructureComparisonResponse = await postgrestGet('contract_structure_comparison', contractStructureComparisonParams);
const contractStructureComparisonResponse = await postgrestGet('/api/postgrest/proxy/contract_structure_comparison', contractStructureComparisonParams);
// console.log('contract_structure_comparison', contractStructureComparisonResponse)
if (contractStructureComparisonResponse.error) {
@@ -201,7 +201,7 @@ export async function getReviewPoints(fileId: string, request: Request) {
},
token: frontendJWT
};
const evaluationResultsResponse = await postgrestGet('evaluation_results', evaluationResultsParams);
const evaluationResultsResponse = await postgrestGet('/api/postgrest/proxy/evaluation_results', evaluationResultsParams);
// console.log('evaluationResultsResponse-------', evaluationResultsResponse,);
if (evaluationResultsResponse.error) {
@@ -230,7 +230,7 @@ export async function getReviewPoints(fileId: string, request: Request) {
},
token: frontendJWT
};
const evaluationPointsResponse = await postgrestGet('evaluation_points', evaluationPointsParams);
const evaluationPointsResponse = await postgrestGet('/api/postgrest/proxy/evaluation_points', evaluationPointsParams);
if (evaluationPointsResponse.error) {
return { error: evaluationPointsResponse.error, status: evaluationPointsResponse.status };
@@ -257,7 +257,7 @@ export async function getReviewPoints(fileId: string, request: Request) {
},
token: frontendJWT
};
const groupsResponse = await postgrestGet('evaluation_point_groups', groupsParams);
const groupsResponse = await postgrestGet('/api/postgrest/proxy/evaluation_point_groups', groupsParams);
if (groupsResponse.error) {
return { error: groupsResponse.error, status: groupsResponse.status };
@@ -281,7 +281,7 @@ export async function getReviewPoints(fileId: string, request: Request) {
},
token: frontendJWT
};
const manualReviewPointsResponse = await postgrestGet('audit_status', manualReviewPointsParams);
const manualReviewPointsResponse = await postgrestGet('/api/postgrest/proxy/audit_status', manualReviewPointsParams);
if (manualReviewPointsResponse.error) {
return { error: manualReviewPointsResponse.error, status: manualReviewPointsResponse.status };
}
@@ -336,7 +336,7 @@ export async function getReviewPoints(fileId: string, request: Request) {
},
token: frontendJWT
};
const scoringProposalsResponse = await postgrestGet('cross_scoring_proposals', scoringProposalsParams);
const scoringProposalsResponse = await postgrestGet('/api/postgrest/proxy/cross_scoring_proposals', scoringProposalsParams);
if (scoringProposalsResponse.error) {
return { error: scoringProposalsResponse.error, status: scoringProposalsResponse.status };
@@ -776,12 +776,13 @@ export async function updateReviewResult(
}
// 首先获取当前评查结果数据
const currentResultResponse = await postgrestGet('evaluation_results', {
const currentResultResponse = await postgrestGet('/api/postgrest/proxy/evaluation_results', {
select: '*',
filter: { id: `eq.${resultId}` },
token: frontendJWT
});
console.log('/api/postgrest/proxy/evaluation_results',currentResultResponse.error)
if (currentResultResponse.error) {
return { error: currentResultResponse.error, status: currentResultResponse.status };
}
@@ -812,7 +813,7 @@ export async function updateReviewResult(
// 调用 API 更新评查点结果数据
const resultResponse = await postgrestPut<unknown, typeof updatedData>(
'evaluation_results',
'/api/postgrest/proxy/evaluation_results',
updatedData,
{ id: resultId },
frontendJWT
@@ -831,7 +832,7 @@ export async function updateReviewResult(
if (editAuditStatusId && editAuditStatusId !== '') {
// 更新现有审核状态记录
const auditStatusResponse = await postgrestPut(
'audit_status',
'/api/postgrest/proxy/audit_status',
{
edit_audit_status: editAuditStatusValue,
// 重新审核时不更新message
@@ -864,7 +865,7 @@ export async function updateReviewResult(
};
// 使用postgrestPost创建新记录
const postResponse = await postgrestPost('audit_status', newAuditStatus, frontendJWT);
const postResponse = await postgrestPost('/api/postgrest/proxy/audit_status', newAuditStatus, frontendJWT);
if (postResponse.error) {
return { error: postResponse.error, status: postResponse.status || 500 };
@@ -938,7 +939,7 @@ export async function confirmReviewResults(documentId: string, request: Request)
// 调用API更新文档审核状态
const response = await postgrestPut<{ id: string }, typeof updateDocumentParams>(
'documents',
'/api/postgrest/proxy/documents',
updateDocumentParams,
{
id: documentId,
-314
View File
@@ -88,122 +88,6 @@ export interface RuleGroupQueryParams {
token?: string;
}
/**
* 获取评查点分组列表(支持分页、筛选、排序)
* @deprecated 使用 getEvaluationPointGroups 代替(FastAPI v3
* @param params 查询参数
* @returns 评查点分组列表和总数
*/
export async function getRuleGroups_legacy(
params?: RuleGroupQueryParams
): Promise<{data: RuleGroup[]; totalCount?: number; error?: never} | {data?: never; error: string; status?: number}> {
try {
const {
page = 1,
pageSize = 50,
name,
code,
is_enabled,
pid = '0', // 默认获取一级分组
orderBy = 'created_at',
order = 'desc',
token
} = params || {};
// 构建筛选条件
const filter: Record<string, string> = {};
// 父级ID筛选 (pid=null或'0'表示一级分组)
if (pid === null || pid === '0') {
filter['pid'] = 'eq.0';
} else if (pid) {
filter['pid'] = `eq.${pid}`;
}
// 名称模糊搜索
if (name) {
filter['name'] = `ilike.*${name}*`;
}
// 编码模糊搜索
if (code) {
filter['code'] = `ilike.*${code}*`;
}
// 状态筛选
if (is_enabled !== undefined) {
filter['is_enabled'] = `eq.${is_enabled}`;
}
const postgrestParams: PostgrestParams = {
select: `
id,
pid,
name,
code,
description,
is_enabled,
created_at
`,
filter,
order: `${orderBy}.${order}`, // PostgREST order format: field.direction
limit: pageSize,
offset: (page - 1) * pageSize,
token
};
const response = await postgrestGet<{code: number; msg: string; data: Array<{
id: number;
pid: number;
name: string;
code?: string;
description?: string;
is_enabled: boolean;
created_at?: string;
}>}>('evaluation_point_groups', postgrestParams);
if (response.error) {
return { error: response.error, status: response.status };
}
// 处理响应数据
let groups: RuleGroup[] = [];
if (response.data && 'code' in response.data && response.data.data) {
groups = response.data.data.map(group => ({
id: group.id.toString(),
pid: group.pid.toString(),
name: group.name,
code: group.code,
description: group.description,
is_enabled: group.is_enabled,
createdAt: group.created_at ? formatDate(group.created_at) : undefined
}));
} else if (Array.isArray(response.data)) {
groups = response.data.map(group => ({
id: group.id.toString(),
pid: group.pid.toString(),
name: group.name,
code: group.code,
description: group.description,
is_enabled: group.is_enabled,
createdAt: group.created_at ? formatDate(group.created_at) : undefined
}));
}
// 注意:由于当前 PostgREST 客户端不支持 count 参数,totalCount 返回当前页的记录数
// 后续可优化为单独查询获取准确的总数
return {
data: groups,
totalCount: groups.length
};
} catch (error) {
console.error('获取评查点分组列表失败:', error);
return {
error: error instanceof Error ? error.message : '获取评查点分组列表失败',
status: 500
};
}
}
/**
* 获取指定分组的子分组(包含评查点数量统计)
@@ -241,204 +125,6 @@ export async function getChildGroups(parentId: string, token?: string): Promise<
// ==================== 批量操作接口 ====================
/**
* 批量更新分组状态(启用/禁用)
* @deprecated 使用 batchUpdateEvaluationPointGroupStatus 代替(FastAPI v3
* @param ids 分组ID列表
* @param is_enabled 目标状态
* @param token JWT token (可选)
* @returns 更新结果
*/
export async function batchUpdateRuleGroupStatus_legacy(
ids: string[],
is_enabled: boolean,
token?: string
): Promise<{
success: boolean;
updated_count: number;
failed_ids: string[];
errors?: Array<{ id: string; error: string }>;
}> {
try {
// ========== 1. 参数验证 ==========
if (!Array.isArray(ids) || ids.length === 0) {
return {
success: false,
updated_count: 0,
failed_ids: [],
errors: [{ id: 'validation', error: 'ID列表不能为空' }]
};
}
// 验证每个ID的有效性
const invalidIds = ids.filter(id => !id || id.trim() === '');
if (invalidIds.length > 0) {
return {
success: false,
updated_count: 0,
failed_ids: ids,
errors: [{ id: 'validation', error: '存在无效的分组ID' }]
};
}
// ========== 2. 逐个更新(确保每个分组都能被正确处理) ==========
const failedIds: string[] = [];
const errors: Array<{ id: string; error: string }> = [];
let updatedCount = 0;
for (const id of ids) {
try {
// 验证分组是否存在
const groupResponse = await getRuleGroup(id, token);
if (groupResponse.error || !groupResponse.data) {
failedIds.push(id);
errors.push({ id, error: '分组不存在或无法访问' });
continue;
}
// 执行更新
const updateResponse = await postgrestPut<ApiResponse<RuleGroup> | RuleGroup, Partial<ApiRuleGroup>>(
'evaluation_point_groups',
{ is_enabled },
{ id },
token
);
if (updateResponse.error) {
failedIds.push(id);
errors.push({ id, error: updateResponse.error });
} else {
updatedCount++;
}
} catch (error) {
failedIds.push(id);
errors.push({
id,
error: error instanceof Error ? error.message : '更新失败'
});
}
}
// ========== 3. 返回结果 ==========
return {
success: failedIds.length === 0,
updated_count: updatedCount,
failed_ids: failedIds,
errors: errors.length > 0 ? errors : undefined
};
} catch (error) {
console.error('批量更新分组状态失败:', error);
return {
success: false,
updated_count: 0,
failed_ids: ids,
errors: [{
id: 'batch',
error: error instanceof Error ? error.message : '批量更新失败'
}]
};
}
}
/**
* 批量删除分组(安全的阻止删除策略)
* @deprecated 使用 batchDeleteEvaluationPointGroups 代替(FastAPI v3
* @param ids 分组ID列表
* @param token JWT token (可选)
* @returns 删除结果
*/
export async function batchDeleteRuleGroups_legacy(
ids: string[],
token?: string
): Promise<{
success: boolean;
deleted_count: number;
failed_ids: string[];
errors?: Array<{ id: string; error: string; details?: { hasChildren?: boolean; hasPoints?: boolean } }>;
}> {
try {
// ========== 1. 参数验证 ==========
if (!Array.isArray(ids) || ids.length === 0) {
return {
success: false,
deleted_count: 0,
failed_ids: [],
errors: [{ id: 'validation', error: 'ID列表不能为空' }]
};
}
// 验证每个ID的有效性
const invalidIds = ids.filter(id => !id || id.trim() === '');
if (invalidIds.length > 0) {
return {
success: false,
deleted_count: 0,
failed_ids: ids,
errors: [{ id: 'validation', error: '存在无效的分组ID' }]
};
}
// ========== 2. 逐个删除(使用安全的阻止删除策略) ==========
const failedIds: string[] = [];
const errors: Array<{ id: string; error: string; details?: { hasChildren?: boolean; hasPoints?: boolean } }> = [];
let deletedCount = 0;
for (const id of ids) {
try {
const deleteResult = await deleteRuleGroup(id, token);
if (!deleteResult.success) {
failedIds.push(id);
errors.push({
id,
error: deleteResult.error || '删除失败',
details: deleteResult.details ? {
hasChildren: deleteResult.details.hasChildren,
hasPoints: deleteResult.details.hasPoints
} : undefined
});
} else {
deletedCount++;
}
} catch (error) {
failedIds.push(id);
errors.push({
id,
error: error instanceof Error ? error.message : '删除失败'
});
}
}
// ========== 3. 返回结果 ==========
return {
success: failedIds.length === 0,
deleted_count: deletedCount,
failed_ids: failedIds,
errors: errors.length > 0 ? errors : undefined
};
} catch (error) {
console.error('批量删除分组失败:', error);
return {
success: false,
deleted_count: 0,
failed_ids: ids,
errors: [{
id: 'batch',
error: error instanceof Error ? error.message : '批量删除失败'
}]
};
}
}
// ========================================
// FastAPI v3 接口函数(新版)
// ========================================
+1 -1
View File
@@ -136,7 +136,7 @@ export async function updateDocumentAuditStatus(id: string, auditStatus: number,
});
const response = await postgrestPut<Document, Partial<Document>>(
'documents',
'/api/postgrest/proxy/documents',
{ audit_status: auditStatus },
{
id: parseInt(id),
+15 -15
View File
@@ -364,7 +364,7 @@ export async function getRule(id: string, token?: string): Promise<{data: Rule;
};
// 获取评查点详情 - 使用正确的PostgREST格式
const response = await postgrestGet<{code: number; msg: string; data: ApiRule} | ApiRule[]>('evaluation_points', postgrestParams);
const response = await postgrestGet<{code: number; msg: string; data: ApiRule} | ApiRule[]>('/api/postgrest/proxy/evaluation_points', postgrestParams);
// 检查是否有错误响应
if (response.error) {
@@ -401,7 +401,7 @@ export async function getRule(id: string, token?: string): Promise<{data: Rule;
};
// 查询评查点分组
const groupResponse = await postgrestGet<{code: number; msg: string; data: {id: number; name: string}[]}>('evaluation_point_groups', groupParams);
const groupResponse = await postgrestGet<{code: number; msg: string; data: {id: number; name: string}[]}>('/api/postgrest/proxy/evaluation_point_groups', groupParams);
if (groupResponse.data?.data && groupResponse.data.data.length > 0) {
// 将分组信息添加到评查点数据中
@@ -480,7 +480,7 @@ export async function createRule(ruleData: Omit<Rule, 'id' | 'createdAt' | 'upda
}
// 检查分组是否存在
const groupResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string; pid: number}> }>('evaluation_point_groups', {
const groupResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string; pid: number}> }>('/api/postgrest/proxy/evaluation_point_groups', {
filter: { 'id': `eq.${ruleData.groupId}` },
select: 'id,name,pid',
token
@@ -524,7 +524,7 @@ export async function createRule(ruleData: Omit<Rule, 'id' | 'createdAt' | 'upda
};
// 使用postgrestPost创建评查点
const response = await postgrestPost<{code: number; msg: string; data: ApiRule}, typeof apiRuleData>('evaluation_points', apiRuleData, token);
const response = await postgrestPost<{code: number; msg: string; data: ApiRule}, typeof apiRuleData>('/api/postgrest/proxy/evaluation_points', apiRuleData, token);
// 检查是否有错误响应
if (response.error) {
@@ -599,7 +599,7 @@ export async function updateRule(id: string, ruleData: Partial<Omit<Rule, 'id' |
// 4. 验证分组ID有效性(如果提供)
if (ruleData.groupId !== undefined) {
const groupResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string; pid: number}> }>('evaluation_point_groups', {
const groupResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string; pid: number}> }>('/api/postgrest/proxy/evaluation_point_groups', {
filter: { 'id': `eq.${ruleData.groupId}` },
select: 'id,name,pid',
token
@@ -645,7 +645,7 @@ export async function updateRule(id: string, ruleData: Partial<Omit<Rule, 'id' |
}
// 使用postgrestPut更新评查点 - 使用正确的PostgREST格式
const response = await postgrestPut<{code: number; msg: string; data: ApiRule} | ApiRule[], typeof apiRuleData>('evaluation_points', apiRuleData, { id: parseInt(id) }, token);
const response = await postgrestPut<{code: number; msg: string; data: ApiRule} | ApiRule[], typeof apiRuleData>('/api/postgrest/proxy/evaluation_points', apiRuleData, { id: parseInt(id) }, token);
// 检查是否有错误响应
if (response.error) {
@@ -816,7 +816,7 @@ export async function getRuleTypes(documentTypeIds?: number[], token?: string):
name: string;
evaluation_point_groups_ids: number[];
}>
}>('document_types', documentTypesParams);
}>('/api/postgrest/proxy/document_types', documentTypesParams);
if (documentTypesResponse.error) {
return { error: documentTypesResponse.error, status: documentTypesResponse.status };
@@ -890,7 +890,7 @@ export async function getRuleTypes(documentTypeIds?: number[], token?: string):
description: string;
is_enabled: boolean;
}>
}>('evaluation_point_groups', groupsParams);
}>('/api/postgrest/proxy/evaluation_point_groups', groupsParams);
// 检查是否有错误响应
if (response.error) {
@@ -975,7 +975,7 @@ export async function getRuleGroupsByType(typeId: string, token?: string): Promi
description: string;
is_enabled: boolean;
}>;
}>('evaluation_point_groups', postgrestParams);
}>('/api/postgrest/proxy/evaluation_point_groups', postgrestParams);
// 检查是否有错误响应
if (response.error) {
@@ -1169,7 +1169,7 @@ export async function getFormattedEvaluationPoint(id: number): Promise<{
}
};
const response = await postgrestGet<{code: number; msg: string; data: ApiRule[]} | ApiRule[]>('evaluation_points', postgrestParams);
const response = await postgrestGet<{code: number; msg: string; data: ApiRule[]} | ApiRule[]>('/api/postgrest/proxy/evaluation_points', postgrestParams);
if (response.error) {
return {
@@ -1238,7 +1238,7 @@ export async function getEvaluationPointGroups(): Promise<{
updated_at?: string;
};
const response = await postgrestGet<{code: number; msg: string; data: EvaluationPointGroupType[]} | EvaluationPointGroupType[]>('evaluation_point_groups', postgrestParams);
const response = await postgrestGet<{code: number; msg: string; data: EvaluationPointGroupType[]} | EvaluationPointGroupType[]>('/api/postgrest/proxy/evaluation_point_groups', postgrestParams);
if (response.error) {
return {
@@ -1480,14 +1480,14 @@ export async function saveEvaluationPoint(evaluationPoint: EvaluationPointInput,
if (isEditMode) {
// 更新操作
response = await postgrestPut<{code: number; msg: string; data: ApiRule} | ApiRule, typeof cleanedData>(
`evaluation_points`,
`/api/postgrest/proxy/evaluation_points`,
cleanedData,
{id: cleanedData.id!}
);
} else {
// 创建操作
response = await postgrestPost<{code: number; msg: string; data: ApiRule} | ApiRule, typeof cleanedData>(
'evaluation_points',
'/api/postgrest/proxy/evaluation_points',
cleanedData
);
}
@@ -1566,7 +1566,7 @@ export async function getRuleStatistics(token?: string): Promise<{data: RuleStat
is_enabled: boolean;
risk: string;
evaluation_point_groups_id: number | null;
}>}>('evaluation_points', postgrestParams);
}>}>('/api/postgrest/proxy/evaluation_points', postgrestParams);
// 检查是否有错误响应
if (response.error) {
@@ -1628,7 +1628,7 @@ export async function getRuleStatistics(token?: string): Promise<{data: RuleStat
token
};
const groupsResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string}>}>('evaluation_point_groups', groupsParams);
const groupsResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; name: string}>}>('/api/postgrest/proxy/evaluation_point_groups', groupsParams);
let groups: Array<{id: number; name: string}> = [];
if (groupsResponse.data && 'code' in groupsResponse.data && groupsResponse.data.data) {
+5 -103
View File
@@ -145,7 +145,7 @@ function getFileExtension(filename: string): string {
* @returns 评查结果
*/
async function getEvaluationResults(id: number, frontendJWT?: string) {
const response = await postgrestGet<[]>('evaluation_results', {
const response = await postgrestGet<[]>('/api/postgrest/proxy/evaluation_results', {
filter: {
'document_id': `eq.${id}`
},
@@ -254,7 +254,7 @@ export async function deleteDocument(id: string, userId: string, token?: string)
}
const response = await postgrestDelete(
'documents',
'/api/postgrest/proxy/documents',
{
filter: {
'id': `eq.${id}`,
@@ -298,7 +298,7 @@ export async function getDocument(id: string, userId: string, frontendJWT?: stri
}
const response = await postgrestGet<Document[]>(
'documents',
'/api/postgrest/proxy/documents',
{
filter: {
'id': `eq.${id}`,
@@ -349,7 +349,7 @@ export async function getDocumentWithNoUserId(id: string, frontendJWT?: string):
// console.log("get单个文档id", id)
const response = await postgrestGet<Document[]>(
'documents',
'/api/postgrest/proxy/documents',
{
filter: {
'id': `eq.${id}`,
@@ -430,7 +430,7 @@ export async function updateDocument(id: string, document: Partial<DocumentUI> &
// console.log('更新文档API数据:', apiDocument);
const response = await postgrestPut<Document, Partial<Document>>(
'documents',
'/api/postgrest/proxy/documents',
apiDocument,
{
id: parseInt(id),
@@ -653,101 +653,3 @@ export async function getDocumentsListFromAPI(searchParams: {
};
}
}
/**
* 获取文档历史版本列表
* @param documentName 文档名称
* @param userId 用户ID
* @param excludeId 排除的文档ID(当前最新版本的ID)
* @param token JWT token
* @returns 历史版本列表
*/
export async function getDocumentHistory(
documentName: string,
userId: string,
excludeId: number,
token?: string
): Promise<{
data?: DocumentVersionUI[];
error?: string;
status?: number;
}> {
try {
if (!documentName) {
return { error: '文档名称不能为空', status: 400 };
}
if (!userId) {
return { error: '用户身份验证失败', status: 401 };
}
// 调用 RPC 函数获取历史版本
const response = await postgrestPost<any[], unknown>(
'rpc/documents_get_document_history',
{
p_document_name: documentName,
p_user_id: parseInt(userId, 10),
p_exclude_id: excludeId
},
token
);
if (response.error || !response.data) {
return { error: response.error || '获取历史版本失败', status: response.status || 500 };
}
const historyDocs = response.data;
// 转换为 UI 格式,并计算问题数量差异
const documents: DocumentVersionUI[] = historyDocs.map((doc: any, index: number) => {
// 计算与下一个版本(更早的版本)的问题数量差异
let issuesDiff: number | undefined;
let issuesDiffType: 'increase' | 'decrease' | 'same' | undefined;
if (index < historyDocs.length - 1) {
const olderDoc = historyDocs[index + 1];
if (doc.false_count != null && olderDoc.false_count != null) {
const diff = doc.false_count - olderDoc.false_count;
issuesDiff = Math.abs(diff);
if (diff > 0) {
issuesDiffType = 'increase';
} else if (diff < 0) {
issuesDiffType = 'decrease';
} else {
issuesDiffType = 'same';
}
}
}
return {
id: doc.id,
name: doc.name,
documentNumber: doc.document_number,
type: doc.type_id.toString(),
typeName: doc.type_name || '未知类型',
size: doc.file_size,
auditStatus: doc.audit_status ?? 0,
fileStatus: doc.status || '',
issues: doc.false_count ?? null,
issuesDiff,
issuesDiffType,
uploadTime: formatDate(doc.created_at),
fileType: getFileExtension(doc.name),
path: doc.path,
isTest: doc.is_test_document,
updatedAt: formatDate(doc.updated_at),
pageCount: doc.ocr_result?.__meta?.page_count || 0,
ocrResult: doc.ocr_result,
versionNumber: historyDocs.length - index
};
});
return { data: documents };
} catch (error) {
console.error('获取文档历史版本失败:', error);
return {
error: error instanceof Error ? error.message : '获取文档历史版本失败',
status: 500
};
}
}
+4 -4
View File
@@ -497,7 +497,7 @@ export async function getTodayDocuments(
}
// console.log('发送请求参数:', params);
const response = await postgrestGet<Document[]>('documents', { ...params, token });
const response = await postgrestGet<Document[]>('/api/postgrest/proxy/documents', { ...params, token });
// console.log('API 响应:', response);
if (response.error) {
@@ -552,7 +552,7 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT
}
// 如果没有 documentTypeIds,返回所有文档类型(不添加过滤条件)
const response = await postgrestGet<DocumentType[]>('document_types', { ...params, token });
const response = await postgrestGet<DocumentType[]>('/api/postgrest/proxy/ocument_types', { ...params, token });
if (response.error) {
return { error: response.error, status: response.status };
@@ -600,7 +600,7 @@ export async function getDocumentsStatus(
'id': `in.(${documentIds.join(',')})`
}
};
documentsResponse = await postgrestGet<Document[]>('documents', { ...documentsParams, token });
documentsResponse = await postgrestGet<Document[]>('/api/postgrest/proxy/documents', { ...documentsParams, token });
}
// 查询合同附件状态
@@ -613,7 +613,7 @@ export async function getDocumentsStatus(
'id': `in.(${attachmentIds.join(',')})`
}
};
attachmentResponse = await postgrestGet<ContractStructureComparison[]>('contract_structure_comparison', { ...attachmentParams, token });
attachmentResponse = await postgrestGet<ContractStructureComparison[]>('/api/postgrest/proxy/contract_structure_comparison', { ...attachmentParams, token });
}
if (documentsResponse.error && attachmentResponse.error) {
+2 -2
View File
@@ -244,7 +244,7 @@ export async function getEntryModules(userRole: string | null | undefined, userA
filter: {}
};
const modulesResponse = await postgrestGet('entry_modules', { ...params, token });
const modulesResponse = await postgrestGet('/api/postgrest/proxy/entry_modules', { ...params, token });
if (modulesResponse.error) {
console.error('❌ [getEntryModules] 查询入口模块失败:', modulesResponse.error);
@@ -295,7 +295,7 @@ export async function getEntryModules(userRole: string | null | undefined, userA
}
};
const typesResponse = await postgrestGet('document_types', { ...typesParams, token });
const typesResponse = await postgrestGet('/api/postgrest/proxy/document_types', { ...typesParams, token });
if (typesResponse.error) {
console.error(`❌ [getEntryModules] 查询模块 ${module.id} 的文档类型失败:`, typesResponse.error);
+1 -244
View File
@@ -530,247 +530,4 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise<void
console.error("❌ [callIDaaSLogout] 调用IDaaS登出接口失败(非HTTP错误):", error);
throw error;
}
}
/**
* 保存用户信息到数据库
*
* 此函数实现以下逻辑:
* 1. 内部生成临时 JWTuser_id 为 'login',仅用于数据库操作)
* 2. 根据 userInfo.sub 查询 sso_users 表中是否已存在该用户
* 3. 如果存在,则更新用户信息(如果用户已有 area 值则不更新)
* 4. 如果不存在,则插入新的用户记录
* 5. 返回保存的用户数据和临时 JWT
*
* @param userInfo - 从 IDaaS 获取的用户信息
* @param userRole - 用户角色
* @param tokenExpiresIn - Token过期时间(秒)
* @param area - 用户所属地区,根据端口号确定
* @returns Promise<{success: boolean, data?: SsoUser, tempToken?: string, error?: string}>
*/
export async function saveUserInfo(
userInfo: UserInfo,
userRole: UserRole,
tokenExpiresIn: number,
area?: string
): Promise<{success: boolean, data?: SsoUser, tempToken?: string, error?: string}> {
try {
console.log("开始保存用户信息", userInfo);
// 验证必要字段
if (!userInfo.sub) {
return { success: false, error: "用户唯一标识 sub 不能为空" };
}
// 🔒 安全:在服务端生成临时 JWT,user_id 使用占位符 'login'
// 这样客户端无法看到真实的 user_id
const tempUserInfo: UserInfoForJWT = {
sub: userInfo.sub,
user_id: 'login', // 使用占位符,避免在客户端暴露真实ID
username: 'login',
nick_name: userInfo.nick_name || userInfo.nickname || userInfo.name || "未知用户",
email: userInfo.email,
phone_number: userInfo.phone_number,
ou_id: userInfo.ou_id || "default",
ou_name: userInfo.ou_name || "未知部门",
is_leader: userInfo.is_leader || false,
user_role: userRole
};
const tempToken = JWTUtils.generateJWT(tempUserInfo, tokenExpiresIn);
// 1. 根据 sub 查询是否已存在该用户
const existingUserResult = await postgrestGet<SsoUser[]>("sso_users", {
filter: {
"sub": `eq.${userInfo.sub}`,
"deleted_at": "is.null" // 只查询未删除的记录
},
token: tempToken
});
if (existingUserResult.error) {
console.error("查询用户失败:", existingUserResult.error);
return { success: false, error: `查询用户失败: ${existingUserResult.error}` };
}
const existingUsers = existingUserResult.data || [];
const existingUser = existingUsers.length > 0 ? existingUsers[0] : null;
// 准备要保存的用户数据
// 注意:OAuth返回的字段是nickname,而不是nick_name
const userData: Partial<SsoUser> = {
sub: userInfo.sub,
username: userInfo.username || userInfo.name || userInfo.sub,
nick_name: userInfo.nick_name || userInfo.nickname || userInfo.name || "未知用户",
phone_number: userInfo.phone_number || undefined,
email: userInfo.email || undefined,
ou_id: userInfo.ou_id || "default",
ou_name: userInfo.ou_name || "未知部门",
status: userInfo.status !== undefined ? userInfo.status : 0,
is_leader: userInfo.is_leader || false,
};
if (existingUser) {
// 2. 用户已存在,执行更新操作
console.log("用户已存在,执行更新操作", existingUser.id);
// 只有在现有用户没有 area 或 area 为空时,才更新 area
if (area && !existingUser.area) {
userData.area = area;
console.log("用户原本无地区信息,更新地区为:", area);
} else if (existingUser.area) {
console.log("用户已有地区信息:", existingUser.area, "不更新");
}
const updateResult = await postgrestPut<SsoUser[], Partial<SsoUser>>(
"sso_users",
userData,
{ id: existingUser.id! },
tempToken
);
if (updateResult.error) {
console.error("更新用户失败:", updateResult.error);
return { success: false, error: `更新用户失败: ${updateResult.error}` };
}
console.log("用户信息更新成功");
return {
success: true,
data: Array.isArray(updateResult.data) ? updateResult.data[0] : updateResult.data as unknown as SsoUser,
tempToken // 返回临时 JWT
};
} else {
// 3. 用户不存在,执行插入操作,设置地区信息
console.log("用户不存在,执行插入操作");
// 新用户直接设置 area
if (area) {
userData.area = area;
console.log("新用户,设置地区为:", area);
}
const insertResult = await postgrestPost<SsoUser[], SsoUser>("sso_users", userData as SsoUser, tempToken);
if (insertResult.error) {
console.error("插入用户失败:", insertResult.error);
return { success: false, error: `插入用户失败: ${insertResult.error}` };
}
console.log("用户信息插入成功");
// 4. 给这个用户默认添加一个角色,角色为common
const userData_with_id = Array.isArray(insertResult.data) ? insertResult.data[0] : insertResult.data as unknown as SsoUser;
if (userData_with_id?.id) {
await addDefaultRole(userData_with_id.id, 2, tempToken);
}
return {
success: true,
data: userData_with_id,
tempToken // 返回临时 JWT
};
}
} catch (error) {
console.error("保存用户信息时发生错误:", error);
return {
success: false,
error: `保存用户信息失败: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* 为用户添加默认角色
*
* @param userId - 用户ID
* @param roleId - 角色ID,默认为2common角色)
* @param token - JWT令牌,用于调用postgrest服务
* @returns 添加结果
*/
export async function addDefaultRole(userId: string, roleId: number = 2, token?: string) {
try {
console.log(`为用户 ${userId} 添加默认角色 ${roleId}`);
// 检查用户是否已经有此角色
const existingRoleResult = await postgrestGet<Array<{id: number, user_id: string, role_id: number}>>("user_role", {
filter: {
user_id: `eq.${userId}`,
role_id: `eq.${roleId}`
},
token
});
if (existingRoleResult.error) {
console.error("查询用户角色失败:", existingRoleResult.error);
return { success: false, error: `查询用户角色失败: ${existingRoleResult.error}` };
}
const existingRoles = existingRoleResult.data || [];
if (existingRoles.length > 0) {
console.log("用户已经拥有此角色,跳过添加");
return { success: true, data: existingRoles[0] };
}
// 添加角色
const addRoleResult = await postgrestPost<Array<{id: number, user_id: string, role_id: number}>, {user_id: string, role_id: number}>("user_role", {
user_id: userId,
role_id: roleId
}, token);
if (addRoleResult.error) {
console.error("添加用户角色失败:", addRoleResult.error);
return { success: false, error: `添加用户角色失败: ${addRoleResult.error}` };
}
console.log("用户角色添加成功");
return {
success: true,
data: Array.isArray(addRoleResult.data) ? addRoleResult.data[0] : addRoleResult.data
};
} catch (error) {
console.error("添加用户角色时发生错误:", error);
return {
success: false,
error: `添加用户角色失败: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* 通过用户sub获取用户信息
*
* @param sub - 用户的唯一标识
* @returns 用户信息
*/
export async function getUserBySub(sub: string) {
try {
// console.log(`查询用户: ${sub}`);
const userResult = await postgrestGet<SsoUser[]>("sso_users", {
filter: {
sub: `eq.${sub}`
}
});
if (userResult.error) {
console.error("查询用户失败:", userResult.error);
return { success: false, error: `查询用户失败: ${userResult.error}` };
}
const users = userResult.data || [];
const user = users.length > 0 ? users[0] : null;
if (!user) {
return { success: false, error: "用户不存在" };
}
return { success: true, data: user };
} catch (error) {
console.error("查询用户时发生错误:", error);
return {
success: false,
error: `查询用户失败: ${error instanceof Error ? error.message : String(error)}`
};
}
}
}
+159
View File
@@ -0,0 +1,159 @@
/**
* MinIO 存储管理 API 客户端
* 基于 /api/v2/storage 接口
*/
import { apiRequest } from '../axios-client';
export interface CopyFileRequest {
source_path: string;
destination_path: string;
source_bucket?: string | null;
destination_bucket?: string | null;
}
export interface CopyFileResponse {
success: boolean;
message: string;
source_path: string;
destination_path: string;
}
export interface MoveFileRequest {
source_path: string;
destination_path: string;
}
export interface MoveFileResponse {
success: boolean;
message: string;
source_path: string;
destination_path: string;
}
export interface DeleteFileRequest {
file_path: string;
}
export interface DeleteFileResponse {
success: boolean;
message: string;
file_path: string;
}
/**
* 复制文件
* @param request 复制文件请求参数
* @param token JWT token(可选)
* @returns 复制结果
*/
export async function copyFile(request: CopyFileRequest, token?: string) {
const response = await apiRequest<CopyFileResponse>(
'/api/v2/storage/files/copy',
{
method: 'POST',
data: request,
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
}
);
if (response.error) {
throw new Error(response.error);
}
return response.data;
}
/**
* 移动/重命名文件
* @param request 移动文件请求参数
* @param token JWT token(可选)
* @returns 移动结果
*/
export async function moveFile(request: MoveFileRequest, token?: string) {
const response = await apiRequest<MoveFileResponse>(
'/api/v2/storage/files/move',
{
method: 'POST',
data: request,
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
}
);
if (response.error) {
throw new Error(response.error);
}
return response.data;
}
/**
* 删除文件
* @param request 删除文件请求参数
* @param token JWT token(可选)
* @returns 删除结果
*/
export async function deleteFile(request: DeleteFileRequest, token?: string) {
const response = await apiRequest<DeleteFileResponse>(
'/api/v2/storage/files',
{
method: 'DELETE',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
},
{ file_path: request.file_path }
);
if (response.error) {
throw new Error(response.error);
}
return response.data;
}
/**
* 生成新的文件路径(用于复制模板文件)
* @param originalPath 原始文件路径
* @param area 地区代码
* @returns 新文件路径
*/
export function generateDraftFilePath(originalPath: string, area: string): string {
// 提取文件目录和文件名
const lastSlashIndex = originalPath.lastIndexOf('/');
const directory = lastSlashIndex >= 0 ? originalPath.substring(0, lastSlashIndex) : '';
const fileName = lastSlashIndex >= 0 ? originalPath.substring(lastSlashIndex + 1) : originalPath;
// 提取文件扩展名
const lastDotIndex = fileName.lastIndexOf('.');
const baseName = lastDotIndex >= 0 ? fileName.substring(0, lastDotIndex) : fileName;
const extension = lastDotIndex >= 0 ? fileName.substring(lastDotIndex) : '';
// 生成时间戳和 UUID
const timestamp = Date.now();
const uuid = generateUUID();
// 构建新文件名:原文件名_地区_时间戳_uuid.扩展名
const newFileName = `${baseName}_${area}_${timestamp}_${uuid}${extension}`;
// 构建完整路径
const newPath = directory ? `${directory}/${newFileName}` : newFileName;
return newPath;
}
/**
* 生成简单的 UUID(不依赖外部库)
* @returns UUID 字符串
*/
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
@@ -70,6 +70,7 @@ export async function highlightText(
options?: HighlightOptions
): Promise<HighlightResponse> {
const color = options?.color ?? 16776960; // 默认黄色
// const page = options?.page ?? 1; // 默认第1页
const page = options?.page ?? null; // 默认第1页
console.log('[HighlightSelectText] 调用 Python 脚本高亮文本:', {
+44 -59
View File
@@ -5,16 +5,14 @@
import { useState, useEffect } from 'react';
import type { PlaceholderSchema } from '~/types/contract-draft';
import { messageService } from '~/components/ui/MessageModal';
interface PlaceholderFormProps {
schema: PlaceholderSchema | null;
values: Record<string, string>;
onChange: (values: Record<string, string>) => void;
onBatchReplace: () => void;
onExportDocument: () => void; // 改名:导出文档
onComplete: () => void;
isReplacing: boolean;
isDeleting: boolean; // 改名:是否正在删除
isDeleting: boolean; // 是否正在删除
onSingleReplace?: (key: string, value: string) => void; // 单个替换
onFieldFocus?: (key: string) => void; // 字段聚焦(高亮)
}
@@ -23,10 +21,7 @@ export function PlaceholderForm({
schema,
values,
onChange,
onBatchReplace,
onExportDocument,
onComplete,
isReplacing,
isDeleting,
onSingleReplace,
onFieldFocus
@@ -81,7 +76,19 @@ export function PlaceholderForm({
const handleCompleteClick = () => {
const missing = getMissingRequiredFields();
if (missing.length > 0) {
alert(`请填写以下必填字段:\n${missing.join('\n')}`);
messageService.show({
type: 'warning',
title: '字段校验失败',
message: '请填写以下必填字段:',
children: (
<ul className="list-disc list-inside mt-2 space-y-1">
{missing.map((field, index) => (
<li key={index} className="text-gray-700">{field}</li>
))}
</ul>
),
confirmText: '确定'
});
return;
}
onComplete();
@@ -106,11 +113,36 @@ export function PlaceholderForm({
<div className="h-full flex flex-col bg-white">
{/* 表单头部 */}
<div className="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-white">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-[#004d38] flex items-center justify-center shadow-sm">
<i className="ri-file-edit-line text-white text-base"></i>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-[#004d38] flex items-center justify-center shadow-sm">
<i className="ri-file-edit-line text-white text-base"></i>
</div>
<h2 className="text-lg font-bold text-gray-900"></h2>
</div>
<h2 className="text-lg font-bold text-gray-900"></h2>
{/* 完成按钮 */}
<button
onClick={handleCompleteClick}
disabled={isDeleting}
className={`flex items-center justify-center gap-1.5 px-6 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${
isDeleting
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700 hover:shadow-md active:scale-[0.98]'
}`}
>
{isDeleting ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
<span></span>
</>
) : (
<>
<i className="ri-check-line"></i>
<span></span>
</>
)}
</button>
</div>
</div>
@@ -175,53 +207,6 @@ export function PlaceholderForm({
))}
</div>
</div>
{/* 操作按钮区域(固定在底部) */}
<div className="border-t border-gray-200 px-6 py-3 bg-gray-50 flex gap-2">
<button
onClick={onBatchReplace}
disabled={isReplacing || isDeleting}
className={`flex-1 flex items-center justify-center gap-1.5 px-4 py-2 text-white text-sm font-medium rounded-lg transition-all duration-150 ${
isReplacing || isDeleting
? 'bg-gray-300 cursor-not-allowed'
: 'bg-gradient-to-r from-primary to-[#004d38] hover:shadow-md active:scale-[0.98]'
}`}
>
{isReplacing ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
<span></span>
</>
) : (
<>
<i className="ri-refresh-line"></i>
<span></span>
</>
)}
</button>
<button
onClick={handleCompleteClick}
disabled={isReplacing || isDeleting}
className={`flex-1 flex items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${
isReplacing || isDeleting
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700 hover:shadow-md active:scale-[0.98]'
}`}
>
{isDeleting ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
<span></span>
</>
) : (
<>
<i className="ri-check-line"></i>
<span></span>
</>
)}
</button>
</div>
</div>
);
}
+146 -127
View File
@@ -11,7 +11,9 @@ import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePr
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
import { getDraftById, deleteDraft } from '~/api/contracts/draft-service.server';
import { getUserSession } from '~/api/login/auth.server';
import { deleteFile } from '~/api/storage/minio-client';
import { toastService } from '~/components/ui/Toast';
import { messageService } from '~/components/ui/MessageModal';
import { downloadFile } from '~/api/axios-client';
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
import path from 'path';
@@ -51,16 +53,28 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const jwt = frontendJWT || undefined;
// 【临时测试】使用测试文档和模拟数据
// const testDocPath = path.join(process.cwd(), 'public', 'testWork', '买卖合同 (1).docx');
const testDocPath = 'contract-template/买卖/买卖合同范本.docx';
// 从 URL 参数获取文件路径、模板 ID 和标题
const url = new URL(request.url);
const filePath = url.searchParams.get('filePath');
const templateId = url.searchParams.get('templateId');
const title = url.searchParams.get('title');
// 创建临时的草稿对象(用于测试)
if (!filePath) {
throw new Response('文件路径参数缺失', { status: 400 });
}
if (!templateId) {
throw new Response('模板ID参数缺失', { status: 400 });
}
console.log('[Loader] 起草合同:', { filePath, templateId, title });
// 创建草稿对象
const draft: DraftedContract = {
id: draftId,
template_id: 1,
file_path: testDocPath,
title: '买卖合同-测试草稿',
template_id: parseInt(templateId),
file_path: filePath,
title: title || '未命名合同',
placeholder_values: {},
status: 'draft',
created_by: parseInt(userInfo.sub),
@@ -72,10 +86,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
let placeholderSchema: PlaceholderSchema | null = null;
try {
console.log('[Loader] 使用测试文档:', testDocPath);
console.log('[Loader] 使用文件:', filePath);
// 提取占位符
const placeholders = await extractPlaceholdersFromDocx(testDocPath);
const placeholders = await extractPlaceholdersFromDocx(filePath);
console.log('[Loader] 提取到的占位符:', placeholders);
// 生成默认 schema
@@ -86,15 +100,15 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
placeholderSchema = null;
}
// 创建临时的模板对象(用于测试)
// 创建模板对象
const template: ContractTemplate = {
id: 1,
title: '买卖合同模板',
template_code: 'TEST-001',
id: parseInt(templateId),
title: title || '合同模板',
template_code: 'DRAFT-' + templateId,
category_id: 1,
file_path: testDocPath,
file_path: filePath,
file_format: 'docx',
description: '测试用买卖合同模板',
description: '起草中的合同',
is_featured: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -103,12 +117,13 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return Response.json({
draft,
template
template,
returnUrl: `/contract-template/detail/${templateId}`
});
}
/**
* Action 函数:处理删除草稿
* Action 函数:处理文件删除
*/
export async function action({ request, params }: ActionFunctionArgs) {
const draftId = parseInt(params.draftId || '0');
@@ -123,19 +138,26 @@ export async function action({ request, params }: ActionFunctionArgs) {
return Response.json({ error: '未登录' }, { status: 401 });
}
const userId = parseInt(userInfo.sub);
const jwt = frontendJWT || undefined;
try {
// 解析表单数据
const formData = await request.formData();
const actionType = formData.get('_action') as string;
if (actionType === 'delete') {
// 删除草稿记录
await deleteDraft(draftId, userId, jwt);
if (actionType === 'deleteFile') {
const filePath = formData.get('filePath') as string;
return Response.json({ success: true, message: '草稿已删除' });
if (!filePath) {
return Response.json({ error: '文件路径缺失' }, { status: 400 });
}
// 删除 MinIO 文件,传递 JWT
await deleteFile({ file_path: filePath }, jwt);
return Response.json({
success: true,
message: '文件删除成功'
});
}
return Response.json({ error: '无效的操作类型' }, { status: 400 });
@@ -149,14 +171,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
export default function ContractDraftPage() {
const { draft, template } = useLoaderData<typeof loader>();
const loaderData = useLoaderData<typeof loader>();
const { draft, template, returnUrl } = loaderData;
const navigate = useNavigate();
const fetcher = useFetcher<ActionData>();
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>(
draft.placeholder_values || {}
);
const [isReplacing, setIsReplacing] = useState(false);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
searchText: string;
@@ -165,56 +187,88 @@ export default function ContractDraftPage() {
} | undefined>(undefined);
const filePreviewRef = useRef<FilePreviewHandle>(null);
const hasDeletedNormally = useRef(false); // 标记是否已通过正常流程删除
const currentPathRef = useRef(window.location.pathname); // 保存当前路由路径
// 从 fetcher.state 判断是否正在操作
const isDeleting = fetcher.state !== 'idle';
// 处理 fetcher 响应(删除草稿
// 处理 fetcher 响应(文件删除)
useEffect(() => {
if (fetcher.data?.success && fetcher.data.message === '草稿已删除') {
// 删除成功,跳转到模板列表
navigate('/contract-template');
if (fetcher.data?.success) {
// 标记已通过正常流程删除
hasDeletedNormally.current = true;
// 文件删除成功,显示提示并跳转
// toastService.success('合同已完成');
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
if (returnUrl) {
navigate(returnUrl);
} else {
navigate('/contract-template');
}
}, 500);
} else if (fetcher.data?.error) {
toastService.error(fetcher.data.error);
}
}, [fetcher.data, navigate]);
}, [fetcher.data, navigate, returnUrl]);
// 监听页面关闭事件 - 自动删除草稿
// 页面卸载或路由切换时清理文件
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
// 发送删除请求(使用 sendBeacon 确保请求发送)
const formData = new FormData();
formData.append('_action', 'delete');
const deleteFileSync = () => {
// 如果已经通过正常流程删除,不再重复删除
if (hasDeletedNormally.current || !draft.file_path) {
return;
}
navigator.sendBeacon(
`/contract-draft/${draft.id}`,
formData
);
// console.log('[Cleanup] 尝试删除文件:', draft.file_path);
// console.log('[Cleanup] 使用路径:', currentPathRef.current);
try {
// 直接使用同步 XMLHttpRequest 确保删除请求真正执行
// 使用保存的原始路径,而不是当前的 window.location.pathname(可能已切换)
const xhr = new XMLHttpRequest();
xhr.open('POST', currentPathRef.current, false); // false = 同步请求
const formData = new FormData();
formData.append('_action', 'deleteFile');
formData.append('filePath', draft.file_path);
xhr.send(formData);
// console.log('[Cleanup] 同步删除完成,状态:', xhr.status);
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
// console.log('[Cleanup] ✅ 文件删除成功:', response);
} catch (e) {
// console.log('[Cleanup] ✅ 文件删除成功(状态码200');
}
} else {
console.error('[Cleanup] ❌ 文件删除失败,状态码:', xhr.status);
}
} catch (error) {
console.error('[Cleanup] 删除文件失败:', error);
}
};
const handleBeforeUnload = () => {
deleteFileSync();
};
// 监听页面卸载事件
window.addEventListener('beforeunload', handleBeforeUnload);
// 清理事件监听器
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [draft.id]);
// 组件卸载时删除草稿(处理路由跳转的情况)
useEffect(() => {
return () => {
// 组件卸载时删除草稿记录
const formData = new FormData();
formData.append('_action', 'delete');
fetch(`/contract-draft/${draft.id}`, {
method: 'POST',
body: formData,
keepalive: true // 确保请求在页面关闭后仍然发送
}).catch(err => {
console.error('[Draft] 删除草稿失败:', err);
});
// 组件卸载时(路由切换),执行删除
deleteFileSync();
};
}, [draft.id]);
}, [draft.file_path]);
// 单个替换占位符
const handleSingleReplace = async (key: string, value: string) => {
@@ -231,7 +285,7 @@ export default function ContractDraftPage() {
// 短暂延迟后清除参数,以便下次可以重新触发
setTimeout(() => {
setAiSuggestionReplace(undefined);
toastService.success(`已替换 ${key}`);
// toastService.success(`已替换 ${key}`);
}, 1000);
};
@@ -249,52 +303,7 @@ export default function ContractDraftPage() {
}, 100);
};
// 批量替换占位符
const handleBatchReplace = async () => {
setIsReplacing(true);
try {
// 获取 CollaboraViewer 引用
const collaboraRef = filePreviewRef.current?.collaboraViewerRef.current;
if (!collaboraRef?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
setIsReplacing(false);
return;
}
console.log('[Draft] 开始批量替换占位符:', placeholderValues);
// 批量替换所有占位符
let replaceCount = 0;
for (const [key, value] of Object.entries(placeholderValues)) {
if (value) { // 只替换有值的字段
const placeholder = `{{${key}}}`;
console.log(`[Draft] 替换: ${placeholder} -> ${value}`);
// 调用 unoCommands.replaceAll 方法
if (collaboraRef.unoCommands?.replaceAll) {
await collaboraRef.unoCommands.replaceAll(placeholder, value);
replaceCount++;
// 添加延迟避免 Collabora 响应不过来
await new Promise(resolve => setTimeout(resolve, 100));
} else {
console.warn('[Draft] unoCommands.replaceAll 方法不可用');
}
}
}
console.log(`[Draft] 替换完成,共替换 ${replaceCount} 个占位符`);
toastService.success(`占位符替换完成(${replaceCount}个)`);
} catch (error) {
console.error('[Draft] 替换失败:', error);
toastService.error(`替换失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsReplacing(false);
}
};
// 导出文档(下载文件)
// 导出文档(下载当前编辑的文件)
const handleExportDocument = async () => {
if (!draft.file_path) {
toastService.error('文件路径不存在,无法下载');
@@ -304,7 +313,7 @@ export default function ContractDraftPage() {
try {
toastService.info('正在下载文件...');
// 使用统一的下载方法
// 使用 axios-client 的 downloadFile 方法下载文件
const blob = await downloadFile(draft.file_path);
// 创建Blob URL
@@ -336,30 +345,43 @@ export default function ContractDraftPage() {
}
};
// 完成起草(下载文件 + 删除草稿记录
// 完成起草(下载文件 + 删除 MinIO 文件
const handleComplete = async () => {
// 1. 先下载文件
await handleExportDocument();
try {
// 1. 先下载文件
await handleExportDocument();
// 2. 延迟后删除草稿记录并跳转
setTimeout(() => {
// 2. 删除 MinIO 文件
const formData = new FormData();
formData.append('_action', 'delete');
formData.append('_action', 'deleteFile');
formData.append('filePath', draft.file_path);
fetcher.submit(formData, { method: 'post' });
}, 500);
} catch (error) {
console.error('[Complete] 操作失败:', error);
toastService.error('操作失败,请重试');
}
};
// 返回模板详情页(删除草稿)
// 返回模板详情页
const handleBack = () => {
if (confirm('确定要返回吗?草稿将被删除。')) {
// 删除草稿记录
const formData = new FormData();
formData.append('_action', 'delete');
messageService.show({
type: 'warning',
title: '确认返回',
message: '确定要返回吗?未保存的更改将丢失。',
confirmText: '确定返回',
cancelText: '取消',
onConfirm: () => {
// 删除 MinIO 文件
const formData = new FormData();
formData.append('_action', 'deleteFile');
formData.append('filePath', draft.file_path);
fetcher.submit(formData, { method: 'post' });
// 删除成功后会自动跳转(通过 useEffect)
}
fetcher.submit(formData, { method: 'post' });
// 注意:文件删除后会在 useEffect 中跳转
}
});
};
return (
@@ -376,11 +398,11 @@ export default function ContractDraftPage() {
</button>
<div className="border-l border-gray-300 h-10"></div>
<div className="flex flex-col gap-1">
<h1 className="text-xl font-bold text-gray-900 tracking-tight">{draft.title}</h1>
<p className="text-sm text-gray-500 flex items-center gap-2">
<i className="ri-file-text-line text-base"></i>
<span>{template.title}</span>
</p>
{/* <h1 className="text-xl font-bold text-gray-900 tracking-tight">{draft.title}</h1> */}
<h1 className="flex items-center gap-2">
<i className="ri-file-text-line"></i>
<span>{template.title.replace(/-[\d-]+$/, '')}</span>
</h1>
</div>
</div>
<div className="flex items-center gap-3">
@@ -422,10 +444,7 @@ export default function ContractDraftPage() {
schema={template.placeholder_schema as any}
values={placeholderValues}
onChange={setPlaceholderValues}
onBatchReplace={handleBatchReplace}
onExportDocument={handleExportDocument}
onComplete={handleComplete}
isReplacing={isReplacing}
isDeleting={isDeleting}
onSingleReplace={handleSingleReplace}
onFieldFocus={handleFieldFocus}
+66 -29
View File
@@ -1,18 +1,17 @@
import type { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { useLoaderData, useNavigate, useSubmit } from '@remix-run/react';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { getContractTemplate } from '~/api/contract-template/templates';
import type { ContractTemplate } from '~/api/contract-template/templates';
import styles from '~/styles/pages/contract-template.css?url';
import filePreviewStyles from '~/styles/components/file-preview-isolation.css?url';
import { getUserSession } from '~/api/login/auth.server';
import { createDraftContract } from '~/api/contracts/draft-service.server';
import { apiRequest, downloadFile } from '~/api/axios-client';
// 导入FilePreview组件
import { FilePreview } from '~/components/reviews';
// 导入统一的下载方法和提示服务
import { downloadFile } from '~/api/axios-client';
import { toastService } from '~/components/ui/Toast';
export const links = () => [
@@ -70,7 +69,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
* Action 函数:处理起草合同请求
*/
export async function action({ request, params }: ActionFunctionArgs) {
const templateId = parseInt(params.id || '0');
const templateId = params.id!;
if (!templateId) {
return Response.json({ error: '模板ID无效' }, { status: 400 });
@@ -86,27 +85,60 @@ export async function action({ request, params }: ActionFunctionArgs) {
// 解析表单数据
const formData = await request.formData();
const title = formData.get('title') as string;
const draftFilePath = formData.get('draftFilePath') as string | null;
const originalFilePath = formData.get('originalFilePath') as string;
if (!title) {
return Response.json({ error: '标题不能为空' }, { status: 400 });
}
// 创建草稿记录(到时候可以换成接口,使用接口来在minio中生成备份文件:备份文件可以用时间戳+uuid来保证唯一性。)
// const draft = await createDraftContract(
// {
// templateId,
// title,
// draftFilePath: draftFilePath || undefined
// },
// parseInt(userInfo.sub),
// draftFilePath || undefined,
// frontendJWT || undefined
// );
if (!originalFilePath) {
return Response.json({ error: '文件路径不存在' }, { status: 400 });
}
// 重定向到草稿编辑页面
// return redirect(`/contract-draft/${draft.id}`);
return redirect(`/contract-draft/1`);
// 生成新文件路径
const area = userInfo.area || 'unknown';
const timestamp = Date.now();
const uuid = crypto.randomUUID();
// 提取文件目录和文件名
const lastSlashIndex = originalFilePath.lastIndexOf('/');
const directory = lastSlashIndex >= 0 ? originalFilePath.substring(0, lastSlashIndex) : '';
const fileName = lastSlashIndex >= 0 ? originalFilePath.substring(lastSlashIndex + 1) : originalFilePath;
// 提取文件扩展名
const lastDotIndex = fileName.lastIndexOf('.');
const baseName = lastDotIndex >= 0 ? fileName.substring(0, lastDotIndex) : fileName;
const extension = lastDotIndex >= 0 ? fileName.substring(lastDotIndex) : '';
// 构建新文件名
const newFileName = `${baseName}_${area}_${timestamp}_${uuid}${extension}`;
const newFilePath = directory ? `${directory}/${newFileName}` : newFileName;
console.log('[Draft] 复制文件:', { originalFilePath, newFilePath });
// 调用 MinIO 复制文件 API(需要传递 JWT)
const jwt = frontendJWT || undefined;
const copyResponse = await apiRequest('/api/v2/storage/files/copy', {
method: 'POST',
data: {
source_path: originalFilePath,
destination_path: newFilePath
},
headers: {
'Authorization': jwt ? `Bearer ${jwt}` : ''
}
});
if (copyResponse.error) {
console.error('[Draft] 文件复制失败:', copyResponse.error);
return Response.json({ error: `文件复制失败: ${copyResponse.error}` }, { status: 500 });
}
console.log('[Draft] 文件复制成功:', copyResponse.data);
// 重定向到草稿编辑页面,通过 URL 参数传递文件路径和模板 ID
const draftUrl = `/contract-draft/1?filePath=${encodeURIComponent(newFilePath)}&templateId=${templateId}&title=${encodeURIComponent(title)}`;
return redirect(draftUrl);
} catch (error) {
console.error('[Template Detail] 创建草稿失败:', error);
return Response.json(
@@ -124,6 +156,12 @@ export default function ContractTemplateDetail() {
// 注释掉收藏功能
// const [isFavorited, setIsFavorited] = useState(false);
// 防止页面加载时自动滚动到预览区域(由 Collabora iframe 的 tabIndex 导致)
useEffect(() => {
// 页面加载后立即滚动回顶部
window.scrollTo({ top: 0, behavior: 'instant' });
}, []);
const handleBack = () => {
navigate(-1);
};
@@ -181,21 +219,20 @@ export default function ContractTemplateDetail() {
const handleStartDraft = () => {
if (isCreatingDraft) return;
// 生成默认标题
// const defaultTitle = `${template.title}-${new Date().toLocaleDateString('zh-CN').replace(/\//g, '')}`;
if (!template.file_path) {
toastService.error('模板文件路径不存在,无法起草');
return;
}
// // 提示用户输入标题
// const title = prompt('请输入合同标题:', defaultTitle);
// if (!title) return;
// 生成默认标题
const defaultTitle = `${template.title}-${new Date().toLocaleDateString('zh-CN').replace(/\//g, '')}`;
setIsCreatingDraft(true);
// 使用 Remix 的 submit 提交表单
const formData = new FormData();
// formData.append('title', title.trim());
formData.append('title', '买卖合同-拟起草合同');
// 可选:如果需要复制文件,可以先调用文件复制服务,然后传递 draftFilePath
// formData.append('draftFilePath', draftFilePath);
formData.append('title', defaultTitle);
formData.append('originalFilePath', template.file_path);
submit(formData, { method: 'post' });
};
@@ -430,7 +467,7 @@ export default function ContractTemplateDetail() {
{/* 合同预览 - 只有当存在pdf_file_path时才显示 */}
{fileContent && (
<div className="content-section mb-8" id="template-preview">
<div className="content-section mb-8">
<h3 className="section-title text-xl font-semibold mb-4"></h3>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div
+1 -1
View File
@@ -188,7 +188,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
toastService.success('更新文档成功');
return redirect("/documents");
return redirect("/documents/list");
} catch (error) {
console.error("更新文档失败2:", error);
return Response.json({
+1 -1
View File
@@ -382,7 +382,7 @@ export default function RuleNew() {
const fetchEvaluationPointGroups = useCallback(async () => {
try {
// console.log("🔍 [fetchEvaluationPointGroups] 开始获取评查点组数据");
const response = await postgrestGet('evaluation_point_groups', { token: frontendJWT });
const response = await postgrestGet('/api/postgrest/proxy/evaluation_point_groups', { token: frontendJWT });
// console.log("🔍 [fetchEvaluationPointGroups] API响应:", response);
+776
View File
@@ -0,0 +1,776 @@
# MinIO 存储管理 API 文档
## 概述
MinIO 存储管理模块提供对象存储的完整管理能力,包括存储桶管理和文件操作。
**基础路径**: `/api/v2/storage`
**认证要求**: 当前无需认证(后续可按需添加)
---
## 接口列表
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/buckets` | 创建存储桶 |
| DELETE | `/buckets` | 删除存储桶 |
| GET | `/buckets` | 列出所有存储桶 |
| POST | `/files/copy` | 复制文件 |
| POST | `/files/move` | 移动/重命名文件 |
| DELETE | `/files` | 删除单个文件 |
| POST | `/files/batch-delete` | 批量删除文件 |
| GET | `/files/download` | 下载文件 |
| GET | `/files` | 列出目录文件 |
| GET | `/files/metadata` | 获取文件元数据 |
| GET | `/files/presigned-url` | 获取预签名URL |
---
## 存储桶管理
### 1. 创建存储桶
**POST** `/api/v2/storage/buckets`
创建新的 MinIO 存储桶。
#### 请求体
```json
{
"bucket_name": "my-new-bucket"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| bucket_name | string | 是 | 存储桶名称(3-63字符,小写字母、数字、连字符) |
#### 响应示例
**成功 (200)**
```json
{
"success": true,
"message": "存储桶创建成功",
"bucket_name": "my-new-bucket"
}
```
**存储桶已存在 (200)**
```json
{
"success": false,
"message": "存储桶已存在: my-new-bucket",
"bucket_name": "my-new-bucket"
}
```
#### cURL 示例
```bash
curl -X POST "http://localhost:8000/api/v2/storage/buckets" \
-H "Content-Type: application/json" \
-d '{"bucket_name": "my-new-bucket"}'
```
---
### 2. 删除存储桶
**DELETE** `/api/v2/storage/buckets`
删除 MinIO 存储桶。支持强制删除(会先删除桶内所有文件)。
#### 请求体
```json
{
"bucket_name": "my-bucket",
"force": true
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| bucket_name | string | 是 | 存储桶名称 |
| force | boolean | 否 | 强制删除(默认 false,为 true 时会先删除桶内所有文件) |
#### 响应示例
**成功 (200)**
```json
{
"success": true,
"message": "存储桶删除成功",
"bucket_name": "my-bucket",
"files_deleted": 15
}
```
**存储桶不存在 (200)**
```json
{
"success": false,
"message": "存储桶不存在: my-bucket",
"bucket_name": "my-bucket",
"files_deleted": 0
}
```
#### cURL 示例
```bash
# 普通删除(桶必须为空)
curl -X DELETE "http://localhost:8000/api/v2/storage/buckets" \
-H "Content-Type: application/json" \
-d '{"bucket_name": "my-bucket"}'
# 强制删除(会删除桶内所有文件)
curl -X DELETE "http://localhost:8000/api/v2/storage/buckets" \
-H "Content-Type: application/json" \
-d '{"bucket_name": "my-bucket", "force": true}'
```
---
### 3. 列出存储桶
**GET** `/api/v2/storage/buckets`
列出所有 MinIO 存储桶。
#### 响应示例
```json
{
"success": true,
"message": "共 3 个存储桶",
"buckets": [
{
"name": "docauditai",
"creation_date": "2024-01-15T08:30:00+00:00"
},
{
"name": "backup",
"creation_date": "2024-02-20T10:15:00+00:00"
},
{
"name": "temp",
"creation_date": "2024-03-01T14:00:00+00:00"
}
]
}
```
#### cURL 示例
```bash
curl -X GET "http://localhost:8000/api/v2/storage/buckets"
```
---
## 文件操作
### 4. 复制文件
**POST** `/api/v2/storage/files/copy`
在 MinIO 内复制文件。支持跨存储桶复制。
#### 请求体
```json
{
"source_path": "documents/contract.pdf",
"destination_path": "backup/contract-2024.pdf",
"source_bucket": null,
"destination_bucket": null
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| source_path | string | 是 | 源文件路径 |
| destination_path | string | 是 | 目标文件路径 |
| source_bucket | string | 否 | 源存储桶(默认使用配置的默认桶) |
| destination_bucket | string | 否 | 目标存储桶(默认使用配置的默认桶) |
#### 响应示例
```json
{
"success": true,
"message": "文件复制成功",
"source_path": "docauditai/documents/contract.pdf",
"destination_path": "docauditai/backup/contract-2024.pdf"
}
```
#### cURL 示例
```bash
# 同桶内复制
curl -X POST "http://localhost:8000/api/v2/storage/files/copy" \
-H "Content-Type: application/json" \
-d '{
"source_path": "documents/contract.pdf",
"destination_path": "backup/contract-2024.pdf"
}'
# 跨桶复制
curl -X POST "http://localhost:8000/api/v2/storage/files/copy" \
-H "Content-Type: application/json" \
-d '{
"source_path": "documents/contract.pdf",
"destination_path": "contract.pdf",
"source_bucket": "docauditai",
"destination_bucket": "backup"
}'
```
---
### 5. 移动/重命名文件
**POST** `/api/v2/storage/files/move`
移动文件(复制后删除源文件)。在同一目录内移动即为重命名。
#### 请求体
```json
{
"source_path": "documents/old-name.pdf",
"destination_path": "documents/new-name.pdf"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| source_path | string | 是 | 源文件路径 |
| destination_path | string | 是 | 目标文件路径 |
#### 响应示例
```json
{
"success": true,
"message": "文件移动成功",
"source_path": "documents/old-name.pdf",
"destination_path": "documents/new-name.pdf"
}
```
#### cURL 示例
```bash
# 重命名文件
curl -X POST "http://localhost:8000/api/v2/storage/files/move" \
-H "Content-Type: application/json" \
-d '{
"source_path": "documents/report-v1.pdf",
"destination_path": "documents/report-v2.pdf"
}'
# 移动到其他目录
curl -X POST "http://localhost:8000/api/v2/storage/files/move" \
-H "Content-Type: application/json" \
-d '{
"source_path": "temp/upload.pdf",
"destination_path": "documents/final.pdf"
}'
```
---
### 6. 删除单个文件
**DELETE** `/api/v2/storage/files`
删除 MinIO 中的单个文件。
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file_path | string | 是 | 文件路径 |
#### 响应示例
**成功 (200)**
```json
{
"success": true,
"message": "文件删除成功",
"file_path": "documents/old-file.pdf"
}
```
**文件不存在 (200)**
```json
{
"success": false,
"message": "文件不存在: documents/old-file.pdf",
"file_path": "documents/old-file.pdf"
}
```
#### cURL 示例
```bash
curl -X DELETE "http://localhost:8000/api/v2/storage/files?file_path=documents/old-file.pdf"
```
---
### 7. 批量删除文件
**POST** `/api/v2/storage/files/batch-delete`
批量删除多个文件。
#### 请求体
```json
{
"file_paths": [
"temp/file1.pdf",
"temp/file2.pdf",
"temp/file3.pdf"
]
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file_paths | string[] | 是 | 文件路径列表(至少1个) |
#### 响应示例
```json
{
"success": true,
"message": "删除完成: 成功 3, 失败 0",
"deleted_count": 3,
"failed_count": 0,
"failed_paths": []
}
```
**部分失败 (200)**
```json
{
"success": false,
"message": "删除完成: 成功 2, 失败 1",
"deleted_count": 2,
"failed_count": 1,
"failed_paths": ["temp/not-exist.pdf"]
}
```
#### cURL 示例
```bash
curl -X POST "http://localhost:8000/api/v2/storage/files/batch-delete" \
-H "Content-Type: application/json" \
-d '{
"file_paths": [
"temp/file1.pdf",
"temp/file2.pdf",
"temp/file3.pdf"
]
}'
```
---
### 8. 下载文件
**GET** `/api/v2/storage/files/download`
从 MinIO 下载文件。返回文件流。
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file_path | string | 是 | 文件路径 |
| filename | string | 否 | 下载时显示的文件名 |
#### 响应
返回文件二进制流,包含以下响应头:
```
Content-Type: application/pdf
Content-Disposition: attachment; filename="contract.pdf"
Content-Length: 1234567
```
#### cURL 示例
```bash
# 下载文件
curl -X GET "http://localhost:8000/api/v2/storage/files/download?file_path=documents/contract.pdf" \
-o contract.pdf
# 指定下载文件名
curl -X GET "http://localhost:8000/api/v2/storage/files/download?file_path=documents/contract.pdf&filename=my-contract.pdf" \
-o my-contract.pdf
```
#### 浏览器直接下载
```
http://localhost:8000/api/v2/storage/files/download?file_path=documents/contract.pdf
```
---
### 9. 列出目录文件
**GET** `/api/v2/storage/files`
列出指定目录下的文件。
#### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| directory_path | string | 否 | "" | 目录路径 |
| recursive | boolean | 否 | false | 是否递归列出子目录 |
| max_files | int | 否 | 100 | 最大返回数量(1-1000 |
| marker | string | 否 | null | 分页标记 |
#### 响应示例
```json
{
"success": true,
"message": "共 5 个文件",
"files": [
"documents/contract-001.pdf",
"documents/contract-002.pdf",
"documents/contract-003.pdf",
"documents/report.docx",
"documents/summary.xlsx"
],
"total_count": 5,
"next_marker": null
}
```
**分页响应 (200)**
```json
{
"success": true,
"message": "共 100 个文件",
"files": ["..."],
"total_count": 100,
"next_marker": "documents/file-100.pdf"
}
```
#### cURL 示例
```bash
# 列出根目录
curl -X GET "http://localhost:8000/api/v2/storage/files"
# 列出指定目录
curl -X GET "http://localhost:8000/api/v2/storage/files?directory_path=documents"
# 递归列出
curl -X GET "http://localhost:8000/api/v2/storage/files?directory_path=documents&recursive=true"
# 分页查询
curl -X GET "http://localhost:8000/api/v2/storage/files?directory_path=documents&max_files=50"
# 获取下一页
curl -X GET "http://localhost:8000/api/v2/storage/files?directory_path=documents&max_files=50&marker=documents/file-50.pdf"
```
---
### 10. 获取文件元数据
**GET** `/api/v2/storage/files/metadata`
获取文件的详细元数据信息。
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file_path | string | 是 | 文件路径 |
#### 响应示例
```json
{
"success": true,
"message": "获取成功",
"file_info": {
"path": "documents/contract.pdf",
"size": 1234567,
"content_type": "application/pdf",
"created_time": "2024-03-15T10:30:00+00:00",
"modified_time": "2024-03-15T10:30:00+00:00",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"url": "http://minio:9000/docauditai/documents/contract.pdf?X-Amz-..."
}
}
```
#### cURL 示例
```bash
curl -X GET "http://localhost:8000/api/v2/storage/files/metadata?file_path=documents/contract.pdf"
```
---
### 11. 获取预签名URL
**GET** `/api/v2/storage/files/presigned-url`
获取文件的预签名下载URL,可用于临时分享。
#### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| file_path | string | 是 | - | 文件路径 |
| expires | int | 否 | 3600 | 过期时间(秒),范围 60-604800(7天) |
#### 响应示例
```json
{
"success": true,
"message": "获取成功",
"url": "http://minio:9000/docauditai/documents/contract.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...",
"expires_in": 3600
}
```
#### cURL 示例
```bash
# 默认1小时过期
curl -X GET "http://localhost:8000/api/v2/storage/files/presigned-url?file_path=documents/contract.pdf"
# 自定义过期时间(1天)
curl -X GET "http://localhost:8000/api/v2/storage/files/presigned-url?file_path=documents/contract.pdf&expires=86400"
# 最长7天
curl -X GET "http://localhost:8000/api/v2/storage/files/presigned-url?file_path=documents/contract.pdf&expires=604800"
```
---
## 前端集成示例
### JavaScript/Fetch
```javascript
const API_BASE = 'http://localhost:8000/api/v2/storage';
// 列出存储桶
async function listBuckets() {
const response = await fetch(`${API_BASE}/buckets`);
return response.json();
}
// 创建存储桶
async function createBucket(bucketName) {
const response = await fetch(`${API_BASE}/buckets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket_name: bucketName })
});
return response.json();
}
// 复制文件
async function copyFile(sourcePath, destPath) {
const response = await fetch(`${API_BASE}/files/copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_path: sourcePath,
destination_path: destPath
})
});
return response.json();
}
// 重命名文件
async function renameFile(oldPath, newPath) {
const response = await fetch(`${API_BASE}/files/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_path: oldPath,
destination_path: newPath
})
});
return response.json();
}
// 删除文件
async function deleteFile(filePath) {
const response = await fetch(
`${API_BASE}/files?file_path=${encodeURIComponent(filePath)}`,
{ method: 'DELETE' }
);
return response.json();
}
// 下载文件
function downloadFile(filePath, filename) {
const url = `${API_BASE}/files/download?file_path=${encodeURIComponent(filePath)}`;
if (filename) {
url += `&filename=${encodeURIComponent(filename)}`;
}
window.open(url, '_blank');
}
// 获取文件列表
async function listFiles(directory = '', recursive = false) {
const params = new URLSearchParams({
directory_path: directory,
recursive: recursive.toString()
});
const response = await fetch(`${API_BASE}/files?${params}`);
return response.json();
}
// 获取预签名URL
async function getPresignedUrl(filePath, expires = 3600) {
const params = new URLSearchParams({
file_path: filePath,
expires: expires.toString()
});
const response = await fetch(`${API_BASE}/files/presigned-url?${params}`);
return response.json();
}
```
### Vue 3 组件示例
```vue
<template>
<div class="file-manager">
<div class="file-list">
<div v-for="file in files" :key="file" class="file-item">
<span>{{ file }}</span>
<div class="actions">
<button @click="handleDownload(file)">下载</button>
<button @click="handleRename(file)">重命名</button>
<button @click="handleDelete(file)">删除</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const API_BASE = '/api/v2/storage';
const files = ref([]);
onMounted(async () => {
await loadFiles();
});
async function loadFiles() {
const response = await fetch(`${API_BASE}/files?directory_path=documents&recursive=true`);
const data = await response.json();
if (data.success) {
files.value = data.files;
}
}
function handleDownload(filePath) {
window.open(`${API_BASE}/files/download?file_path=${encodeURIComponent(filePath)}`);
}
async function handleRename(filePath) {
const newName = prompt('请输入新文件名:');
if (!newName) return;
const directory = filePath.substring(0, filePath.lastIndexOf('/'));
const newPath = `${directory}/${newName}`;
const response = await fetch(`${API_BASE}/files/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_path: filePath,
destination_path: newPath
})
});
const data = await response.json();
if (data.success) {
await loadFiles();
} else {
alert(data.message);
}
}
async function handleDelete(filePath) {
if (!confirm(`确定删除 ${filePath}`)) return;
const response = await fetch(
`${API_BASE}/files?file_path=${encodeURIComponent(filePath)}`,
{ method: 'DELETE' }
);
const data = await response.json();
if (data.success) {
await loadFiles();
} else {
alert(data.message);
}
}
</script>
```
---
## 错误处理
所有接口返回统一格式:
```json
{
"success": false,
"message": "错误描述信息"
}
```
### 常见错误
| 错误 | 说明 | 解决方案 |
|------|------|----------|
| 存储桶不存在 | 操作的存储桶未找到 | 检查桶名是否正确 |
| 文件不存在 | 操作的文件路径无效 | 检查文件路径 |
| 存储桶非空 | 删除桶时桶内有文件 | 使用 force=true 强制删除 |
| 权限不足 | MinIO 访问凭证问题 | 检查配置的 access_key/secret_key |
---
## 测试
使用测试脚本进行完整测试:
```bash
python scripts/test_minio_api.py --host localhost --port 8000
```