优化交叉评查详情页面
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
# OAuth2.0 登录数据流程说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本文档详细说明了中国烟草AI合同及卷宗审核系统中OAuth2.0登录的完整数据流程,从用户点击登录按钮到最终完成身份认证的全过程。
|
||||
|
||||
## 🔄 完整流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户浏览器
|
||||
participant LoginPage as 登录页面(/login)
|
||||
participant IDaaS as IDaaS认证服务器
|
||||
participant CallbackPage as 回调页面(/callback)
|
||||
participant Session as 会话管理
|
||||
participant MainApp as 主应用
|
||||
|
||||
Note over User,MainApp: 1. 用户发起登录
|
||||
User->>LoginPage: 访问登录页面
|
||||
LoginPage->>User: 显示登录界面
|
||||
User->>LoginPage: 点击"统一身份认证登录"
|
||||
|
||||
Note over User,MainApp: 2. 生成授权URL并跳转
|
||||
LoginPage->>LoginPage: generateState() 生成随机状态值
|
||||
LoginPage->>LoginPage: localStorage保存oauth_state
|
||||
LoginPage->>LoginPage: 构建授权URL
|
||||
LoginPage->>User: window.location.href = authorizeUrl
|
||||
|
||||
Note over User,MainApp: 3. IDaaS认证流程
|
||||
User->>IDaaS: 跳转到IDaaS登录页面
|
||||
IDaaS->>User: 显示统一登录界面
|
||||
User->>IDaaS: 输入用户名密码完成认证
|
||||
IDaaS->>User: 重定向回应用 (带code和state)
|
||||
|
||||
Note over User,MainApp: 4. 授权码处理
|
||||
User->>CallbackPage: 访问/callback?code=xxx&state=yyy
|
||||
CallbackPage->>CallbackPage: 验证state参数
|
||||
CallbackPage->>IDaaS: POST /oauth/token (用code换token)
|
||||
IDaaS-->>CallbackPage: 返回access_token等信息
|
||||
|
||||
Note over User,MainApp: 5. 获取用户信息
|
||||
CallbackPage->>IDaaS: GET /userinfo (带access_token)
|
||||
IDaaS-->>CallbackPage: 返回用户详细信息
|
||||
|
||||
Note over User,MainApp: 6. 创建本地会话
|
||||
CallbackPage->>Session: 创建用户会话
|
||||
Session->>Session: 保存token、用户信息等
|
||||
CallbackPage->>User: 重定向到主应用页面
|
||||
User->>MainApp: 成功访问应用
|
||||
```
|
||||
|
||||
## 🔍 详细步骤分析
|
||||
|
||||
### 步骤1: 用户访问登录页面
|
||||
**文件**: `app/routes/login.tsx`
|
||||
|
||||
- 用户访问需要认证的页面时,系统检测到未登录状态
|
||||
- 重定向到 `/login` 页面
|
||||
- 登录页面加载时会检查OAuth配置是否完整
|
||||
|
||||
### 步骤2: 点击登录按钮
|
||||
**触发函数**: `handleOAuthLogin()`
|
||||
|
||||
```typescript
|
||||
// 关键操作流程
|
||||
1. 创建 OAuthClient 实例
|
||||
2. 调用 generateState() 生成随机状态值 (格式: randomString_idp)
|
||||
3. 将状态值保存到 localStorage
|
||||
4. 调用 getAuthorizeUrl(state) 构建授权URL
|
||||
5. 通过 window.location.href 跳转到IDaaS
|
||||
```
|
||||
|
||||
**构建的授权URL格式**:
|
||||
```
|
||||
http://idaas-server/oauth/authorize?
|
||||
response_type=code&
|
||||
scope=read&
|
||||
client_id=YOUR_CLIENT_ID&
|
||||
redirect_uri=http://your-app/callback&
|
||||
state=randomString_idp
|
||||
```
|
||||
|
||||
### 步骤3: IDaaS认证服务器处理
|
||||
**外部系统**: IDaaS平台
|
||||
|
||||
- 用户在IDaaS页面输入账号密码
|
||||
- IDaaS验证用户身份
|
||||
- 认证成功后生成授权码(code)
|
||||
- 重定向回应用的回调地址,携带code和state参数
|
||||
|
||||
### 步骤4: 回调页面处理授权码
|
||||
**文件**: `app/routes/callback.tsx`
|
||||
**函数**: `loader()`
|
||||
|
||||
#### 4.1 参数验证
|
||||
```typescript
|
||||
// 检查错误参数
|
||||
if (error) {
|
||||
return redirect(`/login?error=${error}`);
|
||||
}
|
||||
|
||||
// 检查授权码
|
||||
if (!code) {
|
||||
return redirect("/login?error=missing_code");
|
||||
}
|
||||
|
||||
// 验证状态值
|
||||
if (!state || !state.endsWith("_idp")) {
|
||||
return redirect("/login?error=invalid_state");
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 换取访问令牌
|
||||
**调用**: `oauthClient.getAccessToken(code)`
|
||||
|
||||
```typescript
|
||||
// 发送POST请求到IDaaS
|
||||
URL: http://idaas-server/oauth/token
|
||||
Method: POST
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Body: {
|
||||
grant_type: 'authorization_code',
|
||||
code: '从回调URL获取的code',
|
||||
client_id: 'OAuth应用ID',
|
||||
client_secret: 'OAuth应用密钥',
|
||||
redirect_uri: '回调地址'
|
||||
}
|
||||
```
|
||||
|
||||
**响应数据**:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciO...",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1...",
|
||||
"expires_in": 7199,
|
||||
"scope": "read",
|
||||
"jti": "唯一标识"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤5: 获取用户信息
|
||||
**调用**: `oauthClient.getUserInfo(access_token)`
|
||||
|
||||
```typescript
|
||||
// 发送GET请求获取用户信息
|
||||
URL: http://idaas-server/api/bff/v1.2/oauth2/userinfo
|
||||
Method: GET
|
||||
Headers: {
|
||||
'Authorization': 'Bearer access_token'
|
||||
}
|
||||
```
|
||||
|
||||
**响应数据**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": "200",
|
||||
"data": {
|
||||
"sub": "用户唯一标识",
|
||||
"ou_id": "组织ID",
|
||||
"nickname": "用户昵称",
|
||||
"phone_number": "手机号",
|
||||
"ou_name": "组织名称",
|
||||
"email": "邮箱",
|
||||
"username": "用户名"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤6: 创建本地会话
|
||||
**会话存储**: Remix的Cookie Session Storage
|
||||
|
||||
```typescript
|
||||
// 保存到会话中的信息
|
||||
session.set("isAuthenticated", true); // 认证状态
|
||||
session.set("accessToken", tokenResponse.access_token); // 访问令牌
|
||||
session.set("refreshToken", tokenResponse.refresh_token); // 刷新令牌
|
||||
session.set("tokenIssuedAt", Date.now()); // 令牌颁发时间
|
||||
session.set("tokenExpiresIn", tokenResponse.expires_in); // 令牌有效期
|
||||
session.set("userInfo", userInfo.data); // 用户信息
|
||||
session.set("userRole", userRole); // 用户角色
|
||||
```
|
||||
|
||||
### 步骤7: 重定向到目标页面
|
||||
- 获取原始请求的重定向URL (如果有)
|
||||
- 设置会话Cookie
|
||||
- 重定向到目标页面或首页
|
||||
|
||||
## 🔧 关键技术细节
|
||||
|
||||
### 状态值(State)安全机制
|
||||
- **生成**: 使用随机字符串 + "_idp" 后缀
|
||||
- **存储**: 保存到浏览器localStorage
|
||||
- **验证**: 回调时检查state参数是否以"_idp"结尾
|
||||
- **作用**: 防止CSRF攻击
|
||||
|
||||
### Token管理策略
|
||||
- **存储位置**: 服务器端Session (Cookie)
|
||||
- **安全性**: HttpOnly Cookie,防止XSS攻击
|
||||
- **过期设置**: Session过期时间与OAuth Token同步 (2小时)
|
||||
- **自动刷新**: 提前5分钟自动刷新Token,用户无感知
|
||||
- **刷新机制**: 使用refresh_token实现令牌自动续期
|
||||
- **容错处理**: 刷新失败时自动清理session,重定向登录
|
||||
|
||||
### 错误处理机制
|
||||
- **参数缺失**: missing_code, invalid_state
|
||||
- **网络错误**: token_error, userinfo_error
|
||||
- **处理失败**: callback_error
|
||||
- **用户友好**: 所有错误都转换为中文提示
|
||||
|
||||
### 用户角色判断
|
||||
```typescript
|
||||
// 根据用户名判断角色 (可自定义业务逻辑)
|
||||
const userRole = userInfo.data.username === "admin" ? "developer" : "common";
|
||||
```
|
||||
|
||||
## 🚀 后续操作说明
|
||||
|
||||
### 访问受保护资源
|
||||
用户登录成功后,后续的页面访问都会:
|
||||
1. 从会话中检查 `isAuthenticated` 状态
|
||||
2. **自动检测Token状态**: 验证访问令牌是否过期或即将过期
|
||||
3. **智能刷新机制**: 如果Token将在5分钟内过期,自动使用refresh_token刷新
|
||||
4. **无感知更新**: Token刷新过程对用户完全透明,会话自动延长
|
||||
5. **权限控制**: 根据用户角色控制页面访问权限
|
||||
|
||||
### Token自动刷新流程
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户访问页面] --> B[检查Session中的Token]
|
||||
B --> C{Token是否存在?}
|
||||
C -->|否| D[重定向到登录页]
|
||||
C -->|是| E[检查Token状态]
|
||||
E --> F{Token即将过期?}
|
||||
F -->|否| G[正常访问页面]
|
||||
F -->|是| H[使用refresh_token刷新]
|
||||
H --> I{刷新成功?}
|
||||
I -->|是| J[更新Session中的Token]
|
||||
I -->|否| K[清理Session并重定向登录]
|
||||
J --> G
|
||||
```
|
||||
|
||||
### 单点登出
|
||||
当用户登出时,系统会:
|
||||
1. 调用IDaaS的登出接口
|
||||
2. 清理本地会话数据
|
||||
3. 重定向到登录页面
|
||||
|
||||
## ⚡ 性能优化建议
|
||||
|
||||
### ✅ 已实现的优化
|
||||
1. **智能令牌刷新**: ✅ 提前5分钟自动刷新,避免用户感知中断
|
||||
2. **Session同步**: ✅ Session过期时间与Token同步 (2小时)
|
||||
3. **统一Token管理**: ✅ 使用TokenManager统一处理令牌逻辑
|
||||
4. **错误容错**: ✅ 刷新失败时优雅降级到重新登录
|
||||
|
||||
### 🔄 可进一步优化
|
||||
1. **缓存策略**: 用户信息可在会话期间缓存,减少重复请求
|
||||
2. **错误重试**: 网络请求失败时实现重试机制
|
||||
3. **状态持久化**: 考虑将部分状态信息持久化到数据库
|
||||
4. **Token预刷新**: 可以在页面加载时检查Token状态并预刷新
|
||||
|
||||
## 🔒 安全注意事项
|
||||
|
||||
1. **HTTPS传输**: 生产环境必须使用HTTPS
|
||||
2. **密钥保护**: client_secret必须妥善保存在服务器端
|
||||
3. **状态验证**: 严格验证state参数防止CSRF
|
||||
4. **令牌保护**: 访问令牌不应暴露给前端JavaScript
|
||||
5. **会话安全**: 使用安全的Cookie配置
|
||||
|
||||
---
|
||||
|
||||
**总结**: 整个OAuth2.0登录流程遵循标准的授权码模式,通过多步验证确保用户身份的安全性,同时提供良好的用户体验和错误处理机制。
|
||||
@@ -0,0 +1,130 @@
|
||||
import { postgrestPost } from "../postgrest-client";
|
||||
import { API_BASE_URL } from "../../config/api-config";
|
||||
|
||||
/**
|
||||
* 提出意见的请求参数接口
|
||||
*/
|
||||
export interface SubmitOpinionRequest {
|
||||
reviewPointResultId: string | number;
|
||||
documentId: string | number;
|
||||
auditPoint: string;
|
||||
foundIssue: string;
|
||||
auditOpinion: string;
|
||||
deductionScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提出意见的响应接口
|
||||
*/
|
||||
export interface SubmitOpinionResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: {
|
||||
id: string | number;
|
||||
created_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 交叉评查意见数据接口
|
||||
*/
|
||||
export interface CrossCheckingOpinion {
|
||||
id: string | number;
|
||||
evaluation_point_id: string | number;
|
||||
document_id: string | number;
|
||||
audit_point: string;
|
||||
found_issue: string;
|
||||
audit_opinion: string;
|
||||
deduction_score: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API响应格式
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交交叉评查意见
|
||||
* @param opinionData 意见数据
|
||||
* @returns 提交结果
|
||||
*/
|
||||
export async function submitCrossCheckingOpinion(
|
||||
opinionData: SubmitOpinionRequest
|
||||
): Promise<ApiResponse<SubmitOpinionResponse>> {
|
||||
try {
|
||||
const requestData = {
|
||||
proposer_user_id: 1,
|
||||
evaluation_result_id: opinionData.reviewPointResultId,
|
||||
// document_id: opinionData.documentId,
|
||||
// audit_point: opinionData.auditPoint,
|
||||
// found_issue: opinionData.foundIssue,
|
||||
proposed_score: opinionData.deductionScore,
|
||||
reason: opinionData.auditOpinion
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/admin/cross_review/proposals`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || '提交失败');
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
message: '意见提交成功',
|
||||
data: data
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('提交交叉评查意见失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '提交意见失败',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交叉评查意见列表
|
||||
* @param documentId 文档ID
|
||||
* @returns 意见列表
|
||||
*/
|
||||
export async function getCrossCheckingOpinions(documentId: string | number): Promise<ApiResponse<CrossCheckingOpinion[]>> {
|
||||
try {
|
||||
const response = await postgrestPost('rpc/get_cross_checking_opinions', {
|
||||
p_document_id: documentId
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
error: response.error,
|
||||
status: response.status || 500
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: (response.data as CrossCheckingOpinion[]) || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取交叉评查意见失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '获取意见列表失败',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,19 @@ interface ContractStructureComparison {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 定义评分提案数据接口
|
||||
interface ScoringProposal {
|
||||
id: string | number;
|
||||
evaluation_result_id: string | number;
|
||||
proposer_id: string | number;
|
||||
proposed_score: number;
|
||||
reason: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
document_id: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前评查文件的所有评查点结果
|
||||
* @param fileId 评查文件ID
|
||||
@@ -294,6 +307,26 @@ export async function getReviewPoints(fileId: string) {
|
||||
|
||||
// console.log('groupsMap-------', groupsMap);
|
||||
|
||||
|
||||
//从scoring_proposals表中获取评分提案数据,用于交叉评查
|
||||
const scoringProposalsParams: PostgrestParams = {
|
||||
select: '*',
|
||||
filter: {
|
||||
'document_id': `eq.${fileId}`
|
||||
}
|
||||
};
|
||||
const scoringProposalsResponse = await postgrestGet('scoring_proposals', scoringProposalsParams);
|
||||
|
||||
if (scoringProposalsResponse.error) {
|
||||
return { error: scoringProposalsResponse.error, status: scoringProposalsResponse.status };
|
||||
}
|
||||
const scoringProposalsData = extractApiData<ScoringProposal[]>(scoringProposalsResponse.data) || [];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 构建前端所需的数据格式
|
||||
const resultData: ReviewPointResult[] = evaluationResultsData.map(result => {
|
||||
const point = pointsMap.get(result.evaluation_point_id) || {} as EvaluationPoint;
|
||||
@@ -394,6 +427,10 @@ export async function getReviewPoints(fileId: string) {
|
||||
// 评查配置: point.evaluation_config
|
||||
evaluationConfig: point.evaluation_config || {},
|
||||
|
||||
// 评查点evaluation_point中的fail_message和pass_message 用于交叉评查的提出意见
|
||||
failMessage: point.fail_message || '',
|
||||
passMessage: point.pass_message || '',
|
||||
|
||||
evaluatedPointResultsLog: evaluatedPointResultsLog || {}
|
||||
// evaluatedPointResultsLog: {
|
||||
// rules:[
|
||||
@@ -671,8 +708,8 @@ export async function getReviewPoints(fileId: string) {
|
||||
issueCount: issueCount
|
||||
};
|
||||
// console.log("reviewInfo-------",JSON.stringify(reviewInfo,null,2));
|
||||
// data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document
|
||||
return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument };
|
||||
// data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document scoring_proposals->scoringProposalsData
|
||||
return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument, scoring_proposals: scoringProposalsData };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 交叉评查文件信息组件
|
||||
*/
|
||||
|
||||
interface FileInfoProps {
|
||||
fileInfo: {
|
||||
fileName: string;
|
||||
contractNumber: string;
|
||||
fileSize?: string;
|
||||
fileFormat?: string;
|
||||
pageCount?: number;
|
||||
uploadTime?: string;
|
||||
uploadUser?: string;
|
||||
auditStatus?: number;
|
||||
path?: string;
|
||||
previousRoute?: string;
|
||||
fileType?: string;
|
||||
};
|
||||
onConfirmResults: () => void;
|
||||
}
|
||||
|
||||
export function FileInfo({ fileInfo, onConfirmResults }: FileInfoProps) {
|
||||
|
||||
const handleDownloadFile = () => {
|
||||
if (fileInfo.path) {
|
||||
// 创建一个隐藏的下载链接
|
||||
const link = document.createElement('a');
|
||||
link.href = fileInfo.path;
|
||||
link.download = fileInfo.fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
alert('文件路径不存在,无法下载');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportReport = () => {
|
||||
alert('导出交叉评查报告功能');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 file-info-header">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex-1">
|
||||
{/* 文件基本信息已在面包屑区域显示,这里可以不重复显示 */}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 下载原文件按钮 */}
|
||||
<button
|
||||
onClick={handleDownloadFile}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<i className="fas fa-download mr-1.5"></i>
|
||||
下载原文件
|
||||
</button>
|
||||
|
||||
{/* 导出评查报告按钮 */}
|
||||
<button
|
||||
onClick={handleExportReport}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<i className="fas fa-file-export mr-1.5"></i>
|
||||
导出评查报告
|
||||
</button>
|
||||
|
||||
{/* 确认评查结果按钮 - 只在未审核通过时显示 */}
|
||||
{fileInfo.auditStatus !== 1 && (
|
||||
<button
|
||||
onClick={onConfirmResults}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<i className="fas fa-check mr-1.5"></i>
|
||||
确认评查结果
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 已确认状态显示 */}
|
||||
{fileInfo.auditStatus === 1 && (
|
||||
<span className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 bg-green-100 border border-green-200 rounded-md">
|
||||
<i className="fas fa-check-circle mr-1.5"></i>
|
||||
评查结果已确认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* 文件预览组件
|
||||
* 显示文档内容和评查点高亮
|
||||
*/
|
||||
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import { DOCUMENT_URL } from '~/api/axios-client';
|
||||
|
||||
// 设置worker路径为public目录下的worker文件
|
||||
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
|
||||
// 导入统一的ReviewPoint类型
|
||||
import { type ReviewPoint } from './';
|
||||
import { toastService } from '../ui/Toast';
|
||||
|
||||
/**
|
||||
* 自定义样式
|
||||
* 这些样式解决了PDF页面在放大时互相重叠的问题
|
||||
*/
|
||||
const styles = {
|
||||
pdfContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
position: 'relative' as const,
|
||||
},
|
||||
pageContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
position: 'relative' as const,
|
||||
}
|
||||
};
|
||||
|
||||
// 定义文档内容类型
|
||||
interface FileContent {
|
||||
title: string;
|
||||
contractNumber: string;
|
||||
path: string;
|
||||
ocrResult?: {
|
||||
__meta?: {
|
||||
page_offset?: number;
|
||||
};
|
||||
}; // 添加ocrResult属性
|
||||
parties: {
|
||||
partyA: {
|
||||
name: string;
|
||||
address: string;
|
||||
representative: string;
|
||||
phone: string;
|
||||
};
|
||||
partyB: {
|
||||
name: string;
|
||||
address: string;
|
||||
representative: string;
|
||||
phone: string;
|
||||
};
|
||||
};
|
||||
sections: {
|
||||
title: string;
|
||||
content: string;
|
||||
}[];
|
||||
template_contract_path?: string;
|
||||
}
|
||||
|
||||
interface FilePreviewProps {
|
||||
fileContent: FileContent;
|
||||
reviewPoints?: ReviewPoint[]; // 设为可选
|
||||
activeReviewPointResultId: string | null;
|
||||
targetPage?: number; // 新增目标页码参数
|
||||
isStructuredView?: boolean; // 是否显示结构化视图
|
||||
}
|
||||
|
||||
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
|
||||
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, isStructuredView = false }: FilePreviewProps) {
|
||||
const [zoomLevel, setZoomLevel] = useState(100);
|
||||
// const [highlightsVisible, setHighlightsVisible] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [numPages, setNumPages] = useState<number | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [pageInputValue, setPageInputValue] = useState<string>('');
|
||||
|
||||
// 拖拽状态管理
|
||||
const [dragMode, setDragMode] = useState(false); // 是否处于拖拽模式
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragCursor, setDragCursor] = useState('default');
|
||||
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// 放大文档
|
||||
const handleZoomIn = () => {
|
||||
if (zoomLevel < 200) {
|
||||
setZoomLevel(prevZoom => prevZoom + 10);
|
||||
}
|
||||
};
|
||||
|
||||
// 缩小文档
|
||||
const handleZoomOut = () => {
|
||||
if (zoomLevel > 50) {
|
||||
setZoomLevel(prevZoom => prevZoom - 10);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换拖拽模式
|
||||
const toggleDragMode = () => {
|
||||
setDragMode(prev => !prev);
|
||||
setDragCursor(prev => prev === 'default' ? 'grab' : 'default');
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!dragMode || e.button !== 0) return; // 只在拖拽模式下响应左键点击
|
||||
|
||||
// 防止选中文本
|
||||
e.preventDefault();
|
||||
|
||||
// 设置拖拽状态
|
||||
setIsDragging(true);
|
||||
setDragCursor('grabbing');
|
||||
|
||||
// 记录鼠标初始位置
|
||||
lastMousePosRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
};
|
||||
};
|
||||
|
||||
// 处理拖拽过程
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!dragMode || !isDragging || !contentRef.current) return;
|
||||
|
||||
// 计算鼠标移动距离
|
||||
const dx = e.clientX - lastMousePosRef.current.x;
|
||||
const dy = e.clientY - lastMousePosRef.current.y;
|
||||
|
||||
// 更新容器滚动位置
|
||||
contentRef.current.scrollLeft -= dx;
|
||||
contentRef.current.scrollTop -= dy;
|
||||
|
||||
// 更新鼠标位置记录
|
||||
lastMousePosRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
};
|
||||
};
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleMouseUp = () => {
|
||||
if (!dragMode) return;
|
||||
|
||||
setIsDragging(false);
|
||||
setDragCursor('grab');
|
||||
};
|
||||
|
||||
// 监听鼠标离开窗口事件
|
||||
useEffect(() => {
|
||||
const handleMouseLeave = () => {
|
||||
if (dragMode && isDragging) {
|
||||
setIsDragging(false);
|
||||
setDragCursor('grab');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseleave', handleMouseLeave);
|
||||
document.addEventListener('mouseup', handleMouseUp as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||
document.removeEventListener('mouseup', handleMouseUp as EventListener);
|
||||
};
|
||||
}, [isDragging, dragMode]);
|
||||
|
||||
// 处理页面跳转
|
||||
const prevTargetPageRef = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
// 调试信息:记录组件状态
|
||||
// console.log(`FilePreview更新 - isStructuredView:${isStructuredView}, targetPage:${targetPage}, activeReviewPointResultId:${activeReviewPointResultId}, numPages:${numPages}`);
|
||||
|
||||
// 如果有目标页码,并且与上次相同,提示用户
|
||||
if(targetPage && numPages && targetPage <= numPages && targetPage === prevTargetPageRef.current){
|
||||
// toastService.success(`已跳转至目标页码`);
|
||||
}
|
||||
// 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转
|
||||
if (targetPage && numPages && targetPage <= numPages) {
|
||||
// if (targetPage && numPages && targetPage <= numPages && (targetPage !== prevTargetPageRef.current || activeReviewPointResultId)) {
|
||||
prevTargetPageRef.current = targetPage;
|
||||
let newTargetPage = targetPage;
|
||||
|
||||
// 页码偏移量
|
||||
try {
|
||||
// 安全地访问ocrResult
|
||||
if (fileContent.ocrResult && fileContent.ocrResult.__meta && fileContent.ocrResult.__meta.page_offset) {
|
||||
// 可以根据需要使用page_offset调整目标页面
|
||||
newTargetPage = targetPage + fileContent.ocrResult.__meta.page_offset;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("访问ocrResult时出错:", error);
|
||||
toastService.error("访问ocrResult时出错:" + (error instanceof Error ? error.message : '未知错误'));
|
||||
}
|
||||
|
||||
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
|
||||
// console.log(`尝试跳转到元素ID: ${pageElementId}`);
|
||||
|
||||
const pageElement = document.getElementById(pageElementId);
|
||||
if (pageElement) {
|
||||
// console.log(`跳转到第${newTargetPage}页,对应评查点结果ID: ${activeReviewPointResultId}`);
|
||||
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
console.warn(`未找到页面元素: ${pageElementId}`);
|
||||
}
|
||||
}
|
||||
}, [targetPage, numPages, fileContent, activeReviewPointResultId, isStructuredView]);
|
||||
|
||||
// 获取评查点对应的样式类
|
||||
// const getHighlightClass = (status: string) => {
|
||||
// switch (status) {
|
||||
// case 'warning':
|
||||
// return 'warning';
|
||||
// case 'error':
|
||||
// return 'error';
|
||||
// case 'success':
|
||||
// return 'success';
|
||||
// default:
|
||||
// return 'warning';
|
||||
// }
|
||||
// };
|
||||
|
||||
// 处理页码输入变化
|
||||
const handlePageInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
// 只允许输入数字
|
||||
const value = e.target.value.replace(/\D/g, '');
|
||||
setPageInputValue(value);
|
||||
};
|
||||
|
||||
// 处理页码跳转
|
||||
const handlePageJump = () => {
|
||||
if (!pageInputValue || !numPages) return;
|
||||
|
||||
const targetPageNum = parseInt(pageInputValue, 10);
|
||||
|
||||
// 验证页码是否在有效范围内
|
||||
if (targetPageNum > 0 && targetPageNum <= numPages) {
|
||||
// 找到目标页面元素并滚动到该位置
|
||||
const pageElement = document.getElementById(`page-${targetPageNum}`);
|
||||
if (pageElement) {
|
||||
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
} else {
|
||||
// 页码超出范围,显示错误信息或重置输入
|
||||
toastService.warning(`请输入有效页码 (1-${numPages})`);
|
||||
setPageInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理回车键跳转
|
||||
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handlePageJump();
|
||||
}
|
||||
};
|
||||
|
||||
// PDF文档加载成功回调函数
|
||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
|
||||
setNumPages(numPages);
|
||||
// console.log("PDF加载成功,页数:", numPages);
|
||||
}
|
||||
|
||||
// 计算页面在缩放后的实际间距
|
||||
const calculatePageMargin = (zoomFactor: number) => {
|
||||
// 基础间距为30px,随着缩放倍数线性增加
|
||||
const baseMargin = 30;
|
||||
// 页面缩放后,需要额外添加的间距 = (缩放倍数 - 1) * 页面高度
|
||||
const additionalMargin = Math.max(0, (zoomFactor - 1) * 800); // 800是估计的页面高度
|
||||
return baseMargin + additionalMargin;
|
||||
};
|
||||
|
||||
// 滚动到顶部
|
||||
const handleScrollToTop = () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染PDF文档的所有页面
|
||||
*
|
||||
* 功能描述:
|
||||
* 1. 生成PDF所有页面的渲染数组,每个页面包含页码标识和实际页面内容
|
||||
* 2. 处理页面缩放,通过CSS transform实现页面大小调整
|
||||
* 3. 在每个页面上标记对应的评查点高亮区域
|
||||
* 4. 处理评查点的激活状态,显示特殊的高亮效果
|
||||
*
|
||||
* @returns {JSX.Element[] | null} 返回所有页面组件的数组,如果没有页数信息则返回null
|
||||
*/
|
||||
const renderAllPages = () => {
|
||||
// 如果还没有获取到PDF总页数,返回null
|
||||
if (!numPages) return null;
|
||||
|
||||
// 用于存储所有页面组件的数组
|
||||
const pages = [];
|
||||
|
||||
// 遍历每一页,生成对应的页面组件
|
||||
for (let i = 1; i <= numPages; i++) {
|
||||
// 计算当前缩放级别下的页面容器样式
|
||||
const zoomFactor = zoomLevel / 100;
|
||||
const pageContainerStyle = {
|
||||
...styles.pageContainer,
|
||||
marginBottom: `${calculatePageMargin(zoomFactor)}px`, // 动态计算页面间距
|
||||
};
|
||||
|
||||
// 为结构化视图和普通视图创建不同的ID
|
||||
const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`;
|
||||
|
||||
// 为每一页创建组件
|
||||
pages.push(
|
||||
<div key={i} id={pageId} style={pageContainerStyle}>
|
||||
{/* 页码标识,显示在页面上方 */}
|
||||
<div className="text-center text-gray-500 text-sm mb-2">第 {i} 页</div>
|
||||
|
||||
{/* 页面容器,应用缩放变换,设置相对定位用于放置评查点高亮 */}
|
||||
<div
|
||||
className="page-wrapper"
|
||||
style={{
|
||||
transform: `scale(${zoomFactor})`, // 根据zoomLevel应用缩放
|
||||
transformOrigin: 'top center', // 缩放原点设置为顶部中心
|
||||
position: 'relative', // 相对定位,作为评查点高亮的定位参考
|
||||
display: 'inline-block', // 内联块级元素,宽度由内容决定
|
||||
margin: '0 auto', // 水平居中
|
||||
}}
|
||||
>
|
||||
{/* 渲染PDF页面组件 */}
|
||||
<Page
|
||||
pageNumber={i} // 当前页码
|
||||
renderTextLayer={true} // 启用文本层,使文本可选择
|
||||
renderAnnotationLayer={true} // 启用注释层,显示PDF内置注释
|
||||
className="border border-gray-300 shadow-md" // 添加边框和阴影样式
|
||||
/>
|
||||
|
||||
{/* 渲染评查点高亮区域 */}
|
||||
{/* {highlightsVisible && pageReviewPoints.map(point => {
|
||||
// 判断当前评查点是否为激活状态(被选中)
|
||||
const isActive = point.id === activeReviewPointId;
|
||||
|
||||
return (
|
||||
// 评查点高亮区域
|
||||
<div
|
||||
key={point.id}
|
||||
// 添加多个类名:基础高亮区域、PDF专用高亮、状态样式类、激活状态类
|
||||
className={`highlight-area pdf-highlight ${getHighlightClass(point.status)} ${isActive ? 'highlight-focus' : ''}`}
|
||||
// 设置唯一标识,用于滚动定位和DOM查询
|
||||
data-review-id={point.id}
|
||||
// 设置高亮区域的样式
|
||||
style={{
|
||||
position: 'absolute', // 绝对定位,相对于页面容器
|
||||
zIndex: isActive ? 20 : 10, // 激活状态时提高层级,确保在最上层显示
|
||||
boxShadow: isActive ? '0 0 0 2px yellow, 0 0 10px rgba(0,0,0,0.3)' : '', // 激活时添加特殊阴影效果
|
||||
// 根据评查点的位置信息计算高亮区域位置,实际项目中需根据真实位置坐标计算
|
||||
top: `${point.position?.index ? point.position.index * 20 : 20}px`,
|
||||
left: '50px',
|
||||
width: '300px',
|
||||
height: '30px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 返回所有页面组件数组
|
||||
return pages;
|
||||
};
|
||||
|
||||
// 渲染文档内容
|
||||
const renderDocumentContent = () => {
|
||||
const real_path = fileContent.path || fileContent.template_contract_path || '';
|
||||
|
||||
// 如果路径无效,显示错误信息
|
||||
if (!real_path) {
|
||||
if(!fileContent.template_contract_path){
|
||||
return (
|
||||
<div className="text-red-500 p-4">
|
||||
<p>无法加载文件:合同模板未上传</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-red-500 p-4">
|
||||
<p>无法加载文件:路径无效</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// console.log('real_path',real_path);
|
||||
// 获取文件扩展名
|
||||
const fileExtension = real_path.split('.').pop()?.toLowerCase();
|
||||
|
||||
// PDF内容渲染
|
||||
const renderPdfContent = () => (
|
||||
<div
|
||||
style={{
|
||||
...styles.pdfContainer,
|
||||
// 当缩放大于100%时设置最小宽度,确保出现横向滚动条
|
||||
minWidth: zoomLevel > 100 ? `${zoomLevel}%` : '100%',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
<Document
|
||||
file={DOCUMENT_URL+real_path}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={(error) => {
|
||||
console.error("PDF加载错误:", error);
|
||||
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
|
||||
}}
|
||||
className="w-full"
|
||||
error={<div className="text-red-500">PDF文档加载失败,请检查链接或网络连接。</div>}
|
||||
noData={<div>无数据</div>}
|
||||
loading={<div className="text-center py-10">PDF加载中...</div>}
|
||||
>
|
||||
{renderAllPages()}
|
||||
</Document>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 结构化数据渲染
|
||||
const renderStructuredData = () => (
|
||||
<div className="structured-view p-4 text-left mt-4 border-t border-gray-200">
|
||||
<div className="text-xs font-medium mb-2">结构化数据:</div>
|
||||
{fileContent.ocrResult ? (
|
||||
<div className="overflow-auto max-h-[300px]">
|
||||
<pre className="text-xs whitespace-pre-wrap bg-gray-50 p-3 rounded">
|
||||
{JSON.stringify(fileContent.ocrResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 p-4 text-center">
|
||||
<p>无结构化数据可显示</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 根据文件类型选择不同的渲染方式
|
||||
if (fileExtension === 'pdf') {
|
||||
// 结构化视图模式:显示PDF和结构化数据
|
||||
if (isStructuredView) {
|
||||
return (
|
||||
<div>
|
||||
{renderPdfContent()}
|
||||
{renderStructuredData()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 普通模式:仅显示PDF
|
||||
return renderPdfContent();
|
||||
} else {
|
||||
// 非PDF文件显示不支持消息
|
||||
return (
|
||||
<div className="text-gray-500 p-4">
|
||||
<p>暂不支持预览此类型的文件:{fileExtension}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-preview">
|
||||
<div className="file-preview-header px-2 text-xs sm:text-xs md:text-sm max-w-full flex items-center justify-between min-w-0">
|
||||
<div className="flex items-center min-w-0 flex-shrink-0">
|
||||
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2 flex-shrink-0`}></i>
|
||||
<span className="font-medium text-primary truncate max-w-[120px]" title={isStructuredView ? '模板预览' : '文件预览'}>
|
||||
{isStructuredView ? '模板预览' : '文件预览'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden">
|
||||
<button
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
|
||||
onClick={handleScrollToTop}
|
||||
title="返回顶部"
|
||||
>
|
||||
<i className="ri-arrow-up-double-line"></i>
|
||||
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[60px]">返回顶部</span>
|
||||
</button>
|
||||
<button
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
|
||||
onClick={handleZoomIn}
|
||||
title="放大"
|
||||
>
|
||||
<i className="ri-zoom-in-line"></i>
|
||||
</button>
|
||||
<button
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
|
||||
onClick={handleZoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<i className="ri-zoom-out-line"></i>
|
||||
</button>
|
||||
{/* 页码跳转控件 */}
|
||||
<div className="inline-flex items-center ml-2 flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
className="ant-input ant-input-sm py-0 px-1 text-xs max-h-6 leading-5 w-[2.5rem] text-center
|
||||
focus:outline-none focus:ring-1 focus:ring-green-900"
|
||||
placeholder="页码"
|
||||
value={pageInputValue}
|
||||
onChange={handlePageInputChange}
|
||||
onKeyDown={handlePageInputKeyDown}
|
||||
/>
|
||||
<button
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-1"
|
||||
onClick={handlePageJump}
|
||||
disabled={!numPages}
|
||||
title="跳转到页面"
|
||||
>
|
||||
<i className="ri-arrow-right-line"></i>
|
||||
</button>
|
||||
{numPages && (
|
||||
<span className="ml-1 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
|
||||
/ {numPages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-2 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
|
||||
比例:{zoomLevel}%
|
||||
</span>
|
||||
<button
|
||||
className={`ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-2 flex-shrink-0 ${dragMode ? 'active bg-green-300' : ''}`}
|
||||
title="切换拖拽模式"
|
||||
aria-pressed={dragMode}
|
||||
onClick={toggleDragMode}
|
||||
>
|
||||
<i className="ri-drag-move-line"></i>
|
||||
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[80px]">
|
||||
拖拽模式{dragMode ? '(已激活)' : ''}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="file-preview-content"
|
||||
ref={contentRef}
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 80px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
cursor: dragCursor,
|
||||
userSelect: dragMode ? 'none' : 'auto', // 拖拽模式下防止文本被选中
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="pdf-interactive-container"
|
||||
aria-label="PDF文档查看区域"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onKeyDown={(e) => {
|
||||
// 添加键盘导航支持
|
||||
const scrollAmount = 50;
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (contentRef.current) contentRef.current.scrollTop -= scrollAmount;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (contentRef.current) contentRef.current.scrollTop += scrollAmount;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
if (contentRef.current) contentRef.current.scrollLeft -= scrollAmount;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
if (contentRef.current) contentRef.current.scrollLeft += scrollAmount;
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
textAlign: 'center',
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
{loadError ? (
|
||||
<div className="text-red-500 p-4">
|
||||
<p>{loadError}</p>
|
||||
</div>
|
||||
) : (
|
||||
renderDocumentContent()
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 交叉评查详情组件导出文件
|
||||
*/
|
||||
|
||||
export { FileInfo } from './FileInfo';
|
||||
export { FilePreview } from './FilePreview';
|
||||
export { ReviewPointsList } from './ReviewPointsList';
|
||||
export type { ReviewPoint } from './ReviewPointsList';
|
||||
@@ -222,7 +222,7 @@ export default function CrossCheckingIndex() {
|
||||
type="default"
|
||||
size="small"
|
||||
className="operation-btn secondary"
|
||||
onClick={() => navigate(`/cross-checking/${task.id}/results`)}
|
||||
onClick={() => navigate(`/cross-checking/result?id=1355&previousRoute=cross-checking`)}
|
||||
>
|
||||
<i className="ri-file-text-line"></i>
|
||||
查看结果
|
||||
@@ -478,7 +478,6 @@ export default function CrossCheckingIndex() {
|
||||
name="docType"
|
||||
value={searchParams.get('docType') || ''}
|
||||
options={[
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: CrossCheckingDocType.PENALTY, label: "行政处罚" },
|
||||
{ value: CrossCheckingDocType.PERMIT, label: "行政许可" }
|
||||
]}
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
/**
|
||||
* 交叉评查详情页面
|
||||
*
|
||||
* 功能概述:
|
||||
* - 显示文档交叉评查结果和详细信息
|
||||
* - 支持查看文档内容及评查点高亮标记
|
||||
* - 提供评查点列表,分为通过、警告和错误三种类型
|
||||
* - 支持评查点处理,如一键替换、人工审核等功能
|
||||
* - 支持导出评查报告和下载原文件
|
||||
*
|
||||
* 组件结构:
|
||||
* - FileInfo: 显示文件基本信息和操作按钮
|
||||
* - FilePreview: 文档预览组件,显示文档内容及高亮问题
|
||||
* - ReviewPointsList: 评查点列表组件,显示所有评查结果
|
||||
*
|
||||
* 数据流转:
|
||||
* 1. 页面加载时从API获取评查详情数据
|
||||
* 2. 根据评查点ID关联文档中的高亮区域
|
||||
* 3. 点击评查点时在文档中定位对应位置
|
||||
* 4. 处理评查点时更新状态并反馈到UI
|
||||
*
|
||||
* @author 中国烟草AI合同及卷宗审核系统开发团队
|
||||
*/
|
||||
|
||||
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLoaderData } from "@remix-run/react";
|
||||
import crossCheckingStyles from "~/styles/cross-checking-result.css?url";
|
||||
import { getReviewPoints, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
|
||||
import { toastService } from "~/components/ui/Toast";
|
||||
|
||||
// 导入交叉评查详情页面组件
|
||||
import {
|
||||
FileInfo,
|
||||
FilePreview,
|
||||
ReviewPointsList
|
||||
} from "~/components/cross-checking";
|
||||
|
||||
// 从ReviewPointsList组件中导入ReviewPoint类型
|
||||
import { type ReviewPoint } from '~/components/cross-checking';
|
||||
import { messageService } from "~/components/ui/MessageModal";
|
||||
import { loadingBarService } from "~/components/ui/LoadingBar";
|
||||
import { Breadcrumb } from "~/components/layout/Breadcrumb";
|
||||
|
||||
// 定义评分提案数据接口(与reviews.ts中保持一致)
|
||||
interface ScoringProposal {
|
||||
id: string | number;
|
||||
evaluation_result_id: string | number;
|
||||
proposer_id: string | number;
|
||||
proposed_score: number;
|
||||
reason: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
document_id: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件信息组件
|
||||
* 显示文件名称、状态信息以及操作按钮(下载原文件、导出评查报告、确认评查结果)
|
||||
*/
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// 定义统计数据类型
|
||||
interface Statistics {
|
||||
total: number;
|
||||
success: number;
|
||||
warning: number;
|
||||
error: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// 定义文件信息类型
|
||||
interface FileInfo {
|
||||
fileName: string;
|
||||
contractNumber: string;
|
||||
fileSize: string;
|
||||
fileFormat: string;
|
||||
pageCount: number;
|
||||
uploadTime: string;
|
||||
uploadUser: string;
|
||||
auditStatus: number;
|
||||
fileType: string; // 文件类型(1:合同,2:卷宗等)
|
||||
}
|
||||
|
||||
// 定义合同信息类型
|
||||
interface ContractInfo {
|
||||
contractType: string;
|
||||
signDate: string;
|
||||
parties: {
|
||||
partyA: string;
|
||||
partyB: string;
|
||||
};
|
||||
amount: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
// 定义评查信息类型
|
||||
interface ReviewInfo {
|
||||
reviewTime: string;
|
||||
reviewModel: string;
|
||||
ruleGroup: string;
|
||||
result: string;
|
||||
issueCount: number;
|
||||
}
|
||||
|
||||
// 定义文档内容类型
|
||||
interface FileContent {
|
||||
title: string;
|
||||
contractNumber: string;
|
||||
parties: {
|
||||
partyA: {
|
||||
name: string;
|
||||
address: string;
|
||||
representative: string;
|
||||
phone: string;
|
||||
};
|
||||
partyB: {
|
||||
name: string;
|
||||
address: string;
|
||||
representative: string;
|
||||
phone: string;
|
||||
};
|
||||
};
|
||||
sections: {
|
||||
title: string;
|
||||
content: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// 定义分析项类型
|
||||
interface AnalysisItem {
|
||||
title: string;
|
||||
content: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// 定义分析数据类型
|
||||
interface AnalysisData {
|
||||
riskAlerts: AnalysisItem[];
|
||||
suggestions: AnalysisItem[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// 定义评查数据类型
|
||||
interface ReviewData {
|
||||
fileInfo: FileInfo;
|
||||
contractInfo: ContractInfo;
|
||||
reviewInfo: ReviewInfo;
|
||||
statistics: Statistics;
|
||||
fileContent: FileContent;
|
||||
reviewPoints: ReviewPoint[];
|
||||
aiAnalysis: AnalysisData;
|
||||
}
|
||||
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "交叉评查详情 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{
|
||||
name: "description",
|
||||
content: "查看文档交叉评查结果,处理问题点,确认评查结果"
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export function links() {
|
||||
return [{ rel: "stylesheet", href: crossCheckingStyles }];
|
||||
}
|
||||
|
||||
export const handle = {
|
||||
hideBreadcrumb: true
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get('id') || undefined;
|
||||
const previousRoute = url.searchParams.get('previousRoute') || '';
|
||||
// console.log("id-------",id);
|
||||
if (!id) {
|
||||
return Response.json({ result: false, message: '文件ID不能为空' });
|
||||
}
|
||||
|
||||
// 获取评查点数据
|
||||
const reviewData = await getReviewPoints(id);
|
||||
|
||||
// console.log("documentData-------",JSON.stringify(documentData.data,null,2));
|
||||
// console.log("reviewData-------",JSON.stringify('data' in reviewData ? reviewData.data : '',null,2));
|
||||
if ('error' in reviewData && reviewData.error) {
|
||||
console.error("获取评查点数据错误:", reviewData.error);
|
||||
return Response.json({ result: false, message: reviewData.error });
|
||||
}
|
||||
|
||||
// 确保reviewData有效且具有预期的属性
|
||||
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
|
||||
// console.log("reviewData-------",JSON.stringify(reviewData.data));
|
||||
return Response.json({
|
||||
previousRoute: previousRoute,
|
||||
document: reviewData.document,
|
||||
reviewPoints: reviewData.data,
|
||||
reviewInfo: reviewData.reviewInfo,
|
||||
statistics: reviewData.stats,
|
||||
comparison_document: reviewData.comparison_document,
|
||||
scoring_proposals: reviewData.scoring_proposals || []
|
||||
});
|
||||
} else {
|
||||
console.error("返回的评查数据格式不正确",JSON.stringify(reviewData,null,2));
|
||||
return Response.json({ result: false, message: '返回的评查数据格式不正确' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取评查数据失败:', error);
|
||||
return Response.json({ result: false, message: '获取评查数据失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export default function CrossCheckingResult() {
|
||||
const navigate = useNavigate();
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const { document, reviewPoints, statistics, reviewInfo, scoring_proposals } = loaderData;
|
||||
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
||||
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
|
||||
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
|
||||
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
||||
|
||||
// loader 数据加载出错
|
||||
useEffect(()=>{
|
||||
loadingBarService.hide();
|
||||
if(Object.keys(loaderData).find(key => key === 'result') && !loaderData.result){
|
||||
messageService.show({
|
||||
title: '错误',
|
||||
message: loaderData.message,
|
||||
type: 'error',
|
||||
confirmText: '确定',
|
||||
cancelText: '',
|
||||
onConfirm: () => {
|
||||
navigate(-1);
|
||||
}
|
||||
})
|
||||
}
|
||||
},[loaderData, navigate]);
|
||||
|
||||
|
||||
// 模拟获取评查数据
|
||||
useEffect(() => {
|
||||
if (!document) return;
|
||||
|
||||
// 构建文件信息对象
|
||||
const fileInfo = {
|
||||
fileName: document.name || "未知文件名",
|
||||
path: document.path || "未知路径",
|
||||
contractNumber: document.documentNumber || "未知编号",
|
||||
fileSize: document.size ? formatFileSize(document.size) : "未知大小",
|
||||
// 文件格式类型
|
||||
fileFormat: document.fileType ? document.fileType.toUpperCase() : "未知格式",
|
||||
pageCount: document.pageCount || 0,
|
||||
uploadTime: document.uploadTime || "未知时间",
|
||||
uploadUser: document.uploadUser || "未知用户",
|
||||
auditStatus: document.auditStatus || 0,
|
||||
legalBasis: document.legalBasis || {},
|
||||
// 文件类型(1:合同,2:卷宗。。。)
|
||||
fileType: document.type || ""
|
||||
};
|
||||
|
||||
// 创建包含真实文档数据的评查数据对象
|
||||
const reviewDataObj: ReviewData = {
|
||||
// 使用真实文件信息
|
||||
fileInfo: fileInfo,
|
||||
// 其他字段暂时使用默认值
|
||||
contractInfo: getMockReviewData().contractInfo,
|
||||
reviewInfo: reviewInfo,
|
||||
statistics: statistics,
|
||||
fileContent: getMockReviewData().fileContent,
|
||||
reviewPoints: reviewPoints,
|
||||
aiAnalysis: getMockReviewData().aiAnalysis,
|
||||
};
|
||||
|
||||
|
||||
setReviewData(reviewDataObj);
|
||||
setIsLoading(false);
|
||||
}, [document, reviewPoints, statistics, reviewInfo]);
|
||||
|
||||
|
||||
const handleReviewPointSelect = (reviewPointId: string, page?: number) => {
|
||||
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
|
||||
if (reviewPointId === activeReviewPointResultId && page) {
|
||||
setTargetPage(undefined);
|
||||
// 使用setTimeout确保状态更新后再设置新的targetPage
|
||||
setTimeout(() => {
|
||||
setActiveReviewPointResultId(reviewPointId);
|
||||
setTargetPage(page);
|
||||
}, 0);
|
||||
} else {
|
||||
// 正常设置activeReviewPointId和targetPage
|
||||
setActiveReviewPointResultId(reviewPointId);
|
||||
setTargetPage(page);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 处理评审点状态变更
|
||||
const handleReviewPointStatusChange = async (reviewPointResultId: string, editAuditStatusId: string | number, newStatus: string, message: string) => {
|
||||
// 将字符串的布尔值转换为布尔类型
|
||||
let boolResult = 'review';
|
||||
if(newStatus !== 'review'){
|
||||
boolResult = newStatus === 'true' ? 'true' : 'false';
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用 API 更新评查结果
|
||||
const response = await updateReviewResult(reviewPointResultId, editAuditStatusId, boolResult, message);
|
||||
|
||||
if (response.error) {
|
||||
console.error('更新评查结果失败:', response.error);
|
||||
toastService.error(`更新评查结果失败: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log('评查点状态更新成功:', {
|
||||
// id: reviewPointResultId,
|
||||
// result: boolResult,
|
||||
// message: message
|
||||
// });
|
||||
|
||||
// 更新本地状态
|
||||
if (reviewData) {
|
||||
// 找到要更新的评查点和它的原始状态
|
||||
const reviewPointToUpdate = reviewData.reviewPoints.find(point => point.id === reviewPointResultId);
|
||||
const oldStatus = reviewPointToUpdate?.status || '';
|
||||
const wasSuccess = reviewPointToUpdate?.result === true;
|
||||
const newIsSuccess = newStatus === 'true';
|
||||
|
||||
// 更新评查点
|
||||
const updatedReviewPoints = reviewData.reviewPoints.map(point =>
|
||||
point.id === reviewPointResultId ? {
|
||||
...point,
|
||||
result: newStatus === 'true' ? true : (newStatus === 'false' ? false : point.result),
|
||||
editAuditStatus: boolResult === 'review' ? 0 : 1,
|
||||
title: boolResult === 'review' ? point.title : message,
|
||||
editAuditStatusMessage: boolResult === 'review' ? point.editAuditStatusMessage : message
|
||||
} : point
|
||||
);
|
||||
|
||||
// 更新统计数据
|
||||
const updatedStatistics = { ...reviewData.statistics };
|
||||
|
||||
// 只处理结果实际变化的情况,即从通过变为不通过,或从不通过变为通过
|
||||
if (newStatus !== 'review' && wasSuccess !== newIsSuccess) {
|
||||
if (newIsSuccess) {
|
||||
// 从不通过变为通过
|
||||
updatedStatistics.success += 1;
|
||||
// 减少对应的错误或警告数量
|
||||
if (oldStatus === 'warning') {
|
||||
updatedStatistics.warning = Math.max(0, updatedStatistics.warning - 1);
|
||||
} else if (oldStatus === 'error') {
|
||||
updatedStatistics.error = Math.max(0, updatedStatistics.error - 1);
|
||||
}
|
||||
} else {
|
||||
// 从通过变为不通过
|
||||
updatedStatistics.success = Math.max(0, updatedStatistics.success - 1);
|
||||
// 增加对应的错误或警告数量
|
||||
if (oldStatus === 'warning') {
|
||||
updatedStatistics.warning += 1;
|
||||
} else if (oldStatus === 'error') {
|
||||
updatedStatistics.error += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 UI 状态
|
||||
setReviewData({
|
||||
...reviewData,
|
||||
reviewPoints: updatedReviewPoints,
|
||||
statistics: updatedStatistics
|
||||
});
|
||||
|
||||
// 显示成功消息
|
||||
if(document && document.id && newStatus !== 'review'){
|
||||
toastService.success('评查点状态已更新');
|
||||
}
|
||||
|
||||
// console.log("newReviewPoints",updatedReviewPoints);
|
||||
|
||||
// 如果是review操作才调用API刷新
|
||||
// if (document && document.id && newStatus === 'review') {
|
||||
// await refreshReviewData(document.id.toString());
|
||||
// }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新评查结果出错:', error);
|
||||
toastService.error('更新评查结果失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmResults = async () => {
|
||||
if (!document || !document.id) {
|
||||
toastService.error('文档数据不完整,无法确认评查结果');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示加载状态
|
||||
setIsLoading(true);
|
||||
|
||||
// 调用API确认评查结果
|
||||
const response = await confirmReviewResults(document.id.toString());
|
||||
|
||||
if (response.error) {
|
||||
console.error('确认评查结果失败:', response.error);
|
||||
toastService.error(`确认评查结果失败: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
toastService.success('评查结果已确认,文档审核状态已更新');
|
||||
|
||||
// 导航到交叉评查列表页
|
||||
navigate('/cross-checking');
|
||||
} catch (error) {
|
||||
console.error('确认评查结果出错:', error);
|
||||
toastService.error(`确认评查结果失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 构建自定义面包屑项
|
||||
const getBreadcrumbItems = () => {
|
||||
const items = [
|
||||
{ title: "交叉评查详情", to: `/cross-checking/result?id=${document?.id}` }
|
||||
];
|
||||
|
||||
// 添加前置路由
|
||||
if (loaderData.previousRoute) {
|
||||
if (loaderData.previousRoute === 'cross-checking') {
|
||||
items.unshift({ title: "交叉评查", to: "/cross-checking" });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cross-checking-result-container">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center p-12">
|
||||
<div className="loading-spinner"></div>
|
||||
<span className="ml-3">加载中...</span>
|
||||
</div>
|
||||
) : reviewData && (
|
||||
<>
|
||||
{/* 自定义面包屑 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Breadcrumb
|
||||
items={getBreadcrumbItems()}
|
||||
className="items-center flex !mb-0"
|
||||
/>
|
||||
|
||||
{/* 在面包屑右侧显示精简版的FileInfo */}
|
||||
<div className=" ml-10 text-left flex-1 flex flex-row flex-wrap">
|
||||
<span className="mr-2 text-xl font-medium">
|
||||
{reviewData.fileInfo.fileName}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 flex items-center">
|
||||
{/* 合同编号:{reviewData.fileInfo.contractNumber} */}
|
||||
{ reviewData.fileInfo.fileType != "1" ? "卷宗" : "合同" }
|
||||
编号:{reviewData.fileInfo.contractNumber}
|
||||
{reviewData.fileInfo.fileSize && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
| {reviewData.fileInfo.fileSize} | {reviewData.fileInfo.fileFormat} | {reviewData.fileInfo.pageCount}页
|
||||
</span>
|
||||
)}
|
||||
{reviewData.fileInfo.uploadTime && (
|
||||
<div className="text-xs text-gray-500">
|
||||
| 上传时间:{reviewData.fileInfo.uploadTime}
|
||||
{/* | 上传用户:{reviewData.fileInfo.uploadUser} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完成评查按钮 */}
|
||||
<button
|
||||
onClick={handleConfirmResults}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-green-800 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-800"
|
||||
>
|
||||
<i className="ri-check-double-line mr-1.5"></i>
|
||||
完成评查
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文件信息和操作按钮 */}
|
||||
{/* <FileInfo
|
||||
fileInfo={{
|
||||
...reviewData.fileInfo,
|
||||
previousRoute: loaderData.previousRoute
|
||||
}}
|
||||
onConfirmResults={handleConfirmResults}
|
||||
/> */}
|
||||
|
||||
{/* 交叉评查结果内容 */}
|
||||
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4 lg:justify-between">
|
||||
{/* 左侧:文件预览 */}
|
||||
<div className="w-full lg:w-[62%]">
|
||||
<FilePreview
|
||||
fileContent={document}
|
||||
reviewPoints={reviewData.reviewPoints}
|
||||
activeReviewPointResultId={activeReviewPointResultId}
|
||||
targetPage={targetPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:评查结果 */}
|
||||
<div className="w-full lg:w-[35%]">
|
||||
<ReviewPointsList
|
||||
reviewPoints={reviewData.reviewPoints}
|
||||
statistics={reviewData.statistics}
|
||||
activeReviewPointResultId={activeReviewPointResultId}
|
||||
onReviewPointSelect={handleReviewPointSelect}
|
||||
onStatusChange={handleReviewPointStatusChange}
|
||||
scoringProposals={scoring_proposals as ScoringProposal[]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟评查数据
|
||||
function getMockReviewData(): ReviewData {
|
||||
return {
|
||||
fileInfo: {
|
||||
fileName: "烟草产品销售合同(2023版).docx",
|
||||
contractNumber: "XS-2023-1025-001",
|
||||
fileSize: "5.2MB",
|
||||
fileFormat: "DOCX",
|
||||
pageCount: 5,
|
||||
uploadTime: "2023-10-25 14:30:45",
|
||||
uploadUser: "张三",
|
||||
auditStatus: 0,
|
||||
fileType: "1"
|
||||
},
|
||||
contractInfo: {
|
||||
contractType: "销售合同",
|
||||
signDate: "2023年10月20日",
|
||||
parties: {
|
||||
partyA: "XX烟草公司",
|
||||
partyB: "YY贸易有限公司"
|
||||
},
|
||||
amount: "¥ 1,580,000.00",
|
||||
period: "2023年11月1日至2024年10月31日"
|
||||
},
|
||||
reviewInfo: {
|
||||
reviewTime: "2023-10-25 14:35:12",
|
||||
reviewModel: "DeepSeek",
|
||||
ruleGroup: "合同标准规则组",
|
||||
result: "warning",
|
||||
issueCount: 9
|
||||
},
|
||||
statistics: {
|
||||
total: 15,
|
||||
success: 6,
|
||||
warning: 7,
|
||||
error: 2,
|
||||
score: 75
|
||||
},
|
||||
fileContent: {
|
||||
title: "烟草产品销售合同",
|
||||
contractNumber: "XS-2023-1025-001",
|
||||
parties: {
|
||||
partyA: {
|
||||
name: "XX烟草公司",
|
||||
address: "XX省XX市XX区XX路XX号",
|
||||
representative: "张XX",
|
||||
phone: "123-4567-8901"
|
||||
},
|
||||
partyB: {
|
||||
name: "YY贸易有限公司",
|
||||
address: "XX省XX市XX区YY路YY号",
|
||||
representative: "李YY",
|
||||
phone: "123-4567-8902"
|
||||
}
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
title: "总则",
|
||||
content: "1.1 本合同适用于甲乙双方之间的烟草制品买卖事宜。\n1.2 双方应本着平等互利、诚实信用的原则履行本合同。"
|
||||
},
|
||||
{
|
||||
title: "合同标的物",
|
||||
content: "2.1 产品名称:烟草制品\n2.2 规格型号:如附件所列\n2.3 数量:5000箱\n2.4 质量要求:符合国家标准GB/T XXXXX-XXXX"
|
||||
},
|
||||
{
|
||||
title: "交货与付款",
|
||||
content: "3.1 交货时间:自合同签订之日起30日内。\n3.2 乙方应在收到货物之日起5个工作日内支付合同款项,甲方应在收到乙方全部付款后开具增值税专用发票,乙方应在收到发票后支付剩余款项。\n3.3 交货地点:乙方指定的仓库。\n3.4 运输方式:陆运,运费由甲方承担。"
|
||||
},
|
||||
{
|
||||
title: "合同文本",
|
||||
content: "本合同一式两份,甲乙双方各执一份,具有同等法律效力。"
|
||||
}
|
||||
]
|
||||
},
|
||||
reviewPoints: [
|
||||
{
|
||||
id: "1",
|
||||
pointName: "付款条款",
|
||||
title: "付款条件描述不明确",
|
||||
groupName: "付款条款清晰性",
|
||||
// location: "交货与付款条款",
|
||||
status: "error",
|
||||
editAuditStatus: 0,
|
||||
content: {
|
||||
'anjia':{
|
||||
page: 1,
|
||||
value: { text: "乙方应在收到货物之日起5个工作日内支付合同款项,甲方应在收到乙方全部付款后开具增值税专用发票,乙方应在收到发票后支付剩余款项。" }
|
||||
},
|
||||
'yijia':{
|
||||
page: 1,
|
||||
value: { text: "乙方应在收到货物之日起5个工作日内支付合同款项,甲方应在收到乙方全部付款后开具增值税专用发票,乙方应在收到发票后支付剩余款项。" }
|
||||
}
|
||||
},
|
||||
suggestion: "乙方应在收到货物验收合格之日起5个工作日内支付合同总额的70%,甲方收到该部分款项后3个工作日内向乙方开具等额增值税专用发票;乙方应在收到发票之日起5个工作日内支付剩余30%款项。",
|
||||
position: { section: "交货与付款", index: 2 },
|
||||
result: false
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
pointName: "违约责任",
|
||||
title: "违约责任条款缺失",
|
||||
groupName: "合同权利义务对等性",
|
||||
status: "warning",
|
||||
editAuditStatus: 0,
|
||||
content: {
|
||||
'clause': {
|
||||
page: 1,
|
||||
value: { text: "如合同发生纠纷,双方应协商解决。" }
|
||||
}
|
||||
},
|
||||
suggestion: "如合同发生纠纷,双方应友好协商解决;协商不成的,任何一方均有权向甲方所在地人民法院提起诉讼。任何一方未能履行本合同约定义务,应向守约方支付合同总金额的10%作为违约金;给对方造成损失的,还应赔偿由此产生的全部损失。",
|
||||
position: { section: "争议解决", index: 0 },
|
||||
result: false
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
pointName: "签章审核",
|
||||
title: "签章不完整",
|
||||
groupName: "合同签署规范性",
|
||||
status: "warning",
|
||||
editAuditStatus: 0,
|
||||
content: {
|
||||
'signature': {
|
||||
page: 5,
|
||||
value: { text: "乙方(盖章):YY贸易有限公司\n代表人签字:李YY\n日期:2023年10月20日" }
|
||||
}
|
||||
},
|
||||
suggestion: "需要联系甲方补充公章",
|
||||
needsHumanReview: true,
|
||||
humanReviewNote: "需要联系甲方补充公章",
|
||||
position: { section: "签章", index: 0 },
|
||||
result: false
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
pointName: "交货方式",
|
||||
title: "交货方式描述模糊",
|
||||
groupName: "履行条款明确性",
|
||||
status: "success",
|
||||
editAuditStatus: 0,
|
||||
content: {
|
||||
'delivery': {
|
||||
page: 3,
|
||||
value: { text: "3.4 运输方式:陆运,运费由甲方承担。" }
|
||||
}
|
||||
},
|
||||
suggestion: "建议补充具体的运输方式和时间",
|
||||
needsHumanReview: true,
|
||||
humanReviewNote: "经核实,该交货方式虽然描述不够详细,但符合行业惯例且双方已经多次合作,不会造成实际履行障碍。",
|
||||
humanReviewBy: "王法务",
|
||||
humanReviewTime: "2023-11-05 14:30:22",
|
||||
position: { section: "交货与付款", index: 4 },
|
||||
result: true
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
pointName: "法律适用",
|
||||
title: "法律适用条款缺失",
|
||||
groupName: "争议解决条款完整性",
|
||||
status: "error",
|
||||
editAuditStatus: 0,
|
||||
content: {
|
||||
'missing': {
|
||||
page: 0,
|
||||
value: { text: "" }
|
||||
}
|
||||
},
|
||||
suggestion: "第十三条 法律适用\n本合同的订立、效力、解释、履行及争议的解决均适用中华人民共和国法律。因本合同引起的或与本合同有关的任何争议,双方应友好协商解决。协商不成的,提交甲方所在地人民法院诉讼解决。",
|
||||
position: { section: "缺失", index: 0 },
|
||||
result: false
|
||||
}
|
||||
],
|
||||
aiAnalysis: {
|
||||
riskAlerts: [
|
||||
{
|
||||
title: "风险提示",
|
||||
content: "本合同缺少违约责任条款,可能导致权责不明。",
|
||||
description: "根据《中华人民共和国民法典》第五百七十七条规定,建议增加违约责任条款,明确双方违约责任及赔偿方式。"
|
||||
},
|
||||
{
|
||||
title: "完整性检查",
|
||||
content: "本合同缺少法律适用条款。",
|
||||
description: "根据行业惯例,销售合同应明确约定适用法律和纠纷解决方式,以避免后续争议解决时的不确定性。"
|
||||
}
|
||||
],
|
||||
suggestions: [
|
||||
{
|
||||
title: "优化建议",
|
||||
content: "建议完善付款条件描述。",
|
||||
description: "目前合同中关于付款条件的描述存在歧义,可能导致付款时间和条件不明确。建议按系统修改建议优化。"
|
||||
}
|
||||
],
|
||||
summary: "本合同基本结构完整,主体内容清晰,但存在多处条款描述不完善的问题,主要体现在支付条件、违约责任、不可抗力、保密条款、合同终止条件等方面。这些问题虽不影响合同的基本合规性,但可能在合同履行过程中引发争议和纠纷。同时,合同签章不完整,也影响了合同的法律效力。建议对上述问题进行修改完善后再行签署。"
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
:root {
|
||||
--primary-color: #00684a;
|
||||
--primary-hover: #005a40;
|
||||
--primary-light: rgba(0, 104, 74, 0.1);
|
||||
--success-color: #52c41a;
|
||||
--warning-color: #faad14;
|
||||
--error-color: #ff4d4f;
|
||||
--text-color: rgba(0, 0, 0, 0.85);
|
||||
--text-secondary: rgba(0, 0, 0, 0.45);
|
||||
--border-color: #f0f0f0;
|
||||
--bg-gray: #f5f5f5;
|
||||
}
|
||||
|
||||
.cross-checking-result-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cross-checking-result-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 文件信息和操作按钮区域 */
|
||||
.file-info-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 文件预览区域 */
|
||||
.file-preview {
|
||||
background-color: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
height: calc(100vh - 80px);
|
||||
min-height: 500px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-preview-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--primary-light);
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.file-preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* PDF文档样式约束 */
|
||||
.react-pdf__Document {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.react-pdf__Page {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.react-pdf__Page__canvas {
|
||||
max-width: 100%;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.file-preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 文档内容样式 */
|
||||
.word-document {
|
||||
padding: 40px;
|
||||
background-color: white;
|
||||
font-family: 'SimSun', serif;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.word-document h1 {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.word-document h2 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.word-document p {
|
||||
text-indent: 2em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 高亮区域 */
|
||||
.highlight-area {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
margin: -2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.highlight-area:hover {
|
||||
box-shadow: 0 0 0 3px rgba(250, 173, 20, 0.6);
|
||||
}
|
||||
|
||||
.highlight-area.warning {
|
||||
background-color: rgba(250, 173, 20, 0.2);
|
||||
border: 2px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.highlight-area.error {
|
||||
background-color: rgba(255, 77, 79, 0.2);
|
||||
border: 2px solid var(--error-color);
|
||||
}
|
||||
|
||||
.highlight-area.success {
|
||||
background-color: rgba(82, 196, 26, 0.2);
|
||||
border: 2px solid var(--success-color);
|
||||
}
|
||||
|
||||
/* 评查点列表区域 */
|
||||
.review-points-panel {
|
||||
background-color: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
height: calc(100vh - 80px);
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-panel-header {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--primary-light);
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.review-statistics {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.review-points-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.review-point-item {
|
||||
padding: 5px 10px 10px 10px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.review-point-item:hover {
|
||||
box-shadow: 1px 4px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.review-point-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.review-point-title {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.review-point-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
width: fit-content;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
background-color: #f9f0ff;
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: #e7ffcd;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: #fff1f0;
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: #fff2aec7;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
/* 替换动作按钮 */
|
||||
.replace-action {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.replace-action i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.replace-action:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 人工审核标记 */
|
||||
.human-review-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.human-review-note {
|
||||
background-color: #e6f7ff;
|
||||
border-left: 3px solid #1890ff;
|
||||
padding: 6px 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 文件详情页面 */
|
||||
.info-section {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
padding: 16px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
width: 120px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Navicat Premium Data Transfer
|
||||
|
||||
Source Server : 智慧法务
|
||||
Source Server Type : PostgreSQL
|
||||
Source Server Version : 170005
|
||||
Source Host : nas.7bm.co:54302
|
||||
Source Catalog : docauditai
|
||||
Source Schema : public
|
||||
|
||||
Target Server Type : PostgreSQL
|
||||
Target Server Version : 170005
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 09/07/2025 10:25:35
|
||||
*/
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for document_types
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS "public"."document_types";
|
||||
CREATE TABLE "public"."document_types" (
|
||||
"id" int4 NOT NULL DEFAULT nextval('document_types_id_seq'::regclass),
|
||||
"name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"description" text COLLATE "pg_catalog"."default",
|
||||
"evaluation_point_groups_ids" jsonb,
|
||||
"prompt_config" jsonb,
|
||||
"created_at" timestamptz(6) DEFAULT now(),
|
||||
"updated_at" timestamptz(6) DEFAULT now(),
|
||||
"code" varchar(255) COLLATE "pg_catalog"."default"
|
||||
)
|
||||
;
|
||||
COMMENT ON COLUMN "public"."document_types"."id" IS '类型ID,主键,自增';
|
||||
COMMENT ON COLUMN "public"."document_types"."name" IS '文档类型名称,唯一且非空';
|
||||
COMMENT ON COLUMN "public"."document_types"."description" IS '类型描述,可为空';
|
||||
COMMENT ON COLUMN "public"."document_types"."evaluation_point_groups_ids" IS '关联的评查点组ID,JSONB格式';
|
||||
COMMENT ON COLUMN "public"."document_types"."prompt_config" IS '提示词配置,JSONB格式';
|
||||
COMMENT ON COLUMN "public"."document_types"."created_at" IS '创建时间,带时区,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."document_types"."updated_at" IS '更新时间,带时区,默认当前时间';
|
||||
COMMENT ON TABLE "public"."document_types" IS '文档类型表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Triggers structure for table document_types
|
||||
-- ----------------------------
|
||||
CREATE TRIGGER "update_document_types_updated_at" BEFORE UPDATE ON "public"."document_types"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."update_updated_at_column"();
|
||||
|
||||
-- ----------------------------
|
||||
-- Uniques structure for table document_types
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."document_types" ADD CONSTRAINT "document_types_name_key" UNIQUE ("name");
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table document_types
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."document_types" ADD CONSTRAINT "document_types_pkey" PRIMARY KEY ("id");
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Navicat Premium Data Transfer
|
||||
|
||||
Source Server : 智慧法务
|
||||
Source Server Type : PostgreSQL
|
||||
Source Server Version : 170005
|
||||
Source Host : nas.7bm.co:54302
|
||||
Source Catalog : docauditai
|
||||
Source Schema : public
|
||||
|
||||
Target Server Type : PostgreSQL
|
||||
Target Server Version : 170005
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 09/07/2025 10:25:25
|
||||
*/
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for documents
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS "public"."documents";
|
||||
CREATE TABLE "public"."documents" (
|
||||
"id" int4 NOT NULL DEFAULT nextval('documents_id_seq'::regclass),
|
||||
"user_id" int4,
|
||||
"type_id" int4 NOT NULL,
|
||||
"name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"document_number" varchar(100) COLLATE "pg_catalog"."default",
|
||||
"path" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"storage_type" varchar(20) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'minio'::character varying,
|
||||
"file_size" int4,
|
||||
"upload_time" timestamp(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"is_test_document" bool DEFAULT false,
|
||||
"evaluation_level" varchar(255) COLLATE "pg_catalog"."default",
|
||||
"status" varchar(20) COLLATE "pg_catalog"."default" DEFAULT 'waiting'::character varying,
|
||||
"ocr_result" jsonb,
|
||||
"extracted_results" jsonb,
|
||||
"sumary" text COLLATE "pg_catalog"."default",
|
||||
"remark" text COLLATE "pg_catalog"."default",
|
||||
"created_at" timestamptz(6) DEFAULT now(),
|
||||
"updated_at" timestamptz(6) DEFAULT now(),
|
||||
"evaluations_status" int4,
|
||||
"audit_status" int4,
|
||||
"error_massage" varchar(500) COLLATE "pg_catalog"."default"
|
||||
)
|
||||
;
|
||||
COMMENT ON COLUMN "public"."documents"."id" IS '文档ID,主键,自增';
|
||||
COMMENT ON COLUMN "public"."documents"."user_id" IS '上传用户的ID,外键引用users表,可为空';
|
||||
COMMENT ON COLUMN "public"."documents"."type_id" IS '文档类型ID,外键引用document_types表,非空';
|
||||
COMMENT ON COLUMN "public"."documents"."name" IS '原始文件名,非空';
|
||||
COMMENT ON COLUMN "public"."documents"."document_number" IS '合同编号、许可证号等,可为空';
|
||||
COMMENT ON COLUMN "public"."documents"."path" IS '存储路径,非空';
|
||||
COMMENT ON COLUMN "public"."documents"."storage_type" IS '文件存储方式,默认minio';
|
||||
COMMENT ON COLUMN "public"."documents"."file_size" IS '文件大小,单位字节,可为空';
|
||||
COMMENT ON COLUMN "public"."documents"."upload_time" IS '上传时间,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."documents"."is_test_document" IS '是否是测试文档,默认false';
|
||||
COMMENT ON COLUMN "public"."documents"."status" IS '处理状态,默认waiting(waiting: 上传时默认状态, Cutting: 切分+OCR处理中, extractioning: 大模型抽取信息中, evaluationing: 评查中, processed: 文档处理完成等待审核)';
|
||||
COMMENT ON COLUMN "public"."documents"."ocr_result" IS 'OCR处理结果,JSONB格式';
|
||||
COMMENT ON COLUMN "public"."documents"."extracted_results" IS '内容抽取结果,JSONB格式';
|
||||
COMMENT ON COLUMN "public"."documents"."sumary" IS '评查结果,可为空';
|
||||
COMMENT ON COLUMN "public"."documents"."remark" IS '备注,可为空';
|
||||
COMMENT ON COLUMN "public"."documents"."created_at" IS '创建时间,带时区,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."documents"."updated_at" IS '更新时间,带时区,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."documents"."evaluations_status" IS '评查状态:
|
||||
* 通过 :1
|
||||
* 警告 :-2
|
||||
* 不通过 :-1
|
||||
* 待人工确认 :0';
|
||||
COMMENT ON COLUMN "public"."documents"."audit_status" IS '审核状态:
|
||||
* 待审核:0
|
||||
* 通过:1
|
||||
* 审核中:2
|
||||
* 不通过:-1
|
||||
* 警告:-2';
|
||||
COMMENT ON TABLE "public"."documents" IS '文档表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Triggers structure for table documents
|
||||
-- ----------------------------
|
||||
CREATE TRIGGER "trg_set_updated_at" BEFORE UPDATE ON "public"."documents"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."update_updated_at"();
|
||||
CREATE TRIGGER "update_documents_updated_at" BEFORE UPDATE ON "public"."documents"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."update_updated_at_column"();
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table documents
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."documents" ADD CONSTRAINT "documents_pkey" PRIMARY KEY ("id");
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table documents
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."documents" ADD CONSTRAINT "documents_type_id_fkey" FOREIGN KEY ("type_id") REFERENCES "public"."document_types" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "public"."documents" ADD CONSTRAINT "documents_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Navicat Premium Data Transfer
|
||||
|
||||
Source Server : 智慧法务
|
||||
Source Server Type : PostgreSQL
|
||||
Source Server Version : 170005
|
||||
Source Host : nas.7bm.co:54302
|
||||
Source Catalog : docauditai
|
||||
Source Schema : public
|
||||
|
||||
Target Server Type : PostgreSQL
|
||||
Target Server Version : 170005
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 09/07/2025 16:21:17
|
||||
*/
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for evaluation_points
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS "public"."evaluation_points";
|
||||
CREATE TABLE "public"."evaluation_points" (
|
||||
"id" int4 NOT NULL DEFAULT nextval('evaluation_points_id_seq'::regclass),
|
||||
"code" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"evaluation_point_groups_id" int4,
|
||||
"risk" varchar(10) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"description" text COLLATE "pg_catalog"."default",
|
||||
"is_enabled" bool DEFAULT true,
|
||||
"references_laws" jsonb NOT NULL,
|
||||
"extraction_config" jsonb NOT NULL,
|
||||
"evaluation_config" jsonb NOT NULL,
|
||||
"pass_message" text COLLATE "pg_catalog"."default",
|
||||
"fail_message" text COLLATE "pg_catalog"."default",
|
||||
"suggestion_message" text COLLATE "pg_catalog"."default",
|
||||
"suggestion_message_type" varchar(20) COLLATE "pg_catalog"."default" DEFAULT 'warning'::character varying,
|
||||
"post_action" varchar(50) COLLATE "pg_catalog"."default",
|
||||
"action_config" text COLLATE "pg_catalog"."default",
|
||||
"created_at" timestamptz(6) DEFAULT now(),
|
||||
"updated_at" timestamptz(6) DEFAULT now(),
|
||||
"evaluation_point_groups_pid" int4,
|
||||
"score" numeric(5,2) DEFAULT 0.00
|
||||
)
|
||||
;
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."id" IS '主键,自增';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."code" IS '评查点编码,唯一且非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."name" IS '评查点名称,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."evaluation_point_groups_id" IS '所属分组ID,外键引用evaluation_point_groups表';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."risk" IS '风险等级,非空(高/中/低)';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."description" IS '评查点描述,可为空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."is_enabled" IS '是否启用,默认true';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."references_laws" IS '引用法典,JSONB格式,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."extraction_config" IS '抽取配置,JSONB格式,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."evaluation_config" IS '评查设置,JSONB格式,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."pass_message" IS '通过提示,可为空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."fail_message" IS '不通过提示,可为空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."suggestion_message" IS '建议信息,可为空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."suggestion_message_type" IS '建议信息类型,默认warning(info/warning/error)';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."post_action" IS '评查后动作类型,可为空(none/manual/replace)';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."action_config" IS '动作配置,可为空';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."created_at" IS '创建时间,带时区,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."updated_at" IS '更新时间,带时区,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."evaluation_point_groups_pid" IS '所属分组PID,外键引用evaluation_point_groups表';
|
||||
COMMENT ON COLUMN "public"."evaluation_points"."score" IS '评查点得分';
|
||||
COMMENT ON TABLE "public"."evaluation_points" IS '评查点表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Triggers structure for table evaluation_points
|
||||
-- ----------------------------
|
||||
CREATE TRIGGER "update_evaluation_points_updated_at" BEFORE UPDATE ON "public"."evaluation_points"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."update_updated_at_column"();
|
||||
|
||||
-- ----------------------------
|
||||
-- Uniques structure for table evaluation_points
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."evaluation_points" ADD CONSTRAINT "evaluation_points_code_key" UNIQUE ("code");
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table evaluation_points
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."evaluation_points" ADD CONSTRAINT "evaluation_points_pkey" PRIMARY KEY ("id");
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Navicat Premium Data Transfer
|
||||
|
||||
Source Server : 智慧法务
|
||||
Source Server Type : PostgreSQL
|
||||
Source Server Version : 170005
|
||||
Source Host : nas.7bm.co:54302
|
||||
Source Catalog : docauditai
|
||||
Source Schema : public
|
||||
|
||||
Target Server Type : PostgreSQL
|
||||
Target Server Version : 170005
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 09/07/2025 10:25:50
|
||||
*/
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for evaluation_results
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS "public"."evaluation_results";
|
||||
CREATE TABLE "public"."evaluation_results" (
|
||||
"id" int4 NOT NULL DEFAULT nextval('evaluation_results_id_seq'::regclass),
|
||||
"document_id" int4 NOT NULL,
|
||||
"evaluation_point_id" int4 NOT NULL,
|
||||
"status" varchar(20) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"extracted_results" jsonb NOT NULL,
|
||||
"rules_results" jsonb NOT NULL,
|
||||
"evaluated_results" jsonb NOT NULL,
|
||||
"created_at" timestamptz(6) DEFAULT now(),
|
||||
"updated_at" timestamptz(6) DEFAULT now(),
|
||||
"evaluated_point_results_log" jsonb
|
||||
)
|
||||
;
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."id" IS '主键,自增';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."document_id" IS '文档ID,外键引用documents表,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."evaluation_point_id" IS '评查点ID,外键引用evaluation_points表,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."status" IS '结果状态,非空(pending/extracted/evaluated/failed)';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."extracted_results" IS '抽取结果,JSONB格式,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."rules_results" IS '规则判断结果,JSONB格式,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."evaluated_results" IS '评查结果,JSONB格式,非空';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."created_at" IS '创建时间,带时区,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."updated_at" IS '更新时间,带时区,默认当前时间';
|
||||
COMMENT ON COLUMN "public"."evaluation_results"."evaluated_point_results_log" IS '记录每个评查点规则的评查记录';
|
||||
COMMENT ON TABLE "public"."evaluation_results" IS '评查点结果表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Triggers structure for table evaluation_results
|
||||
-- ----------------------------
|
||||
CREATE TRIGGER "update_evaluation_results_updated_at" BEFORE UPDATE ON "public"."evaluation_results"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."update_updated_at_column"();
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table evaluation_results
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."evaluation_results" ADD CONSTRAINT "evaluation_results_pkey" PRIMARY KEY ("id");
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table evaluation_results
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."evaluation_results" ADD CONSTRAINT "evaluation_results_evaluation_point_id_fkey" FOREIGN KEY ("evaluation_point_id") REFERENCES "public"."evaluation_points" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Navicat Premium Data Transfer
|
||||
|
||||
Source Server : 智慧法务
|
||||
Source Server Type : PostgreSQL
|
||||
Source Server Version : 170005
|
||||
Source Host : nas.7bm.co:54302
|
||||
Source Catalog : docauditai
|
||||
Source Schema : public
|
||||
|
||||
Target Server Type : PostgreSQL
|
||||
Target Server Version : 170005
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 16/07/2025 21:59:28
|
||||
*/
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for scoring_proposals
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS "public"."scoring_proposals";
|
||||
CREATE TABLE "public"."scoring_proposals" (
|
||||
"id" int4 NOT NULL DEFAULT nextval('scoring_proposals_id_seq'::regclass),
|
||||
"evaluation_result_id" int4 NOT NULL,
|
||||
"proposer_id" int4 NOT NULL,
|
||||
"proposed_score" numeric(10,2) NOT NULL,
|
||||
"reason" text COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"status" varchar(50) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'pending_review'::character varying,
|
||||
"created_at" timestamptz(6) NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz(6) NOT NULL DEFAULT now(),
|
||||
"document_id" int4 NOT NULL
|
||||
)
|
||||
;
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."id" IS '计分提案的唯一标识符。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."evaluation_result_id" IS '关联的评查结果ID,外键引用 evaluation_results 表。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."proposer_id" IS '提出提案的用户ID,外键引用 users 表。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."proposed_score" IS '提案中建议的修正分数。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."reason" IS '修改分数的理由说明。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."status" IS '提案的当前状态:待审核、已批准或已否决。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."created_at" IS '提案创建的时间戳。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."updated_at" IS '提案最后更新的时间戳。';
|
||||
COMMENT ON COLUMN "public"."scoring_proposals"."document_id" IS '关联的文档ID,用于快速查询和统计';
|
||||
COMMENT ON TABLE "public"."scoring_proposals" IS '存储用户对评查结果提出的计分修改提案。';
|
||||
|
||||
-- ----------------------------
|
||||
-- Indexes structure for table scoring_proposals
|
||||
-- ----------------------------
|
||||
CREATE INDEX "idx_scoring_proposals_document_id" ON "public"."scoring_proposals" USING btree (
|
||||
"document_id" "pg_catalog"."int4_ops" ASC NULLS LAST
|
||||
);
|
||||
CREATE INDEX "idx_scoring_proposals_document_status" ON "public"."scoring_proposals" USING btree (
|
||||
"document_id" "pg_catalog"."int4_ops" ASC NULLS LAST,
|
||||
"status" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
|
||||
);
|
||||
CREATE INDEX "idx_scoring_proposals_evaluation_result_id" ON "public"."scoring_proposals" USING btree (
|
||||
"evaluation_result_id" "pg_catalog"."int4_ops" ASC NULLS LAST
|
||||
);
|
||||
CREATE INDEX "idx_scoring_proposals_proposer_id" ON "public"."scoring_proposals" USING btree (
|
||||
"proposer_id" "pg_catalog"."int4_ops" ASC NULLS LAST
|
||||
);
|
||||
CREATE UNIQUE INDEX "uq_pending_proposal" ON "public"."scoring_proposals" USING btree (
|
||||
"evaluation_result_id" "pg_catalog"."int4_ops" ASC NULLS LAST
|
||||
) WHERE status::text = 'pending_review'::text;
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table scoring_proposals
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."scoring_proposals" ADD CONSTRAINT "scoring_proposals_pkey" PRIMARY KEY ("id");
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table scoring_proposals
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."scoring_proposals" ADD CONSTRAINT "scoring_proposals_evaluation_result_id_fkey" FOREIGN KEY ("evaluation_result_id") REFERENCES "public"."evaluation_results" ("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
ALTER TABLE "public"."scoring_proposals" ADD CONSTRAINT "scoring_proposals_proposer_id_fkey" FOREIGN KEY ("proposer_id") REFERENCES "public"."users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,82 @@
|
||||
CREATE OR REPLACE FUNCTION "public"."get_review_files_with_details"("p_keyword" text=NULL::text, "p_typeid" _int4=NULL::integer[], "p_evaluations_status" int4=NULL::integer, "p_date_from" date=NULL::date, "p_date_to" date=NULL::date, "p_sort_order" text='created_at_desc'::text, "p_page" int4=1, "p_page_size" int4=10)
|
||||
RETURNS TABLE("id" int4, "status" varchar, "path" varchar, "file_name" varchar, "file_code" varchar, "file_type_name" varchar, "file_type_id" int4, "file_size" int4, "upload_time" timestamptz, "created_at" timestamptz, "evaluations_status" int4, "audit_status" int4, "created_by_user_id" int4, "issue_count" int8, "total_score" numeric, "pass_count" int8, "warning_count" int8, "fail_count" int8, "manual_count" int8, "issues" jsonb) AS $BODY$
|
||||
DECLARE
|
||||
offset_val integer;
|
||||
sort_column text;
|
||||
sort_direction text;
|
||||
BEGIN
|
||||
offset_val := (p_page - 1) * p_page_size;
|
||||
|
||||
SELECT
|
||||
CASE
|
||||
WHEN p_sort_order = 'upload_time_asc' THEN 'created_at'
|
||||
WHEN p_sort_order = 'upload_time_desc' THEN 'created_at'
|
||||
WHEN p_sort_order = 'issue_count_asc' THEN 'issue_count'
|
||||
WHEN p_sort_order = 'issue_count_desc' THEN 'issue_count'
|
||||
ELSE 'created_at' -- 默认排序字段
|
||||
END,
|
||||
CASE
|
||||
WHEN p_sort_order LIKE '%_asc' THEN 'ASC'
|
||||
ELSE 'DESC'
|
||||
END
|
||||
INTO sort_column, sort_direction;
|
||||
|
||||
RETURN QUERY EXECUTE format('
|
||||
WITH doc_agg AS (
|
||||
SELECT
|
||||
er.document_id,
|
||||
COUNT(*) FILTER (WHERE (er.evaluated_results->>''result'')::boolean = false) AS issue_count,
|
||||
SUM(ep.score) FILTER (WHERE (er.evaluated_results->>''result'')::boolean = true) AS total_score,
|
||||
COUNT(*) FILTER (WHERE (er.evaluated_results->>''result'')::boolean = true) AS pass_count,
|
||||
COUNT(*) FILTER (WHERE (er.evaluated_results->>''result'')::boolean = false AND (ep.suggestion_message_type = ''warning'' OR ep.suggestion_message_type = ''info'')) AS warning_count,
|
||||
COUNT(*) FILTER (WHERE (er.evaluated_results->>''result'')::boolean = false AND ep.suggestion_message_type = ''error'') AS fail_count,
|
||||
COUNT(*) FILTER (WHERE ep.post_action = ''manual'') AS manual_count,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
''severity'', ep.suggestion_message_type,
|
||||
''message'', er.evaluated_results->>''message''
|
||||
)
|
||||
) FILTER (WHERE (er.evaluated_results->>''result'')::boolean = false) AS issues
|
||||
FROM evaluation_results er
|
||||
JOIN evaluation_points ep ON er.evaluation_point_id = ep.id
|
||||
GROUP BY er.document_id
|
||||
)
|
||||
SELECT
|
||||
d.id,
|
||||
d.status,
|
||||
d.path,
|
||||
d.name AS file_name,
|
||||
d.document_number AS file_code,
|
||||
dt.name AS file_type_name,
|
||||
d.type_id AS file_type_id,
|
||||
d.file_size,
|
||||
d.upload_time::timestamptz,
|
||||
d.created_at,
|
||||
d.evaluations_status,
|
||||
d.audit_status,
|
||||
d.user_id AS created_by_user_id,
|
||||
COALESCE(agg.issue_count, 0) AS issue_count,
|
||||
COALESCE(agg.total_score, 0) AS total_score,
|
||||
COALESCE(agg.pass_count, 0) AS pass_count,
|
||||
COALESCE(agg.warning_count, 0) AS warning_count,
|
||||
COALESCE(agg.fail_count, 0) AS fail_count,
|
||||
COALESCE(agg.manual_count, 0) AS manual_count,
|
||||
agg.issues
|
||||
FROM documents d
|
||||
LEFT JOIN document_types dt ON d.type_id = dt.id
|
||||
LEFT JOIN doc_agg agg ON d.id = agg.document_id
|
||||
WHERE
|
||||
($1 IS NULL OR (d.name ILIKE ''%%'' || $1 || ''%%'' OR d.document_number ILIKE ''%%'' || $1 || ''%%'')) AND
|
||||
($2 IS NULL OR d.type_id = ANY($2)) AND
|
||||
($3 IS NULL OR d.evaluations_status = $3) AND
|
||||
($4 IS NULL OR d.created_at >= $4) AND
|
||||
($5 IS NULL OR d.created_at < ($5 + INTERVAL ''1 day''))
|
||||
ORDER BY %I %s
|
||||
LIMIT $6 OFFSET $7
|
||||
', sort_column, sort_direction)
|
||||
USING p_keyword, p_typeid, p_evaluations_status, p_date_from, p_date_to, p_page_size, offset_val;
|
||||
END;
|
||||
$BODY$
|
||||
LANGUAGE plpgsql VOLATILE
|
||||
COST 100
|
||||
ROWS 1000;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Reference in New Issue
Block a user