feat: 合同审查轮询方案(先审查后扣积分、HTML报告下载、数据库记录)

This commit is contained in:
2026-10-13 17:36:38 +08:00
parent 904fd1f8f1
commit 4625e7c130
83 changed files with 845 additions and 350 deletions
+255 -118
View File
@@ -502,22 +502,27 @@
<!-- 合同审查 -->
<view v-if="menuIndex === '4'" class="review-container">
<view class="review-title">📋 AI 合同审查</view>
<!-- 积分提示 -->
<view class="points-notice">
<span class="pn-icon">💎</span>
<view class="pn-text">
<view class="pn-title">合同审查消耗积分</view>
<view class="pn-desc">每页消耗 60 积分,上传后自动计算。</view>
</view>
<view class="pn-balance">余额:{{ pointsBalance }} 分</view>
</view>
<!-- 上传区 -->
<view v-if="!reviewFile" class="upload-area" @click="selectReviewFile">
<view class="upload-icon">📤</view>
<view class="upload-title">上传合同文件</view>
<view class="upload-desc">支持 PDF / Word / 图片,最大 20MB</view>
<view class="upload-btn">选择文件上传</view>
<!-- 积分提示 -->
<view class="points-notice">
<span class="pn-icon">💎</span>
<view class="pn-text">
<view class="pn-title">合同审查消耗积分</view>
<view class="pn-desc">根据文件大小自动计算,每 100KB 约 150 积分。审查后余额需保留至少 200 积分。</view>
</view>
<view class="pn-balance">余额:{{ pointsBalance }} 分</view>
</view>
<!-- 免责声明 -->
<view class="disclaimer-notice">
<span class="dn-icon">⚠️</span>
<view class="dn-text">请确保合同内容清晰可见。如因合同不清晰导致分析失败,平台不承担后果,积分正常消耗。</view>
</view>
<!-- 上传区 -->
<view v-if="!reviewFile" class="upload-area" @click="selectReviewFile">
<view class="upload-icon">📤</view>
<view class="upload-title">上传合同文件</view>
<view class="upload-desc">仅支持 PDF / Word(.pdf .doc .docx),最大 20MB</view>
<view class="upload-btn">选择文件上传</view>
</view>
<!-- 上传中提示 -->
<view v-if="reviewUploading" style="text-align:center;padding:30px 0;">
<i class="el-icon-loading" style="font-size:32px;color:#a92924;"></i>
@@ -547,11 +552,17 @@
<view class="opt-points">{{ reviewPoints * 2 }} 积分</view>
</view> -->
</view>
<view style="text-align:center;margin-top:16px;">
<el-button type="primary" :loading="reviewLoading" @click="startReview">
{{ reviewLoading ? '审查中...' : '开始审查(' + (reviewMode === 0 ? reviewPoints : reviewPoints * 2) + ' 积分)' }}
</el-button>
</view>
<view style="text-align:center;margin-top:16px;">
<el-button type="primary" :loading="reviewLoading"
:disabled="pointsBalance < reviewPoints + 200"
@click="startReview">
{{ reviewLoading ? '审查中...' : '开始审查(' + reviewPoints + ' 积分)' }}
</el-button>
<p v-if="pointsBalance < reviewPoints + 200" style="font-size:12px;color:#f56c6c;margin-top:6px;">
⚠️ 积分不足,当前余额 {{ pointsBalance }},需要 {{ reviewPoints }} 积分且审查后余额需保留至少 200 积分。
<span style="color:#a92924;cursor:pointer;text-decoration:underline;" @click="$refs.aiChat ? null : null">去充值</span>
</p>
</view>
</view>
<!-- 审查中 -->
<view v-if="reviewLoading" style="text-align:center;padding:40px 0;">
@@ -560,8 +571,8 @@
<el-progress :percentage="reviewProgress" :stroke-width="8"
style="max-width:400px;margin:0 auto;"></el-progress>
</view>
<!-- 审查结果 -->
<view v-if="reviewResult">
<!-- 审查结果 -->
<view v-if="reviewResult">
<view class="result-header">
<h3>📋 审查结果 — {{ reviewFile.name }}</h3>
<span class="result-status">✅ 审查完成</span>
@@ -569,25 +580,35 @@
<view class="result-body">
<div v-html="reviewResult"></div>
</view>
<view class="result-footer">
<el-button type="primary" size="small" @click="resetReview">重新审查</el-button>
</view>
<view class="result-footer">
<el-button v-if="reviewHtmlUrl" type="success" size="small" @click="openUrl(reviewHtmlUrl)">📄 查看完整 HTML 报告</el-button>
<el-button size="small" @click="resetReview">重新审查</el-button>
</view>
</view>
</view>
<!-- 我的报告 -->
<view v-if="menuIndex === '5'" class="review-container">
<view class="review-title">📈 我的报告</view>
<view class="review-title">
📈 我的报告
<el-button size="mini" type="primary" style="margin-left:12px;" @click="queryReports()">🔍 查询报告</el-button>
</view>
<view v-if="reportList.length === 0" style="text-align:center;padding:60px 0;color:#999;">
<div style="font-size:48px;margin-bottom:12px;">📋</div>
暂无审查报告
暂无审查报告,点击上方「查询报告」从服务器加载
</view>
<view v-for="(r, i) in reportList" :key="i" class="report-item">
<view class="report-info">
<view class="report-name">{{ r.fileName }}</view>
<view class="report-time">{{ r.time }}</view>
<view class="report-name">📄 {{ r.fileName }}</view>
<view class="report-time">{{ r.time }} · 消耗 {{ Math.abs(r.amount) }} 积分</view>
</view>
<el-button size="mini" type="text" @click="viewReport(r)">查看</el-button>
<el-tag v-if="r.status === 'uploaded'" size="mini" type="info">待审查</el-tag>
<el-tag v-else-if="r.status === 'reviewing'" size="mini" type="warning">审查中</el-tag>
<el-tag v-else-if="r.status === 'completed'" size="mini" type="success">已完成</el-tag>
<el-tag v-else-if="r.status === 'failed'" size="mini" type="danger">失败</el-tag>
<el-button v-if="r.htmlUrl" size="mini" type="text" @click="openUrl(r.htmlUrl)">查看报告</el-button>
<el-button v-if="r.fileUrl" size="mini" type="text" @click="previewFile(r)">查看文件</el-button>
<!-- <el-button v-if="r.status === 'completed'" size="mini" type="text" @click="viewReport(r)">查看摘要</el-button> -->
</view>
</view>
@@ -625,12 +646,15 @@
<!-- 使用说明 -->
<view class="review-title" style="font-size:14px;margin-top:16px;">📖 积分使用说明</view>
<view style="font-size:13px;color:#666;line-height:2;margin-bottom:20px;">
<p>• <strong>标准审查</strong>:每页 60 积分 — 风险条款识别、合规性检查</p>
<p>• <strong>深度分析</strong>:标准审查 × 2 — 标准审查 + 估价参考 + 修改建议</p>
<p>• <strong>合同审查</strong>:每 100KB 约 150 积分(根据文件大小自动计算)</p>
<p>• <strong>AI 客服</strong>:免费 — 签约宝专属问答</p>
<p>• 新用户注册自动赠送 500 体验积分</p>
<p>• 积分长期有效,不过期</p>
</view>
<view class="review-title" style="font-size:14px;">
📜 积分明细
<el-button size="mini" type="text" style="margin-left:12px;" @click="getPointsLog()">🔍 查看</el-button>
</view>
<view class="review-title" style="font-size:14px;">📜 积分明细</view>
<el-table :data="pointsLogList" style="width:100%">
<el-table-column prop="_add_time_str" label="时间" width="180"></el-table-column>
<el-table-column label="类型" width="80">
@@ -844,6 +868,10 @@
// 合同审查数据
reviewFile: null,
reviewFileId: null,
reviewRecordId: null,
reviewHtmlUrl: '',
reviewPdfUrl: '',
reviewContractInfo: {},
reviewUploading: false,
reviewMode: 0,
reviewPoints: 100,
@@ -1474,6 +1502,8 @@
this.userInfo = vk.getVuex('$user.userInfo')
this.getUserInfo()
this.getPointsBalance()
if (this.menuIndex === '6') this.getPointsLog();
if (this.menuIndex === '5') this.getReportList();
},
methods: {
dayjs,
@@ -1487,8 +1517,6 @@
this.isSignTips = false
// AI 智能服务菜单(3/4/5/6)不调 query,加载对应数据
if (e >= 3) {
if (e === 6) this.getPointsLog();
if (e === 5) this.getReportList();
if (e === 4 || e === 6) this.getPointsBalance();
return;
}
@@ -1640,6 +1668,35 @@
html = html.replace(/\n/g, '<br>');
return html;
},
openUrl(url) {
if (url) window.open(url, '_blank');
},
formatReviewResult(text) {
if (!text) return '';
let html = text;
// 处理 markdown 标题 ## / ###
html = html.replace(/^###\s+(.+)$/gm, '<h4 style="margin:16px 0 8px;color:#333;">$1</h4>');
html = html.replace(/^##\s+(.+)$/gm, '<h3 style="margin:20px 0 10px;color:#222;">$1</h3>');
// 处理加粗
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#a92924;">$1</strong>');
// 处理链接(PDF下载链接等)— 自动识别 http/https 链接
html = html.replace(/(https?:\/\/[^\s\)\]\"\<]+)/g, '<a href="$1" target="_blank" style="color:#a92924;text-decoration:underline;word-break:break-all;">$1</a>');
// 处理列表
html = html.replace(/^[\-\•]\s+(.+)$/gm, '<div style="padding-left:16px;margin:4px 0;">• $1</div>');
html = html.replace(/^(\d+)\.\s+(.+)$/gm, '<div style="padding-left:16px;margin:4px 0;">$1. $2</div>');
// 处理表格行(简单的 | 分隔表格)
html = html.replace(/\|(.+)\|/g, function(match) {
const cells = match.split('|').filter(c => c.trim());
if (cells.length > 1) {
return '<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #eee;">' +
cells.map(c => '<span style="flex:1;font-size:13px;">' + c.trim() + '</span>').join('') + '</div>';
}
return match;
});
// 换行
html = html.replace(/\n/g, '<br>');
return html;
},
// ===== 积分相关 =====
async getPointsBalance() {
try {
@@ -1663,36 +1720,64 @@
}
});
if (res.code === 0) {
this.pointsLogList = (res.data.rows || []).map(r => ({
...r,
_add_time_str: dayjs(r._add_time).format('YYYY-MM-DD HH:mm:ss')
this.pointsLogList = (res.data.rows || []).map(r => ({
...r,
_add_time_str: dayjs(r._add_time && r._add_time['$date'] ? r._add_time['$date'] : r._add_time).format('YYYY-MM-DD HH:mm:ss')
}));
}
} catch (e) {
/* 静默 */ }
},
async getReportList() {
try {
const reports = JSON.parse(localStorage.getItem('jjx_review_reports') || '[]');
this.reportList = reports.map(r => ({
fileName: r.fileName,
fileSize: r.fileSize,
time: r.time,
amount: r.points,
result: r.result,
status: 'completed'
}));
} catch (e) { /* 静默 */ }
},
async queryReports() {
try {
const res = await vk.callFunction({
url: 'client/points/kh/getLog',
data: {
pageIndex: 1,
pageSize: 50,
biz_type: 'contract_review'
}
url: 'client/ai/pub/getMyReports',
data: { pageIndex: 1, pageSize: 50 }
});
if (res.code === 0) {
this.reportList = (res.data.rows || []).map(r => ({
fileName: r.reason ? r.reason.replace('合同审查 — ', '') : '未知文件',
time: dayjs(r._add_time).format('YYYY-MM-DD HH:mm:ss'),
bizId: r.biz_id
if (res.code === 0 && res.data && res.data.rows) {
this.reportList = res.data.rows.map(r => ({
fileName: r.file_name,
fileSize: r.file_size,
fileUrl: r.file_url || '',
htmlUrl: r.html_url || '',
time: dayjs(r._add_time && r._add_time['$date'] ? r._add_time['$date'] : r._add_time).format('YYYY-MM-DD HH:mm:ss'),
amount: r.points_used || 0,
result: r.result,
status: r.status,
recordId: r._id
}));
this.$message.success('加载了 ' + this.reportList.length + ' 条报告');
}
} catch (e) {
/* 静默 */ }
this.$message.error('查询失败');
}
},
viewReport(r) {
this.$message.info('查看报告:' + r.fileName);
// 跳转到合同审查页并显示该报告的结果
this.menuIndex = '4';
this.reviewResult = r.result || '暂无审查结果';
this.reviewFile = { name: r.fileName, size: r.fileSize || 0 };
},
previewFile(r) {
if (!r.fileUrl) {
this.$message.warning('文件链接不存在');
return;
}
// 新窗口打开文件(PDF直接预览,Word下载)
window.open(r.fileUrl, '_blank');
},
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
@@ -1703,7 +1788,7 @@
selectReviewFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.doc,.docx,.jpg,.png';
input.accept = '.pdf,.doc,.docx';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
@@ -1725,17 +1810,19 @@
reader.readAsDataURL(file);
});
const res = await vk.callFunction({
url: 'client/ai/pub/review',
data: {
file_base64: fileBase64,
file_name: file.name,
upload_only: true
}
});
const res = await vk.callFunction({
url: 'client/ai/pub/review',
data: {
file_base64: fileBase64,
file_name: file.name,
file_size: file.size,
upload_only: true
}
});
if (res.code === 0 && res.data && res.data.file_id) {
this.reviewFileId = res.data.file_id;
this.reviewRecordId = res.data.record_id; // 保存待审查记录ID
this.$message.success('文件上传成功');
} else {
this.$message.error(res.msg || '文件上传失败');
@@ -1755,83 +1842,125 @@
return;
}
const pointsNeeded = this.reviewMode === 0 ? this.reviewPoints : this.reviewPoints * 2;
if (this.pointsBalance < pointsNeeded) {
this.$message.warning('积分不足,当前余额 ' + this.pointsBalance + ',需要 ' + pointsNeeded);
const pointsNeeded = this.reviewPoints;
if (this.pointsBalance < pointsNeeded + 200) {
this.$message.warning('积分不足,当前余额 ' + this.pointsBalance + ',需要 ' + pointsNeeded + ' 且审查后余额需保留至少 200 积分');
return;
}
this.reviewLoading = true;
this.reviewProgress = 0;
// 模拟进度
const interval = setInterval(() => {
this.reviewProgress = Math.min(this.reviewProgress + Math.random() * 10, 95);
}, 800);
// 模拟进度条
const progressTimer = setInterval(() => {
this.reviewProgress = Math.min(this.reviewProgress + 1, 90);
}, 1500);
try {
// 1. 扣积分
const deductRes = await vk.callFunction({
url: 'client/points/kh/deduct',
// 1. 发起审查(只更新状态,立即返回)
const res = await vk.callFunction({
url: 'client/ai/pub/review',
data: {
amount: pointsNeeded,
reason: '合同审查 — ' + this.reviewFile.name,
biz_type: 'contract_review'
file_id: this.reviewFileId,
record_id: this.reviewRecordId,
file_name: this.reviewFile.name,
file_size: this.reviewFile.size
}
});
if (deductRes.code !== 0) {
this.$message.error(deductRes.msg || '扣积分失败');
if (res.code !== 0) {
this.$message.error(res.msg || '审查发起失败');
this.reviewLoading = false;
clearInterval(interval);
clearInterval(progressTimer);
return;
}
this.pointsBalance = deductRes.data.balance;
// 2. 调用合同审查(异步,立即返回)
const res = await vk.callFunction({
url: 'client/ai/pub/review',
data: { file_id: this.reviewFileId, file_name: this.reviewFile.name }
});
if (res.code !== 0) {
this.$message.error(res.msg || '审查发起失败');
this.reviewLoading = false;
clearInterval(interval);
return;
const recordId = res.data.record_id;
// 2. 轮询结果(每 5 秒查一次,最多 10 分钟)
let result = '';
let pointsUsed = 0;
let htmlUrl = '';
let contractInfo = {};
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 10000));
const pollRes = await vk.callFunction({
url: 'client/ai/pub/getReviewResult',
data: { record_id: recordId }
});
if (pollRes.code === 0 && pollRes.data) {
if (pollRes.data.status === 'completed') {
result = pollRes.data.result;
pointsUsed = pollRes.data.points_used || 0;
htmlUrl = pollRes.data.html_url || '';
contractInfo = pollRes.data.contract_info || {};
break;
}
if (pollRes.data.status === 'failed') {
this.$message.error(pollRes.data.result || '审查失败');
this.reviewLoading = false;
clearInterval(progressTimer);
return;
}
}
}
const chatId = res.data.chat_id;
const conversationId = res.data.conversation_id;
// 3. 轮询结果(每 3 秒查一次,最多 5 分钟)
let answer = '';
for (let i = 0; i < 100; i++) {
await new Promise(r => setTimeout(r, 3000));
this.reviewProgress = Math.min(this.reviewProgress + 1, 95);
const pollRes = await vk.callFunction({
url: 'client/ai/pub/getReviewResult',
data: { chat_id: chatId, conversation_id: conversationId }
});
if (pollRes.code === 0 && pollRes.data.status === 'completed') {
answer = pollRes.data.answer;
break;
}
}
clearInterval(interval);
clearInterval(progressTimer);
this.reviewProgress = 100;
if (answer) {
this.reviewResult = answer;
this.getReportList();
this.$message.success('合同审查完成');
} else {
this.$message.error('审查超时,请稍后在「我的报告」中查看');
}
if (result) {
// 解析 JSON 结果
try {
const jsonMatch = result.match(/```json\s*([\s\S]*?)```/);
if (jsonMatch) {
const info = JSON.parse(jsonMatch[1].trim());
this.reviewContractInfo = info;
// 生成摘要文本用于页面显示
let summary = '<strong>合同名称:</strong>' + (info.contract_name || '-') + '<br>';
summary += '<strong>合同类型:</strong>' + (info.contract_type || '-') + '<br>';
summary += '<strong>综合风险:</strong><span style="color:#f56c6c;font-weight:700;">' + (info.risk_level || '-') + '</span><br>';
summary += '<strong>风险统计:</strong>高 ' + (info.risk_count && info.risk_count.high || 0) + ' / 中 ' + (info.risk_count && info.risk_count.medium || 0) + ' / 低 ' + (info.risk_count && info.risk_count.low || 0) + '<br>';
summary += '<strong>总体评估:</strong>' + (info.risk_summary || '-') + '<br>';
// 高风险摘要
const highRisks = (info.risks || []).filter(r => r.level === 'high');
if (highRisks.length > 0) {
summary += '<br><strong style="color:#f56c6c;">⚠️ 高风险摘要:</strong><br>';
highRisks.forEach(r => {
summary += '<div style="padding:8px 12px;margin:6px 0;background:#fff1f0;border-left:3px solid #f56c6c;border-radius:0 4px 4px 0;font-size:13px;">';
summary += '<strong>风险 #' + r.id + '</strong> ' + (r.location || '') + ':' + (r.problem || '');
summary += '</div>';
});
}
this.reviewResult = summary;
} else {
this.reviewResult = result;
}
} catch (e) {
this.reviewResult = result;
}
this.reviewHtmlUrl = htmlUrl;
this.pointsBalance -= pointsUsed;
// 保存报告到本地
const reports = JSON.parse(localStorage.getItem('jjx_review_reports') || '[]');
reports.unshift({
fileName: this.reviewFile.name,
fileSize: this.reviewFile.size,
time: new Date().toLocaleString(),
points: pointsUsed,
result: result,
htmlUrl: htmlUrl
});
localStorage.setItem('jjx_review_reports', JSON.stringify(reports.slice(0, 50)));
this.getReportList();
this.$message.success('合同审查完成,消耗 ' + pointsUsed + ' 积分');
} else {
this.$message.warning('审查时间较长,请稍后在「我的报告」中查看结果');
}
} catch (e) {
clearInterval(interval);
clearInterval(progressTimer);
this.$message.error('审查异常,请稍后重试');
}
@@ -1840,6 +1969,7 @@
resetReview() {
this.reviewFile = null;
this.reviewFileId = null;
this.reviewRecordId = null;
this.reviewUploading = false;
this.reviewResult = null;
this.reviewProgress = 0;
@@ -2739,6 +2869,13 @@
gap: 8px;
}
.disclaimer-notice {
display: flex; align-items: flex-start; gap: 8px;
background: #fff8e1; border: 1px solid #ffe082; border-radius: 8px;
padding: 12px 16px; margin-bottom: 20px; font-size: 12px; color: #e6a23c;
}
.dn-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
.dn-text { line-height: 1.6; }
.points-notice {
display: flex;
align-items: center;