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:
2026-06-12 10:19:52 +08:00
parent d46754ede8
commit 7a2603e1f9
7 changed files with 568 additions and 132 deletions

View File

@@ -14,7 +14,15 @@ import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.annotation.PostConstruct;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.BiConsumer;
/**
* 小米 MiMo 大模型调用客户端OpenAI 兼容 /chat/completions
@@ -80,6 +88,105 @@ public class MiMoClient {
return send(body);
}
/**
* 流式对话SSE。一边接收一边回调返回拼接好的最终答案 content。
*
* @param imageDataUris 多模态图片(可空)
* @param onDelta 回调kind = "reasoning"(思考) / "content"(正文)text = 增量片段
*/
public String chatStream(String systemPrompt, String userText, List<String> imageDataUris,
BiConsumer<String, String> onDelta) {
if (StringUtils.isBlank(props.getApiKey())) {
throw new ServiceException("未配置 MiMo API Keyapplication.yml: mimo.api-key");
}
ObjectNode body = baseBody();
body.put("stream", true);
ArrayNode messages = body.putArray("messages");
if (StringUtils.isNotBlank(systemPrompt)) {
messages.addObject().put("role", "system").put("content", systemPrompt);
}
boolean multimodal = imageDataUris != null && !imageDataUris.isEmpty();
if (multimodal) {
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
ArrayNode content = userMsg.putArray("content");
content.addObject().put("type", "text").put("text", userText);
for (String uri : imageDataUris) {
if (StringUtils.isBlank(uri)) continue;
ObjectNode img = content.addObject();
img.put("type", "image_url");
img.putObject("image_url").put("url", uri);
}
} else {
messages.addObject().put("role", "user").put("content", userText);
}
HttpURLConnection conn = null;
try {
URL url = new URL(props.getBaseUrl() + "/chat/completions");
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setConnectTimeout(10_000);
conn.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("api-key", props.getApiKey());
conn.setRequestProperty("Authorization", "Bearer " + props.getApiKey());
try (OutputStream os = conn.getOutputStream()) {
os.write(json.writeValueAsString(body).getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
if (code >= 400) {
String err = readAll(conn.getErrorStream());
throw new ServiceException("AI 服务返回 " + code + "" + StringUtils.substring(err, 0, 300));
}
StringBuilder full = new StringBuilder();
try (BufferedReader r = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
if (line.isEmpty() || !line.startsWith("data:")) continue;
String payload = line.substring(5).trim();
if ("[DONE]".equals(payload)) break;
JsonNode delta = json.readTree(payload).path("choices").path(0).path("delta");
JsonNode rc = delta.get("reasoning_content");
if (rc != null && !rc.isNull() && !rc.asText().isEmpty()) {
onDelta.accept("reasoning", rc.asText());
}
JsonNode c = delta.get("content");
if (c != null && !c.isNull() && !c.asText().isEmpty()) {
full.append(c.asText());
onDelta.accept("content", c.asText());
}
}
}
if (full.length() == 0) {
throw new ServiceException("AI 未返回有效内容(文档过长或额度不足)");
}
return full.toString();
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("调用 MiMo 流式接口失败", e);
throw new ServiceException("AI 服务调用失败:" + e.getMessage());
} finally {
if (conn != null) conn.disconnect();
}
}
private String readAll(InputStream in) {
if (in == null) return "";
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String l;
while ((l = r.readLine()) != null) sb.append(l);
return sb.toString();
} catch (Exception e) {
return "";
}
}
private ObjectNode baseBody() {
ObjectNode body = json.createObjectNode();
body.put("model", props.getModel());

View File

@@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
@@ -40,6 +41,17 @@ public class OaAiReviewController extends BaseController {
return R.ok("审核完成", service.analyze(file, reviewType, position));
}
/**
* 上传并流式审核SSE边生成边推送结束后落库
*/
@Log(title = "AI审核", businessType = BusinessType.OTHER)
@PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8")
public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file,
@RequestParam("reviewType") String reviewType,
@RequestParam(value = "position", required = false) String position) {
return service.analyzeStream(file, reviewType, position);
}
@GetMapping("/list")
public TableDataInfo<OaAiReviewVo> list(OaAiReviewBo bo, PageQuery pageQuery) {
return service.queryPageList(bo, pageQuery);

View File

@@ -5,6 +5,7 @@ import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Collection;
@@ -19,6 +20,11 @@ public interface IOaAiReviewService {
*/
OaAiReviewVo analyze(MultipartFile file, String reviewType, String position);
/**
* 流式审核边生成边推送SSE结束后落库
*/
SseEmitter analyzeStream(MultipartFile file, String reviewType, String position);
TableDataInfo<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery);
OaAiReviewVo queryById(Long id);

View File

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.oa.aireview.DocumentParseUtil;
import com.ruoyi.oa.aireview.MiMoClient;
@@ -21,9 +22,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.File;
import java.nio.file.Files;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -52,6 +58,66 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
@Override
public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) {
Prepared p = prepare(file, reviewType, position);
String result = p.images != null
? miMoClient.chatMultimodal(p.system, p.userText, p.images)
: miMoClient.chatText(p.system, p.userText);
// 留存原件
SysOssVo oss = uploadQuietly(file);
OaAiReview entity = persist(p, result, oss, currentUsername());
return baseMapper.selectVoById(entity.getId());
}
@Override
public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) {
// 同步校验 + 解析(出错可立即以普通异常返回),耗时的大模型调用放到异步线程
Prepared p = prepare(file, reviewType, position);
String username = currentUsername();
long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L;
SseEmitter emitter = new SseEmitter(timeoutMs);
Thread worker = new Thread(() -> {
try {
String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> {
try {
Map<String, Object> ev = new HashMap<>();
ev.put("type", kind); // reasoning / content
ev.put("c", chunk);
emitter.send(ev);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
SysOssVo oss = uploadQuietly(p.fileName, p.bytes);
OaAiReview entity = persist(p, result, oss, username);
Map<String, Object> done = new HashMap<>();
done.put("type", "done");
done.put("id", entity.getId());
done.put("matchScore", entity.getMatchScore());
done.put("riskLevel", entity.getRiskLevel());
emitter.send(done);
emitter.complete();
} catch (Exception e) {
log.error("流式审核失败", e);
try {
Map<String, Object> err = new HashMap<>();
err.put("type", "error");
Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e;
err.put("msg", cause.getMessage());
emitter.send(err);
} catch (Exception ignored) {}
emitter.complete();
}
});
worker.setDaemon(true);
worker.setName("ai-review-stream");
worker.start();
return emitter;
}
/** 校验 + 读取 + 解析 + 构建提示词 */
private Prepared prepare(MultipartFile file, String reviewType, String position) {
if (file == null || file.isEmpty()) {
throw new ServiceException("请上传文件");
}
@@ -66,7 +132,6 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) {
throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件");
}
byte[] bytes;
try {
bytes = file.getBytes();
@@ -74,63 +139,100 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
throw new ServiceException("读取文件失败");
}
// 1. 提取文字
String text = DocumentParseUtil.extractText(fileName, bytes);
text = truncate(text);
Prepared p = new Prepared();
p.reviewType = reviewType;
p.position = position;
p.fileName = fileName;
p.bytes = bytes;
p.system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position);
// 2. 构建提示词
String system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position);
// 3. 调用大模型(文字优先;扫描版 PDF 走多模态)
String result;
String text = truncate(DocumentParseUtil.extractText(fileName, bytes));
if (StringUtils.length(text) >= MIN_TEXT_LEN) {
String userText = "contract".equals(reviewType)
p.userText = "contract".equals(reviewType)
? "以下是待审核的合同全文:\n\n" + text
: "以下是待评估的简历内容:"
+ (StringUtils.isNotBlank(position) ? "(目标岗位:" + position + "" : "") + "\n\n" + text;
result = miMoClient.chatText(system, userText);
p.images = null;
} else if (DocumentParseUtil.isPdf(fileName)) {
List<String> images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages());
String userText = "contract".equals(reviewType)
p.images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages());
p.userText = "contract".equals(reviewType)
? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。"
: "请评估以下图片中的简历(扫描件)。"
+ (StringUtils.isNotBlank(position) ? "目标岗位:" + position + "" : "");
result = miMoClient.chatMultimodal(system, userText, images);
} else {
throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF");
}
return p;
}
// 4. 留存原件(失败不阻塞主流程)
Long ossId = null;
String fileUrl = null;
try {
SysOssVo oss = ossService.upload(file, 0L);
if (oss != null) {
ossId = oss.getOssId();
fileUrl = oss.getUrl();
}
} catch (Exception e) {
log.warn("AI审核原件留存失败{}", fileName, e);
}
// 5. 落库
/** 落库(结论解析、摘要、创建人) */
private OaAiReview persist(Prepared p, String result, SysOssVo oss, String username) {
OaAiReview entity = new OaAiReview();
entity.setReviewType(reviewType);
entity.setFileName(fileName);
entity.setOssId(ossId);
entity.setFileUrl(fileUrl);
entity.setPosition(position);
entity.setReviewType(p.reviewType);
entity.setFileName(p.fileName);
if (oss != null) {
entity.setOssId(oss.getOssId());
entity.setFileUrl(oss.getUrl());
}
entity.setPosition(p.position);
entity.setResultMd(result);
entity.setSummary(buildSummary(result));
entity.setModel(miMoProps.getModel());
if ("resume".equals(reviewType)) {
if ("resume".equals(p.reviewType)) {
entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100));
} else {
entity.setRiskLevel(parseStr(RISK_PATTERN, result));
}
if (StringUtils.isNotBlank(username)) {
entity.setCreateBy(username);
}
baseMapper.insert(entity);
return entity;
}
return baseMapper.selectVoById(entity.getId());
private String currentUsername() {
try {
return LoginHelper.getLoginUser() != null ? LoginHelper.getLoginUser().getUsername() : null;
} catch (Exception e) {
return null;
}
}
private SysOssVo uploadQuietly(MultipartFile file) {
try {
return ossService.upload(file, 0L);
} catch (Exception e) {
log.warn("AI审核原件留存失败{}", file.getOriginalFilename(), e);
return null;
}
}
private SysOssVo uploadQuietly(String fileName, byte[] bytes) {
File tmp = null;
try {
String suffix = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : "";
tmp = File.createTempFile("aireview", suffix);
Files.write(tmp.toPath(), bytes);
return ossService.upload(tmp, fileName, 0L);
} catch (Exception e) {
log.warn("AI审核原件留存失败{}", fileName, e);
return null;
} finally {
if (tmp != null && tmp.exists()) {
try { tmp.delete(); } catch (Exception ignored) {}
}
}
}
/** 解析后的待审核数据 */
private static class Prepared {
String reviewType;
String position;
String fileName;
byte[] bytes;
String system;
String userText;
List<String> images;
}
private String truncate(String text) {

View File

@@ -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"),

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

View File

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