AI审核新增改为独立流式页面:左侧实时输出+右侧文档预览
解决上传后长时间无反馈的问题——改为流式(SSE)边生成边展示。
后端:
- MiMoClient.chatStream:HttpURLConnection 读 SSE,分别回调 reasoning(思考)
与 content(正文) 增量;支持多模态(扫描件PDF)
- IOaAiReviewService.analyzeStream + 实现:同步校验/解析文档后,异步线程调用
流式接口,通过 SseEmitter 推送 {type:reasoning|content|done|error},
结束后落库(含结论解析、摘要、原件OSS留存);createBy 显式回填(异步线程无登录上下文)
· 抽出 prepare()/persist() 复用,analyze() 与 analyzeStream() 共用
- Controller 新增 POST /oa/aiReview/analyzeStream(multipart→text/event-stream)
前端:
- 新增独立二级页面 views/oa/aiReview/add.vue(路由 /hint/aiReview/add):
· 顶部:类型/岗位/选文件/开始审核
· 左侧:用原生 fetch 读流,实时渲染——思考过程(可折叠)+正文 Markdown 实时输出
· 右侧:选中文件即用本地 objectURL 预览(PDF 内嵌 iframe,Word 占位提示)
· 完成后显示匹配度/风险标签 + 查看详情
- 列表页「新增审核」由弹窗改为跳转该页面,移除弹窗相关逻辑
- router 增加 /hint/aiReview/add 隐藏路由
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,12 @@ export const constantRoutes = [
|
||||
name: "editMeetingMinutes",
|
||||
meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" },
|
||||
},
|
||||
{
|
||||
path: "aiReview/add",
|
||||
component: () => import("@/views/oa/aiReview/add"),
|
||||
name: "aiReviewAdd",
|
||||
meta: { title: "新增审核", activeMenu: "/hint/aiReview" },
|
||||
},
|
||||
{
|
||||
path: "aiReview/detail/:id(\\d+)",
|
||||
component: () => import("@/views/oa/aiReview/detail"),
|
||||
|
||||
297
ruoyi-ui/src/views/oa/aiReview/add.vue
Normal file
297
ruoyi-ui/src/views/oa/aiReview/add.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="app-container ai-review-add">
|
||||
<!-- 顶部操作栏 -->
|
||||
<el-card shadow="never" class="topbar">
|
||||
<div class="bar">
|
||||
<div class="left">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
|
||||
<span class="title"><i class="el-icon-magic-stick" /> 新增 AI 审核</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<el-radio-group v-model="reviewType" size="small" :disabled="streaming">
|
||||
<el-radio-button label="contract">合同审核</el-radio-button>
|
||||
<el-radio-button label="resume">简历审核</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-input v-if="reviewType === 'resume'" v-model="position" size="small"
|
||||
placeholder="目标岗位(选填)" clearable :disabled="streaming" style="width: 200px" />
|
||||
<el-upload action="#" :auto-upload="false" :show-file-list="false" :limit="1"
|
||||
:on-change="onFileChange" accept=".pdf,.doc,.docx" :disabled="streaming">
|
||||
<el-button size="small" icon="el-icon-paperclip" :disabled="streaming">
|
||||
{{ fileName || '选择文件' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button size="small" type="primary" icon="el-icon-cpu" :loading="streaming"
|
||||
@click="start">{{ streaming ? '审核中…' : '开始审核' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-hint">
|
||||
{{ reviewType === 'contract'
|
||||
? '从“我方”利益角度审查合同,找出不利条款并给出利好我方的修改/补充建议。'
|
||||
: '评估候选人,分析与目标岗位的匹配度、优势、短板与面试建议。' }}
|
||||
支持 PDF / Word(.doc/.docx),≤ 20MB。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="12" class="body">
|
||||
<!-- 左:流式输出 -->
|
||||
<el-col :span="12" :xs="24">
|
||||
<el-card shadow="never" class="panel out-panel">
|
||||
<div slot="header" class="hd">
|
||||
<span><i class="el-icon-document" /> 审核结果</span>
|
||||
<div class="hd-tags">
|
||||
<span v-if="streaming" class="streaming-dot">● 实时生成中</span>
|
||||
<el-tag v-if="done && reviewType === 'resume' && matchScore != null" size="mini"
|
||||
type="success" effect="dark">匹配度 {{ matchScore }}</el-tag>
|
||||
<el-tag v-if="done && reviewType === 'contract' && riskLevel" size="mini"
|
||||
:type="riskTagType(riskLevel)" effect="dark">{{ riskLevel }}风险</el-tag>
|
||||
<el-button v-if="done && savedId" type="text" size="mini" icon="el-icon-view"
|
||||
@click="goDetail">查看详情</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="outBody" class="out-body">
|
||||
<div v-if="!streaming && !content && !reasoning" class="placeholder">
|
||||
<i class="el-icon-cpu" />
|
||||
<div>选择文件后点击“开始审核”,结果将实时流式输出</div>
|
||||
</div>
|
||||
|
||||
<!-- 思考过程 -->
|
||||
<div v-if="reasoning" class="reasoning">
|
||||
<div class="reasoning-hd" @click="showReasoning = !showReasoning">
|
||||
<i :class="showReasoning ? 'el-icon-arrow-down' : 'el-icon-arrow-right'" />
|
||||
<i class="el-icon-loading" v-if="streaming && !content" />
|
||||
思考过程
|
||||
</div>
|
||||
<pre v-show="showReasoning" class="reasoning-body">{{ reasoning }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 正文(markdown 实时渲染) -->
|
||||
<div v-if="content" class="md-body" v-html="renderedMd" />
|
||||
<div v-else-if="streaming && reasoning" class="thinking-tip">AI 正在分析文档,马上输出结论…</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右:文档预览 -->
|
||||
<el-col :span="12" :xs="24">
|
||||
<el-card shadow="never" class="panel preview-panel">
|
||||
<div slot="header" class="hd"><span><i class="el-icon-view" /> 文档预览</span></div>
|
||||
<div class="preview-body">
|
||||
<iframe v-if="previewUrl && isPdf" :src="previewUrl" class="pdf-frame" />
|
||||
<div v-else-if="previewUrl && !isPdf" class="preview-placeholder">
|
||||
<i class="el-icon-document" />
|
||||
<div>{{ fileName }}</div>
|
||||
<div class="sub">Word 文档暂不支持在线预览,审核结果见左侧</div>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">
|
||||
<i class="el-icon-picture-outline" />
|
||||
<div>选择 PDF 文件可在此预览</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getToken } from '@/utils/auth'
|
||||
const marked = require('marked')
|
||||
|
||||
export default {
|
||||
name: 'OaAiReviewAdd',
|
||||
data () {
|
||||
return {
|
||||
reviewType: 'contract',
|
||||
position: '',
|
||||
rawFile: null,
|
||||
fileName: '',
|
||||
|
||||
streaming: false,
|
||||
done: false,
|
||||
reasoning: '',
|
||||
content: '',
|
||||
showReasoning: true,
|
||||
savedId: null,
|
||||
matchScore: null,
|
||||
riskLevel: null,
|
||||
|
||||
previewUrl: '',
|
||||
isPdf: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderedMd () {
|
||||
try { return marked(this.content) } catch (e) { return this.content }
|
||||
}
|
||||
},
|
||||
created () {
|
||||
marked.setOptions({ breaks: true })
|
||||
},
|
||||
beforeDestroy () {
|
||||
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
|
||||
},
|
||||
methods: {
|
||||
goBack () { this.$router.push('/hint/aiReview') },
|
||||
goDetail () { if (this.savedId) this.$router.push('/hint/aiReview/detail/' + this.savedId) },
|
||||
riskTagType (r) { return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success') },
|
||||
|
||||
onFileChange (file) {
|
||||
if (!/\.(pdf|doc|docx)$/i.test(file.name)) {
|
||||
return this.$modal.msgError('仅支持 PDF / Word(.doc/.docx)')
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
return this.$modal.msgError('文件不能超过 20MB')
|
||||
}
|
||||
this.rawFile = file.raw
|
||||
this.fileName = file.name
|
||||
// 立即生成本地预览
|
||||
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
|
||||
this.previewUrl = URL.createObjectURL(file.raw)
|
||||
this.isPdf = /\.pdf$/i.test(file.name)
|
||||
},
|
||||
|
||||
async start () {
|
||||
if (!this.rawFile) return this.$modal.msgError('请先选择文件')
|
||||
this.reasoning = ''
|
||||
this.content = ''
|
||||
this.done = false
|
||||
this.savedId = null
|
||||
this.matchScore = null
|
||||
this.riskLevel = null
|
||||
this.showReasoning = true
|
||||
this.streaming = true
|
||||
|
||||
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)
|
||||
|
||||
try {
|
||||
const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + getToken() },
|
||||
body: fd
|
||||
})
|
||||
if (!resp.ok || !resp.body) {
|
||||
let msg = '审核失败(' + resp.status + ')'
|
||||
try { const j = await resp.json(); if (j && j.msg) msg = j.msg } catch (e) {}
|
||||
this.$modal.msgError(msg)
|
||||
this.streaming = false
|
||||
return
|
||||
}
|
||||
const reader = resp.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buf = ''
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let idx
|
||||
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
||||
const frame = buf.slice(0, idx)
|
||||
buf = buf.slice(idx + 2)
|
||||
this.handleFrame(frame)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.$modal.msgError('连接中断:' + (e.message || e))
|
||||
} finally {
|
||||
this.streaming = false
|
||||
}
|
||||
},
|
||||
|
||||
handleFrame (frame) {
|
||||
let data = ''
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('data:')) data += line.slice(5).trim()
|
||||
}
|
||||
if (!data) return
|
||||
let ev
|
||||
try { ev = JSON.parse(data) } catch (e) { return }
|
||||
if (ev.type === 'reasoning') {
|
||||
this.reasoning += ev.c
|
||||
this.scrollBottom()
|
||||
} else if (ev.type === 'content') {
|
||||
if (this.content === '') this.showReasoning = false // 正文开始时折叠思考过程
|
||||
this.content += ev.c
|
||||
this.scrollBottom()
|
||||
} else if (ev.type === 'done') {
|
||||
this.done = true
|
||||
this.savedId = ev.id
|
||||
this.matchScore = ev.matchScore
|
||||
this.riskLevel = ev.riskLevel
|
||||
this.$modal.msgSuccess('审核完成')
|
||||
} else if (ev.type === 'error') {
|
||||
this.$modal.msgError(ev.msg || '审核失败')
|
||||
}
|
||||
},
|
||||
|
||||
scrollBottom () {
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.outBody
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-review-add { padding: 10px; }
|
||||
.topbar { margin-bottom: 8px; ::v-deep .el-card__body { padding: 10px 14px; } }
|
||||
.bar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
.left { display: flex; align-items: center; gap: 12px;
|
||||
.title { font-weight: 600; font-size: 15px; color: #303133; i { color: #409eff; margin-right: 4px; } }
|
||||
}
|
||||
.right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
}
|
||||
.bar-hint { font-size: 12px; color: #909399; margin-top: 8px; }
|
||||
|
||||
.body { margin-top: 0; }
|
||||
.panel { ::v-deep .el-card__header { padding: 9px 14px; } }
|
||||
.hd { display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; font-weight: 600; color: #303133; i { color: #409eff; margin-right: 4px; }
|
||||
.hd-tags { display: flex; align-items: center; gap: 8px; }
|
||||
.streaming-dot { color: #67c23a; font-size: 12px; animation: blink 1s infinite; }
|
||||
}
|
||||
|
||||
.out-panel, .preview-panel { height: calc(100vh - 180px); ::v-deep .el-card__body { height: calc(100% - 40px); padding: 0; } }
|
||||
.out-body { height: 100%; overflow-y: auto; padding: 12px 16px; }
|
||||
.placeholder, .preview-placeholder {
|
||||
text-align: center; color: #c0c4cc; padding: 90px 20px;
|
||||
i { font-size: 52px; opacity: .4; display: block; margin-bottom: 14px; }
|
||||
.sub { font-size: 12px; margin-top: 6px; }
|
||||
}
|
||||
.thinking-tip { color: #909399; font-size: 13px; padding: 8px 0; }
|
||||
|
||||
.reasoning { margin-bottom: 12px; border: 1px dashed #e0e3e9; border-radius: 6px; background: #fafbfc; }
|
||||
.reasoning-hd { cursor: pointer; user-select: none; padding: 6px 10px; font-size: 12px; color: #909399;
|
||||
i { margin-right: 4px; } }
|
||||
.reasoning-body { margin: 0; padding: 0 12px 10px; font-size: 12px; color: #b0b3b8; line-height: 1.6;
|
||||
white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; }
|
||||
|
||||
.preview-body { height: 100%; }
|
||||
.pdf-frame { width: 100%; height: 100%; border: none; }
|
||||
|
||||
.md-body {
|
||||
font-size: 14px; color: #2c3e50; line-height: 1.8;
|
||||
::v-deep {
|
||||
h1, h2, h3 { color: #303133; margin: 16px 0 9px; 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
|
||||
</style>
|
||||
@@ -68,47 +68,11 @@
|
||||
|
||||
<pagination v-show="total > 0" :total="total"
|
||||
:page.sync="query.pageNum" :limit.sync="query.pageSize" @pagination="getList" />
|
||||
|
||||
<!-- 新增审核弹窗 -->
|
||||
<el-dialog title="新增 AI 审核" :visible.sync="uploadVisible" width="520px" :close-on-click-modal="false"
|
||||
@closed="resetUpload">
|
||||
<el-form label-width="80px" size="small">
|
||||
<el-form-item label="审核类型">
|
||||
<el-radio-group v-model="reviewType">
|
||||
<el-radio-button label="contract">合同审核</el-radio-button>
|
||||
<el-radio-button label="resume">简历审核</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="dialog-hint">
|
||||
{{ reviewType === 'contract'
|
||||
? '从“我方”利益角度审查合同,找出不利条款并给出利好我方的修改建议。'
|
||||
: '评估候选人,分析与目标岗位的匹配度、优势短板与面试建议。' }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="reviewType === 'resume'" label="目标岗位">
|
||||
<el-input v-model="position" placeholder="选填,如:机械设计工程师" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="上传文件">
|
||||
<el-upload ref="uploader" drag action="#" :auto-upload="false" :limit="1"
|
||||
: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-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button size="small" @click="uploadVisible = false">取 消</el-button>
|
||||
<el-button size="small" type="primary" :loading="analyzing" @click="doAnalyze">
|
||||
{{ analyzing ? 'AI 审核中…' : '开始审核' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { analyzeAiReview, listAiReview, delAiReview } from '@/api/oa/aiReview'
|
||||
import { listAiReview, delAiReview } from '@/api/oa/aiReview'
|
||||
|
||||
export default {
|
||||
name: 'OaAiReview',
|
||||
@@ -118,14 +82,7 @@ export default {
|
||||
showSearch: true,
|
||||
total: 0,
|
||||
list: [],
|
||||
query: { pageNum: 1, pageSize: 10, keyword: '', reviewType: '' },
|
||||
|
||||
uploadVisible: false,
|
||||
reviewType: 'contract',
|
||||
position: '',
|
||||
fileList: [],
|
||||
rawFile: null,
|
||||
analyzing: false
|
||||
query: { pageNum: 1, pageSize: 10, keyword: '', reviewType: '' }
|
||||
}
|
||||
},
|
||||
created () {
|
||||
@@ -163,53 +120,8 @@ export default {
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
// ===== 新增审核 =====
|
||||
openUpload () {
|
||||
this.uploadVisible = true
|
||||
},
|
||||
resetUpload () {
|
||||
this.reviewType = 'contract'
|
||||
this.position = ''
|
||||
this.fileList = []
|
||||
this.rawFile = null
|
||||
this.analyzing = false
|
||||
if (this.$refs.uploader) this.$refs.uploader.clearFiles()
|
||||
},
|
||||
onFileChange (file, fileList) {
|
||||
if (fileList.length > 1) fileList.splice(0, fileList.length - 1)
|
||||
if (!/\.(pdf|doc|docx)$/i.test(file.name)) {
|
||||
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
|
||||
try {
|
||||
const res = await analyzeAiReview(fd)
|
||||
this.$modal.msgSuccess('审核完成')
|
||||
this.uploadVisible = false
|
||||
this.getList()
|
||||
if (res.data && res.data.id) this.goDetail(res.data)
|
||||
} catch (e) {
|
||||
// request.js 已弹错误提示
|
||||
} finally {
|
||||
this.analyzing = false
|
||||
}
|
||||
this.$router.push('/hint/aiReview/add')
|
||||
},
|
||||
|
||||
riskTagType (r) {
|
||||
@@ -221,10 +133,4 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.summary-cell { color: #606266; font-size: 12px; }
|
||||
.dialog-hint { font-size: 12px; color: #909399; line-height: 1.6; margin-top: 4px; }
|
||||
.uploader {
|
||||
::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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user