Files
fad_oa/ruoyi-ui/src/views/oa/aiReview/add.vue
wangyu 7a2603e1f9 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>
2026-06-12 10:19:52 +08:00

298 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>