feat: 新增合同审查功能 —— AI 条款完整性检查 + 风险评分 + 修改建议

- 后端:contract_review.go —— 检查条款完整性、付款/违约/争议条款、风险评分 0-100、AI 深度审查
- 前端:ContractReviewPage.vue —— 合同输入、风险评分展示、风险详情列表、修改建议
- 路由:POST /api/contract/review + 前端 /tools/contract-review 路由
- 模板:StudioPage 新增合同审查模板入口

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-13 23:27:08 +08:00
co-authored by Claude Code
parent 3c58b376b3
commit 752c7837ef
5 changed files with 670 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
import http from './http'
export function reviewContract(data) {
return http.post('/contract/review', data)
}
+1
View File
@@ -23,6 +23,7 @@ const routes = [
{ path: 'apps/knowledge-operations', name: 'KnowledgeOperationsApp', meta: { workspaceKey: 'knowledge-operations' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'apps/process-coordination', name: 'ProcessCoordinationApp', meta: { workspaceKey: 'process-coordination' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'apps/report-generation', name: 'ReportGenerationApp', meta: { workspaceKey: 'report-generation' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
{ path: 'tools/contract-review', name: 'ContractReview', component: () => import('@/views/tools/ContractReviewPage.vue') },
{ path: 'report-gen', name: 'ReportGeneration', meta: { requiresAdmin: true }, component: () => import('@/views/report/ReportGenerationPage.vue') },
{ path: 'tools/document-translate', name: 'DocumentTranslate', component: () => import('@/views/tools/DocumentTranslatePage.vue) },
{ path: 'tools/copy-proofreading', name: 'CopyProofreading', component: () => import('@/views/tools/CopyProofreadingPage.vue) },
@@ -0,0 +1,198 @@
<template>
<div class="contract-review-page">
<header class="page-header">
<div class="header-left">
<h1>合同审查</h1>
<p>AI 智能审查合同条款完整性、付款/违约/争议条款质量,提供风险评分和修改建议</p>
</div>
</header>
<div class="main-layout">
<section class="input-panel">
<div class="card">
<div class="card-title">合同内容</div>
<el-input v-model="form.content" type="textarea" :rows="18" placeholder="粘贴合同正文,AI 将自动解析章节并审查条款" />
<div class="actions">
<el-button type="primary" :loading="reviewing" @click="review" style="width:200px">
<el-icon v-if="!reviewing"><Search /></el-icon>
{{ reviewing ? '审查中...' : '开始审查' }}
</el-button>
<el-select v-model="form.risk_standard" style="width:150px" placeholder="审查标准">
<el-option label="标准" value="standard" />
<el-option label="严格" value="strict" />
</el-select>
</div>
</div>
</section>
<section class="preview-panel">
<div class="card">
<div class="card-header">
<div class="card-title">审查结果</div>
<div class="card-actions" v-if="result">
<el-button size="small" @click="copyResult"><el-icon><DocumentCopy /></el-icon> 复制</el-button>
</div>
</div>
<div v-if="!result" class="empty-preview">
<span class="icon-item">📋</span>
<h3>审查结果预览区</h3>
<p>在左侧粘贴合同内容后点击「开始审查」</p>
</div>
<div v-else class="result-area">
<!-- 评分 -->
<div class="score-section">
<div class="score-ring" :class="scoreClass">
<span class="score-value">{{ result.score }}</span>
<span class="score-label">安全评分</span>
</div>
<div class="score-stats">
<div class="stat-item"><span class="stat-num">{{ result.critical_risks }}</span><span>严重</span></div>
<div class="stat-item"><span class="stat-num">{{ result.high_risks }}</span><span>高</span></div>
<div class="stat-item"><span class="stat-num">{{ result.medium_risks }}</span><span>中</span></div>
<div class="stat-item"><span class="stat-num">{{ result.low_risks }}</span><span>低</span></div>
<div class="stat-item total"><span class="stat-num">{{ result.total_risks }}</span><span>总计</span></div>
</div>
</div>
<!-- 风险总结 -->
<div class="risk-summary" :class="scoreClass">
<strong>风险总结:</strong>{{ result.risk_summary || '合同整体质量良好' }}
</div>
<!-- 建议 -->
<div class="suggestions" v-if="result.suggestions">
<strong>修改建议:</strong>
<p>{{ result.suggestions }}</p>
</div>
<!-- 风险列表 -->
<div class="risks-list">
<h4>风险详情 ({{ result.total_risks }})</h4>
<div v-for="(risk, idx) in result.risks" :key="idx" class="risk-card" :class="risk.level">
<div class="risk-header">
<el-tag size="small" :type="levelTag(risk.level)">{{ levelLabel(risk.level) }}</el-tag>
<span class="risk-section">{{ risk.section }}</span>
<span class="risk-type">{{ risk.type }}</span>
</div>
<div class="risk-body">
<div class="problem"><strong>问题:</strong>{{ risk.problem }}</div>
<div class="suggestion" v-if="risk.suggestion"><strong>建议:</strong>{{ risk.suggestion }}</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, DocumentCopy } from '@element-plus/icons-vue'
import { reviewContract } from '@/api/contract'
const reviewing = ref(false)
const result = ref(null)
const form = ref({
content: '',
risk_standard: 'standard',
})
const scoreClass = computed(() => {
if (!result.value) return ''
if (result.value.score >= 80) return 'score-good'
if (result.value.score >= 60) return 'score-warn'
return 'score-bad'
})
const levelLabel = (l) => {
const map = { critical: '严重', high: '高', medium: '中', low: '低' }
return map[l] || l
}
const levelTag = (l) => {
const map = { critical: 'danger', high: 'warning', medium: 'info', low: 'success' }
return map[l] || 'info'
}
async function review() {
if (!form.value.content.trim()) {
ElMessage.warning('请粘贴合同内容')
return
}
reviewing.value = true
try {
const res = await reviewContract(form.value)
result.value = res.data
ElMessage.success('审查完成')
} catch (error) {
ElMessage.error(error.message || '审查失败')
} finally {
reviewing.value = false
}
}
async function copyResult() {
if (!result.value) return
try {
const text = result.value.risks.map(r => `【${levelLabel(r.level)}】${r.section} - ${r.problem} → ${r.suggestion}`).join('\n')
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败')
}
}
</script>
<style scoped>
.contract-review-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
.page-header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
.header-left h1 { margin: 0 0 6px; font-size: 24px; color: #1f2d3d; font-weight: 700; }
.header-left p { margin: 0; font-size: 14px; color: #6b7785; }
.main-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 24px; }
.card { border-radius: 16px; background: #fff; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); padding: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; margin-bottom: 12px; }
.card-actions { display: flex; gap: 8px; }
.actions { display: flex; gap: 8px; margin-top: 12px; }
.input-panel .el-input__inner, .input-panel .el-textarea__inner { font-size: 13px; }
.empty-preview { text-align: center; padding: 60px 20px; }
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
.empty-preview p { font-size: 13px; color: #6b7785; }
.icon-item { font-size: 48px; }
.result-area { max-height: 650px; overflow-y: auto; }
.score-section { display: flex; align-items: center; gap: 24px; margin-bottom: 16px; padding: 16px; background: #f8f9fb; border-radius: 12px; }
.score-ring { text-align: center; }
.score-ring.score-good .score-value { color: #67c23a; }
.score-ring.score-warn .score-value { color: #e6a23c; }
.score-ring.score-bad .score-value { color: #f56c6c; }
.score-value { font-size: 42px; font-weight: 700; display: block; line-height: 1; }
.score-label { font-size: 12px; color: #909399; }
.score-stats { display: flex; gap: 16px; }
.stat-item { text-align: center; }
.stat-num { font-size: 24px; font-weight: 600; color: #3d4854; display: block; }
.stat-item.total .stat-num { color: #2b63d9; font-size: 28px; }
.stat-item span:last-child { font-size: 12px; color: #909399; }
.risk-summary { margin-bottom: 12px; padding: 12px; border-radius: 8px; border-left: 3px solid; }
.risk-summary.score-good { background: #f0f9ff; border-color: #67c23a; }
.risk-summary.score-warn { background: #fdf6ec; border-color: #e6a23c; }
.risk-summary.score-bad { background: #fef0f0; border-color: #f56c6c; }
.suggestions { margin-bottom: 12px; padding: 12px; background: #f0f7ff; border-radius: 8px; }
.risks-list h4 { margin: 0 0 8px; font-size: 14px; color: #1f2d3d; }
.risk-card { padding: 12px; margin-bottom: 8px; border-radius: 8px; border: 1px solid #eef1f6; }
.risk-card.critical { border-left: 3px solid #f56c6c; background: #fef0f0; }
.risk-card.high { border-left: 3px solid #e6a23c; background: #fdf6ec; }
.risk-card.medium { border-left: 3px solid #909399; background: #f4f4f5; }
.risk-card.low { border-left: 3px solid #67c23a; background: #f0f9ff; }
.risk-header { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.risk-section { font-weight: 600; font-size: 13px; color: #1f2d3d; }
.risk-type { font-size: 12px; color: #909399; }
.problem { font-size: 13px; color: #3d4854; margin-bottom: 4px; }
.suggestion { font-size: 13px; color: #67c23a; }
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
</style>