Files
fad_oa/ruoyi-ui/src/views/oa/aiReview/add.vue
wangyu 9e6ae1eca9 AI审核支持微调/自定义审核重点
- 新增审核可选择审核重点(字典驱动,合同/简历各一套)并填写附加要求自由文本,
  两者合并为 requirements 随请求提交,后端追加进系统提示词,让模型按需聚焦
- 审核项存字典 oa_ai_review_item_contract / oa_ai_review_item_resume,
  用户可在系统管理→字典管理自行增删审核项(无需改代码),各预置10项
- oa_ai_review 增加 requirements 列(已应用到生产库),落库留痕;详情页展示
- 前后端贯通:analyze / analyzeStream 均新增 requirements 参数

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:35:27 +08:00

371 lines
15 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>
<!-- 审核重点 / 附加要求 -->
<div class="req-area">
<div class="req-line">
<span class="req-label">审核重点</span>
<el-checkbox-group v-model="checkedItems" size="mini" :disabled="streaming" class="req-items">
<el-checkbox v-for="it in itemOptions" :key="it.value" :label="it.label" border>{{ it.label }}</el-checkbox>
</el-checkbox-group>
<span class="req-tip">可在系统管理字典管理增删审核项</span>
</div>
<div class="req-line">
<span class="req-label">附加要求</span>
<el-input v-model="extraText" type="textarea" :rows="2" :disabled="streaming"
class="req-extra" maxlength="500" show-word-limit
placeholder="可补充本次审核的特殊关注点,例如:重点核查付款比例是否对我方有利、是否有自动续约陷阱…" />
</div>
</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'
import { getDicts } from '@/api/system/dict/data'
const marked = require('marked')
export default {
name: 'OaAiReviewAdd',
data () {
return {
reviewType: 'contract',
position: '',
rawFile: null,
fileName: '',
// 审核重点(字典) + 附加要求(自由文本)
itemDicts: { contract: [], resume: [] },
checkedItems: [],
extraText: '',
streaming: false,
done: false,
reasoning: '',
content: '',
showReasoning: true,
savedId: null,
matchScore: null,
riskLevel: null,
eventCount: 0,
previewUrl: '',
isPdf: false
}
},
computed: {
renderedMd () {
try { return marked(this.content) } catch (e) { return this.content }
},
itemOptions () {
return this.itemDicts[this.reviewType] || []
},
requirements () {
const parts = []
if (this.checkedItems.length) parts.push('重点审核项:' + this.checkedItems.join('、'))
if (this.extraText && this.extraText.trim()) parts.push('其他要求:' + this.extraText.trim())
return parts.join('\n')
}
},
watch: {
// 切换类型时清空已选审核项(两套字典不同)
reviewType () { this.checkedItems = [] }
},
created () {
marked.setOptions({ breaks: true })
this.loadItemDicts()
},
beforeDestroy () {
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
},
methods: {
loadItemDicts () {
getDicts('oa_ai_review_item_contract').then(res => {
this.itemDicts = { ...this.itemDicts, contract: (res.data || []).map(d => ({ label: d.dictLabel, value: d.dictValue })) }
})
getDicts('oa_ai_review_item_resume').then(res => {
this.itemDicts = { ...this.itemDicts, resume: (res.data || []).map(d => ({ label: d.dictLabel, value: d.dictValue })) }
})
},
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)
if (this.requirements) fd.append('requirements', this.requirements)
try {
const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', {
method: 'POST',
headers: { Authorization: 'Bearer ' + getToken(), Accept: 'text/event-stream' },
body: fd
})
// 非流式响应(鉴权失败 / 后端异常被包成 JSON / HTML→ 读出来报错,避免静默
const ct = resp.headers.get('content-type') || ''
if (!resp.ok || !resp.body || ct.indexOf('text/event-stream') === -1) {
let msg = '审核失败HTTP ' + resp.status + ''
try {
const txt = await resp.text()
try { const j = JSON.parse(txt); if (j && (j.msg || j.message)) msg = j.msg || j.message }
catch (e) { if (txt) msg = txt.slice(0, 200) }
} catch (e) {}
this.$modal.msgError(msg)
this.eventCount = 0
this.streaming = false
return
}
const reader = resp.body.getReader()
const decoder = new TextDecoder('utf-8')
let buf = ''
this.eventCount = 0
// 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)
}
}
if (this.eventCount === 0) {
this.$modal.msgError('未收到任何流式数据,请检查后端是否可访问 AI 服务')
}
} 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 }
this.eventCount++
if (ev.type === 'start') {
// 通道已打开,等待解析/生成
} else 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; }
.req-area { margin-top: 10px; border-top: 1px dashed #ebeef5; padding-top: 8px; }
.req-line { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px;
&:last-child { margin-bottom: 0; }
}
.req-label { flex: 0 0 56px; font-size: 12px; color: #606266; padding-top: 5px; font-weight: 600; }
.req-items { flex: 1; display: flex; flex-wrap: wrap; gap: 6px 0;
::v-deep .el-checkbox { margin-right: 8px; margin-left: 0; }
::v-deep .el-checkbox.is-bordered { padding: 4px 10px 4px 8px; height: auto; }
}
.req-tip { flex: 0 0 auto; font-size: 11px; color: #c0c4cc; padding-top: 5px; }
.req-extra { flex: 1; }
.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>