新增 AI 合同/简历审核功能(小米 MiMo 多模态大模型)
核心诉求:合同审核站在我方(德睿福)立场,找出不利条款并给出利好我方的
修改/补充建议;简历审核评估候选人与目标岗位的匹配度。
后端(ruoyi-oa):
- 接入小米 MiMo(OpenAI 兼容 /chat/completions),mimo-v2.5 多模态模型
· MiMoProperties 绑定 application.yml mimo: 配置(base-url/api-key/model/...)
· MiMoClient:text + multimodal(image_url base64) 两种调用,独立长超时
RestTemplate;mimo-v2.5 是推理模型,max-tokens 配 8192 留足思考额度
- DocumentParseUtil:PDF 文字(PDFBox)、Word(POI: docx XWPF / doc HWPF),
扫描版 PDF(提取文字过短)用 PDFRenderer 转 PNG 走多模态
- OaAiReview 实体 + BO/VO/Mapper/Service/Controller(/oa/aiReview)
· analyze 上传解析→构建提示词→调用大模型→留存原件(OSS)→落库
· 合同/简历两套提示词;正则解析风险评级:高/中/低与匹配度评分:NN入库
· 提供 list/detail/delete
- ruoyi-oa/pom.xml 增加 poi-ooxml、poi-scratchpad(Word 解析)
- application.yml 增加 mimo: 配置块
前端(ruoyi-ui):
- views/oa/aiReview/index.vue:类型切换(合同/简历)、拖拽上传(pdf/word)、
简历目标岗位输入、审核(loading)、Markdown 结果渲染、历史记录列表
- api/oa/aiReview.js:analyze 用 FormData,超时放宽到 5 分钟
SQL(已应用到生产库):
- oa_ai_review 表;菜单挂信息下(menu_id 2063910000000000001),授权10个角色
已用真实接口端到端验证:合同审核输出利好我方意见、风险评级可正确解析。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
28
ruoyi-ui/src/api/oa/aiReview.js
Normal file
28
ruoyi-ui/src/api/oa/aiReview.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 上传合同/简历进行 AI 审核
|
||||
* @param {FormData} data 包含 file, reviewType(contract|resume), position(可选)
|
||||
*/
|
||||
export function analyzeAiReview (data) {
|
||||
return request({
|
||||
url: '/oa/aiReview/analyze',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
// 推理模型 + 长文档,单次可能较慢,放宽到 5 分钟
|
||||
timeout: 300000
|
||||
})
|
||||
}
|
||||
|
||||
export function listAiReview (query) {
|
||||
return request({ url: '/oa/aiReview/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getAiReview (id) {
|
||||
return request({ url: '/oa/aiReview/' + id, method: 'get' })
|
||||
}
|
||||
|
||||
export function delAiReview (ids) {
|
||||
return request({ url: '/oa/aiReview/' + ids, method: 'delete' })
|
||||
}
|
||||
284
ruoyi-ui/src/views/oa/aiReview/index.vue
Normal file
284
ruoyi-ui/src/views/oa/aiReview/index.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="app-container ai-review">
|
||||
<el-row :gutter="12">
|
||||
<!-- 左:上传 + 历史 -->
|
||||
<el-col :span="8" :xs="24">
|
||||
<el-card shadow="never" class="panel">
|
||||
<div slot="header" class="hd">
|
||||
<i class="el-icon-cpu" /> AI 智能审核
|
||||
</div>
|
||||
|
||||
<el-radio-group v-model="reviewType" size="small" class="type-switch" @change="onTypeChange">
|
||||
<el-radio-button label="contract">合同审核</el-radio-button>
|
||||
<el-radio-button label="resume">简历审核</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="hint">
|
||||
{{ reviewType === 'contract'
|
||||
? '从“我方”利益角度审查合同,找出不利条款并给出利好我方的修改/补充建议。'
|
||||
: '评估候选人,分析与目标岗位的匹配度、优势短板与面试建议。' }}
|
||||
</div>
|
||||
|
||||
<el-input v-if="reviewType === 'resume'" v-model="position" size="small" class="pos-input"
|
||||
placeholder="目标岗位(选填,如:机械设计工程师)" clearable />
|
||||
|
||||
<el-upload ref="uploader" drag action="#" :auto-upload="false" :limit="1"
|
||||
:show-file-list="true" :on-change="onFileChange" :on-remove="onFileRemove"
|
||||
:file-list="fileList" accept=".pdf,.doc,.docx" class="uploader">
|
||||
<i class="el-icon-upload" />
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
<div slot="tip" class="el-upload__tip">支持 PDF / Word(.doc/.docx),单个文件 ≤ 20MB</div>
|
||||
</el-upload>
|
||||
|
||||
<el-button type="primary" icon="el-icon-magic-stick" :loading="analyzing"
|
||||
style="width:100%" @click="doAnalyze">
|
||||
{{ analyzing ? 'AI 审核中…' : '开始审核' }}
|
||||
</el-button>
|
||||
|
||||
<div class="hist-hd">
|
||||
<span><i class="el-icon-time" /> 历史记录</span>
|
||||
<el-input v-model="query.keyword" size="mini" placeholder="搜索文件名/岗位" clearable
|
||||
prefix-icon="el-icon-search" style="width:150px"
|
||||
@keyup.enter.native="searchHist" @clear="searchHist" />
|
||||
</div>
|
||||
<div v-loading="histLoading" class="hist-list">
|
||||
<div v-if="histList.length === 0 && !histLoading" class="hist-empty">暂无记录</div>
|
||||
<div v-for="r in histList" :key="r.id" class="hist-item"
|
||||
:class="{ sel: current && current.id === r.id }" @click="viewHistory(r.id)">
|
||||
<div class="hi-top">
|
||||
<el-tag size="mini" :type="r.reviewType === 'contract' ? 'warning' : 'success'" effect="plain">
|
||||
{{ r.reviewType === 'contract' ? '合同' : '简历' }}
|
||||
</el-tag>
|
||||
<span v-if="r.reviewType === 'resume' && r.matchScore != null" class="hi-badge score">
|
||||
{{ r.matchScore }}分
|
||||
</span>
|
||||
<span v-if="r.reviewType === 'contract' && r.riskLevel" class="hi-badge"
|
||||
:class="riskClass(r.riskLevel)">{{ r.riskLevel }}风险</span>
|
||||
<el-button type="text" icon="el-icon-delete" class="hi-del" @click.stop="delHistory(r)" />
|
||||
</div>
|
||||
<div class="hi-name">{{ r.fileName }}</div>
|
||||
<div class="hi-meta">{{ r.position || '—' }} · {{ r.createTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="query.pageNum"
|
||||
:limit.sync="query.pageSize" :page-sizes="[10,20,50]" small @pagination="loadHist" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右:结果 -->
|
||||
<el-col :span="16" :xs="24">
|
||||
<el-card shadow="never" class="panel result-panel">
|
||||
<div slot="header" class="hd result-hd">
|
||||
<span><i class="el-icon-document-checked" /> 审核结果</span>
|
||||
<div v-if="current" class="result-tags">
|
||||
<el-tag size="mini" :type="current.reviewType === 'contract' ? 'warning' : 'success'">
|
||||
{{ current.reviewType === 'contract' ? '合同' : '简历' }}
|
||||
</el-tag>
|
||||
<span class="r-file">{{ current.fileName }}</span>
|
||||
<el-tag v-if="current.reviewType === 'resume' && current.matchScore != null"
|
||||
size="mini" type="success" effect="dark">匹配度 {{ current.matchScore }}</el-tag>
|
||||
<el-tag v-if="current.reviewType === 'contract' && current.riskLevel"
|
||||
size="mini" :type="riskTagType(current.riskLevel)" effect="dark">
|
||||
{{ current.riskLevel }}风险</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="analyzing" :element-loading-text="loadingText" class="result-body">
|
||||
<div v-if="!current && !analyzing" class="result-empty">
|
||||
<i class="el-icon-cpu" />
|
||||
<div>上传合同或简历,点击“开始审核”获取 AI 分析结果</div>
|
||||
</div>
|
||||
<div v-else-if="current" class="md-body" v-html="renderedMd" />
|
||||
<div v-else class="result-empty"><div>{{ loadingText }}</div></div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { analyzeAiReview, listAiReview, getAiReview, delAiReview } from '@/api/oa/aiReview'
|
||||
const marked = require('marked')
|
||||
|
||||
export default {
|
||||
name: 'OaAiReview',
|
||||
data () {
|
||||
return {
|
||||
reviewType: 'contract',
|
||||
position: '',
|
||||
fileList: [],
|
||||
rawFile: null,
|
||||
|
||||
analyzing: false,
|
||||
loadingText: 'AI 审核中,长文档可能需要 30~90 秒,请稍候…',
|
||||
current: null,
|
||||
|
||||
histList: [],
|
||||
total: 0,
|
||||
histLoading: false,
|
||||
query: { pageNum: 1, pageSize: 10, keyword: '', reviewType: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderedMd () {
|
||||
if (!this.current || !this.current.resultMd) return ''
|
||||
try { return marked(this.current.resultMd) } catch (e) { return this.current.resultMd }
|
||||
}
|
||||
},
|
||||
created () {
|
||||
marked.setOptions({ breaks: true })
|
||||
this.loadHist()
|
||||
},
|
||||
methods: {
|
||||
onTypeChange () {
|
||||
this.query.reviewType = ''
|
||||
this.loadHist()
|
||||
},
|
||||
onFileChange (file, fileList) {
|
||||
// 仅保留最后一个文件
|
||||
if (fileList.length > 1) fileList.splice(0, fileList.length - 1)
|
||||
const isOk = /\.(pdf|doc|docx)$/i.test(file.name)
|
||||
if (!isOk) {
|
||||
this.$modal.msgError('仅支持 PDF / Word(.doc/.docx)')
|
||||
this.fileList = []
|
||||
this.rawFile = null
|
||||
return
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
this.$modal.msgError('文件不能超过 20MB')
|
||||
this.fileList = []
|
||||
this.rawFile = null
|
||||
return
|
||||
}
|
||||
this.rawFile = file.raw
|
||||
this.fileList = [file]
|
||||
},
|
||||
onFileRemove () {
|
||||
this.rawFile = null
|
||||
this.fileList = []
|
||||
},
|
||||
async doAnalyze () {
|
||||
if (!this.rawFile) return this.$modal.msgError('请先上传文件')
|
||||
const fd = new FormData()
|
||||
fd.append('file', this.rawFile)
|
||||
fd.append('reviewType', this.reviewType)
|
||||
if (this.reviewType === 'resume' && this.position) fd.append('position', this.position)
|
||||
this.analyzing = true
|
||||
this.current = null
|
||||
try {
|
||||
const res = await analyzeAiReview(fd)
|
||||
this.current = res.data
|
||||
this.$modal.msgSuccess('审核完成')
|
||||
this.loadHist()
|
||||
} catch (e) {
|
||||
// request.js 已弹错误提示
|
||||
} finally {
|
||||
this.analyzing = false
|
||||
}
|
||||
},
|
||||
|
||||
searchHist () {
|
||||
this.query.pageNum = 1
|
||||
this.loadHist()
|
||||
},
|
||||
loadHist () {
|
||||
this.histLoading = true
|
||||
this.query.reviewType = this.reviewType
|
||||
listAiReview(this.query).then(res => {
|
||||
this.histList = res.rows || []
|
||||
this.total = res.total || 0
|
||||
}).finally(() => { this.histLoading = false })
|
||||
},
|
||||
viewHistory (id) {
|
||||
getAiReview(id).then(res => { this.current = res.data })
|
||||
},
|
||||
delHistory (r) {
|
||||
this.$modal.confirm(`确认删除「${r.fileName}」的审核记录?`).then(() => {
|
||||
return delAiReview(r.id)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('已删除')
|
||||
if (this.current && this.current.id === r.id) this.current = null
|
||||
this.loadHist()
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
riskClass (r) {
|
||||
return r === '高' ? 'risk-high' : (r === '中' ? 'risk-mid' : 'risk-low')
|
||||
},
|
||||
riskTagType (r) {
|
||||
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-review { padding: 10px; }
|
||||
.panel { ::v-deep .el-card__header { padding: 10px 14px; } }
|
||||
.hd { font-weight: 600; font-size: 14px; color: #303133; i { color: #409eff; margin-right: 4px; } }
|
||||
|
||||
.type-switch { margin-bottom: 10px; width: 100%;
|
||||
::v-deep .el-radio-button { width: 50%; }
|
||||
::v-deep .el-radio-button__inner { width: 100%; }
|
||||
}
|
||||
.hint { font-size: 12px; color: #909399; line-height: 1.6; margin-bottom: 10px; }
|
||||
.pos-input { margin-bottom: 10px; }
|
||||
.uploader { margin-bottom: 10px;
|
||||
::v-deep .el-upload, ::v-deep .el-upload-dragger { width: 100%; }
|
||||
::v-deep .el-upload-dragger { height: 130px; }
|
||||
.el-icon-upload { font-size: 40px; color: #c0c4cc; margin: 18px 0 8px; line-height: 1; }
|
||||
}
|
||||
|
||||
.hist-hd { display: flex; justify-content: space-between; align-items: center;
|
||||
margin: 14px 0 8px; font-size: 13px; font-weight: 600; color: #303133;
|
||||
i { color: #409eff; margin-right: 4px; }
|
||||
}
|
||||
.hist-list { max-height: calc(100vh - 470px); overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.hist-empty { text-align: center; color: #c0c4cc; padding: 20px 0; font-size: 12px; }
|
||||
.hist-item { border: 1px solid #ebeef5; border-radius: 4px; padding: 6px 10px; cursor: pointer; transition: .15s;
|
||||
&:hover { border-color: #409eff; background: #fafdff; }
|
||||
&.sel { border-color: #409eff; background: #ecf5ff; }
|
||||
.hi-top { display: flex; align-items: center; gap: 6px; }
|
||||
.hi-badge { font-size: 11px; padding: 0 5px; border-radius: 8px; line-height: 16px;
|
||||
&.score { background: #f0f9eb; color: #67c23a; }
|
||||
&.risk-high { background: #fef0f0; color: #f56c6c; }
|
||||
&.risk-mid { background: #fdf6ec; color: #e6a23c; }
|
||||
&.risk-low { background: #f0f9eb; color: #67c23a; }
|
||||
}
|
||||
.hi-del { margin-left: auto; padding: 0; color: #c0c4cc; &:hover { color: #f56c6c; } }
|
||||
.hi-name { font-size: 13px; color: #303133; margin: 3px 0 1px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hi-meta { font-size: 11px; color: #909399; }
|
||||
}
|
||||
|
||||
.result-panel { min-height: calc(100vh - 140px); }
|
||||
.result-hd { display: flex; justify-content: space-between; align-items: center;
|
||||
.result-tags { display: flex; align-items: center; gap: 8px;
|
||||
.r-file { font-size: 12px; color: #606266; max-width: 320px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
}
|
||||
.result-body { min-height: 400px; }
|
||||
.result-empty { text-align: center; color: #c0c4cc; padding: 100px 0;
|
||||
i { font-size: 56px; opacity: .4; display: block; margin-bottom: 14px; }
|
||||
}
|
||||
|
||||
.md-body {
|
||||
font-size: 14px; color: #2c3e50; line-height: 1.8; padding: 4px 6px;
|
||||
::v-deep {
|
||||
h1, h2, h3 { color: #303133; margin: 18px 0 10px; font-weight: 600; }
|
||||
h1 { font-size: 19px; } h2 { font-size: 16px; border-left: 3px solid #409eff; padding-left: 8px; }
|
||||
h3 { font-size: 14px; }
|
||||
p { margin: 8px 0; }
|
||||
ul, ol { padding-left: 22px; margin: 8px 0; }
|
||||
li { margin: 4px 0; }
|
||||
strong { color: #c0392b; }
|
||||
code { background: #f4f7fa; padding: 1px 5px; border-radius: 3px; font-size: 13px; color: #e6a23c; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
|
||||
th, td { border: 1px solid #ebeef5; padding: 6px 10px; font-size: 13px; }
|
||||
th { background: #f5f7fa; }
|
||||
blockquote { border-left: 3px solid #dcdfe6; padding-left: 10px; color: #909399; margin: 8px 0; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user