Merge branch 'main' of http://49.232.154.205:10100/DeXun/fad_oa
This commit is contained in:
@@ -328,3 +328,19 @@ fad:
|
||||
webKey: 34bf20d1db5b183558b9bb85d6eed783
|
||||
securityKey: 6f9171724396deb5f8c42ef256b3cbc5
|
||||
|
||||
--- # 小米 MiMo 大模型(AI 合同/简历审核)
|
||||
mimo:
|
||||
# OpenAI 兼容接口地址(不含 /chat/completions)
|
||||
base-url: https://api.xiaomimimo.com/v1
|
||||
# API Key
|
||||
api-key: sk-cgdkhgch2w1cg37dl12scuckyzbnrkj37ih3b6f0k13dcgwp
|
||||
# 多模态模型
|
||||
model: mimo-v2.5
|
||||
# 最大生成 token(mimo-v2.5 为推理模型,会先消耗 token 思考,需留足额度)
|
||||
max-tokens: 8192
|
||||
temperature: 0.3
|
||||
# 单次请求读超时(秒)。推理+长文档审核较慢,给足时间
|
||||
timeout: 180
|
||||
# PDF 无法提取文字时(扫描件)转图片走多模态,最多渲染页数
|
||||
max-image-pages: 8
|
||||
|
||||
|
||||
@@ -83,6 +83,17 @@
|
||||
<version>2.0.29</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 解析 Word(.docx/.doc) 简历/合同 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-scratchpad</artifactId>
|
||||
<version>${poi.version}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ruoyi.oa.aireview;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.poi.hwpf.extractor.WordExtractor;
|
||||
import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文档解析:从 PDF / Word 提取文字;扫描版 PDF 渲染为图片走多模态。
|
||||
*
|
||||
* @author wangyu
|
||||
*/
|
||||
@Slf4j
|
||||
public final class DocumentParseUtil {
|
||||
|
||||
private DocumentParseUtil() {}
|
||||
|
||||
/** 提取文字内容(PDF / docx / doc)。提取不到返回空串,不抛异常。 */
|
||||
public static String extractText(String fileName, byte[] bytes) {
|
||||
String lower = fileName == null ? "" : fileName.toLowerCase();
|
||||
try {
|
||||
if (lower.endsWith(".pdf")) {
|
||||
return extractPdfText(bytes);
|
||||
} else if (lower.endsWith(".docx")) {
|
||||
try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes));
|
||||
XWPFWordExtractor ex = new XWPFWordExtractor(doc)) {
|
||||
return StringUtils.trimToEmpty(ex.getText());
|
||||
}
|
||||
} else if (lower.endsWith(".doc")) {
|
||||
try (WordExtractor ex = new WordExtractor(new ByteArrayInputStream(bytes))) {
|
||||
return StringUtils.trimToEmpty(ex.getText());
|
||||
}
|
||||
}
|
||||
throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件");
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("解析文档失败:{}", fileName, e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractPdfText(byte[] bytes) throws Exception {
|
||||
try (PDDocument doc = PDDocument.load(bytes)) {
|
||||
PDFTextStripper stripper = new PDFTextStripper();
|
||||
return StringUtils.trimToEmpty(stripper.getText(doc));
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为 PDF */
|
||||
public static boolean isPdf(String fileName) {
|
||||
return fileName != null && fileName.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 PDF 渲染为 PNG 图片的 data URI 列表(用于扫描件走多模态)。
|
||||
*
|
||||
* @param maxPages 最多渲染页数
|
||||
*/
|
||||
public static List<String> renderPdfImages(byte[] bytes, int maxPages) {
|
||||
List<String> images = new ArrayList<>();
|
||||
try (PDDocument doc = PDDocument.load(bytes)) {
|
||||
PDFRenderer renderer = new PDFRenderer(doc);
|
||||
int pages = Math.min(doc.getNumberOfPages(), Math.max(1, maxPages));
|
||||
for (int i = 0; i < pages; i++) {
|
||||
BufferedImage img = renderer.renderImageWithDPI(i, 120, ImageType.RGB);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", baos);
|
||||
String b64 = Base64.getEncoder().encodeToString(baos.toByteArray());
|
||||
images.add("data:image/png;base64," + b64);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("PDF 转图片失败", e);
|
||||
throw new ServiceException("PDF 转图片失败:" + e.getMessage());
|
||||
}
|
||||
return images;
|
||||
}
|
||||
}
|
||||
238
ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java
Normal file
238
ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java
Normal file
@@ -0,0 +1,238 @@
|
||||
package com.ruoyi.oa.aireview;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
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)。
|
||||
*
|
||||
* 注意:mimo-v2.5 是推理模型,响应里 message.content 是最终答案,
|
||||
* message.reasoning_content 是思考过程;max_completion_tokens 要给足,
|
||||
* 否则 token 被思考耗尽会出现 content 为空、finish_reason=length。
|
||||
*
|
||||
* @author wangyu
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MiMoClient {
|
||||
|
||||
private final MiMoProperties props;
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(10_000);
|
||||
factory.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本对话
|
||||
*/
|
||||
public String chatText(String systemPrompt, String userText) {
|
||||
ObjectNode body = baseBody();
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
if (StringUtils.isNotBlank(systemPrompt)) {
|
||||
messages.addObject().put("role", "system").put("content", systemPrompt);
|
||||
}
|
||||
messages.addObject().put("role", "user").put("content", userText);
|
||||
return send(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态对话:一段文字 + 若干图片(data URI,形如 data:image/png;base64,xxx)
|
||||
*/
|
||||
public String chatMultimodal(String systemPrompt, String userText, List<String> imageDataUris) {
|
||||
ObjectNode body = baseBody();
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
if (StringUtils.isNotBlank(systemPrompt)) {
|
||||
messages.addObject().put("role", "system").put("content", systemPrompt);
|
||||
}
|
||||
ObjectNode userMsg = messages.addObject();
|
||||
userMsg.put("role", "user");
|
||||
ArrayNode content = userMsg.putArray("content");
|
||||
content.addObject().put("type", "text").put("text", userText);
|
||||
if (imageDataUris != null) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 Key(application.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());
|
||||
// OpenAI 兼容新参数名;MiMo 同时也接受 max_tokens,这里两者都给以防万一
|
||||
body.put("max_completion_tokens", props.getMaxTokens());
|
||||
body.put("max_tokens", props.getMaxTokens());
|
||||
body.put("temperature", props.getTemperature());
|
||||
body.put("stream", false);
|
||||
return body;
|
||||
}
|
||||
|
||||
private String send(ObjectNode body) {
|
||||
if (StringUtils.isBlank(props.getApiKey())) {
|
||||
throw new ServiceException("未配置 MiMo API Key(application.yml: mimo.api-key)");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
// 文档要求 api-key 头;同时带 Bearer 以兼容 OpenAI 风格
|
||||
headers.set("api-key", props.getApiKey());
|
||||
headers.set("Authorization", "Bearer " + props.getApiKey());
|
||||
|
||||
String url = props.getBaseUrl() + "/chat/completions";
|
||||
try {
|
||||
HttpEntity<String> entity = new HttpEntity<>(json.writeValueAsString(body), headers);
|
||||
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
|
||||
if (resp.getStatusCode() != HttpStatus.OK || resp.getBody() == null) {
|
||||
throw new ServiceException("AI 服务返回异常:" + resp.getStatusCode());
|
||||
}
|
||||
JsonNode root = json.readTree(resp.getBody());
|
||||
JsonNode message = root.path("choices").path(0).path("message");
|
||||
String content = message.path("content").asText("");
|
||||
String finish = root.path("choices").path(0).path("finish_reason").asText("");
|
||||
if (StringUtils.isBlank(content)) {
|
||||
if ("length".equals(finish)) {
|
||||
throw new ServiceException("文档过长,AI 输出被截断,请精简文档或提高 mimo.max-tokens 后重试");
|
||||
}
|
||||
throw new ServiceException("AI 未返回有效内容");
|
||||
}
|
||||
int total = root.path("usage").path("total_tokens").asInt(0);
|
||||
log.info("MiMo 审核完成,model={}, 消耗 token={}", props.getModel(), total);
|
||||
return content;
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("调用 MiMo 失败", e);
|
||||
throw new ServiceException("AI 服务调用失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ruoyi.oa.aireview;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 小米 MiMo 大模型配置(AI 合同/简历审核)
|
||||
*
|
||||
* @author wangyu
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "mimo")
|
||||
public class MiMoProperties {
|
||||
|
||||
/** OpenAI 兼容接口地址(不含 /chat/completions) */
|
||||
private String baseUrl = "https://api.xiaomimimo.com/v1";
|
||||
|
||||
/** API Key */
|
||||
private String apiKey;
|
||||
|
||||
/** 多模态模型名 */
|
||||
private String model = "mimo-v2.5";
|
||||
|
||||
/** 最大生成 token(mimo-v2.5 为推理模型,需留足额度给思考+输出) */
|
||||
private Integer maxTokens = 8192;
|
||||
|
||||
/** 采样温度 */
|
||||
private Double temperature = 0.3;
|
||||
|
||||
/** 单次请求读超时(秒) */
|
||||
private Integer timeout = 180;
|
||||
|
||||
/** PDF 转图片走多模态时最多渲染页数 */
|
||||
private Integer maxImagePages = 8;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ruoyi.oa.controller;
|
||||
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
|
||||
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
|
||||
import com.ruoyi.oa.service.IOaAiReviewService;
|
||||
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;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* AI 审核(合同 / 简历)
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/oa/aiReview")
|
||||
public class OaAiReviewController extends BaseController {
|
||||
|
||||
private final IOaAiReviewService service;
|
||||
|
||||
/**
|
||||
* 上传并审核
|
||||
*/
|
||||
@Log(title = "AI审核", businessType = BusinessType.OTHER)
|
||||
@PostMapping(value = "/analyze", consumes = "multipart/form-data")
|
||||
public R<OaAiReviewVo> analyze(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("reviewType") String reviewType,
|
||||
@RequestParam(value = "position", required = false) String position) {
|
||||
return R.ok("审核完成", service.analyze(file, reviewType, position));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传并流式审核(SSE:边生成边推送,结束后落库)。
|
||||
* 不加 @Log:操作日志切面会尝试序列化返回值,SseEmitter 不适合被序列化。
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public R<OaAiReviewVo> getInfo(@NotNull @PathVariable Long id) {
|
||||
return R.ok(service.queryById(id));
|
||||
}
|
||||
|
||||
@Log(title = "AI审核", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@NotEmpty @PathVariable Long[] ids) {
|
||||
return toAjax(service.deleteByIds(Arrays.asList(ids)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ruoyi.oa.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.validation.constraints.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import com.ruoyi.common.annotation.RepeatSubmit;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.common.core.validate.AddGroup;
|
||||
import com.ruoyi.common.core.validate.EditGroup;
|
||||
import com.ruoyi.common.core.validate.QueryGroup;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.utils.poi.ExcelUtil;
|
||||
import com.ruoyi.oa.domain.vo.OaArrivalDetailVo;
|
||||
import com.ruoyi.oa.domain.bo.OaArrivalDetailBo;
|
||||
import com.ruoyi.oa.service.IOaArrivalDetailService;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
|
||||
/**
|
||||
* 到货明细
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-06-12
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/oa/arrivalDetail")
|
||||
public class OaArrivalDetailController extends BaseController {
|
||||
|
||||
private final IOaArrivalDetailService iOaArrivalDetailService;
|
||||
|
||||
/**
|
||||
* 查询到货明细列表
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo<OaArrivalDetailVo> list(OaArrivalDetailBo bo, PageQuery pageQuery) {
|
||||
return iOaArrivalDetailService.queryPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出到货明细列表
|
||||
*/
|
||||
@Log(title = "到货明细", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/export")
|
||||
public void export(OaArrivalDetailBo bo, HttpServletResponse response) {
|
||||
List<OaArrivalDetailVo> list = iOaArrivalDetailService.queryList(bo);
|
||||
ExcelUtil.exportExcel(list, "到货明细", OaArrivalDetailVo.class, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取到货明细详细信息
|
||||
*
|
||||
* @param detailId 主键
|
||||
*/
|
||||
@GetMapping("/{detailId}")
|
||||
public R<OaArrivalDetailVo> getInfo(@NotNull(message = "主键不能为空")
|
||||
@PathVariable Long detailId) {
|
||||
return R.ok(iOaArrivalDetailService.queryById(detailId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增到货明细
|
||||
*/
|
||||
@Log(title = "到货明细", businessType = BusinessType.INSERT)
|
||||
@RepeatSubmit()
|
||||
@PostMapping()
|
||||
public R<Void> add(@Validated(AddGroup.class) @RequestBody OaArrivalDetailBo bo) {
|
||||
return toAjax(iOaArrivalDetailService.insertByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改到货明细
|
||||
*/
|
||||
@Log(title = "到货明细", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PutMapping()
|
||||
public R<Void> edit(@Validated(EditGroup.class) @RequestBody OaArrivalDetailBo bo) {
|
||||
return toAjax(iOaArrivalDetailService.updateByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除到货明细
|
||||
*
|
||||
* @param detailIds 主键串
|
||||
*/
|
||||
@Log(title = "到货明细", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{detailIds}")
|
||||
public R<Void> remove(@NotEmpty(message = "主键不能为空")
|
||||
@PathVariable Long[] detailIds) {
|
||||
return toAjax(iOaArrivalDetailService.deleteWithValidByIds(Arrays.asList(detailIds), true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.oa.controller;
|
||||
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.annotation.RepeatSubmit;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo;
|
||||
import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo;
|
||||
import com.ruoyi.oa.service.IOaMeetingMinutesService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 会议纪要
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/oa/meetingMinutes")
|
||||
public class OaMeetingMinutesController extends BaseController {
|
||||
|
||||
private final IOaMeetingMinutesService service;
|
||||
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo<OaMeetingMinutesVo> list(OaMeetingMinutesBo bo, PageQuery pageQuery) {
|
||||
return service.queryPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public R<OaMeetingMinutesVo> getInfo(@NotNull @PathVariable Long id) {
|
||||
return R.ok(service.queryById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增,返回新纪要ID(前端据此切换为编辑态,避免重复保存生成多条)
|
||||
*/
|
||||
@Log(title = "会议纪要", businessType = BusinessType.INSERT)
|
||||
@RepeatSubmit
|
||||
@PostMapping
|
||||
public R<Long> add(@RequestBody OaMeetingMinutesBo bo) {
|
||||
if (!service.insertByBo(bo)) {
|
||||
return R.fail("保存失败");
|
||||
}
|
||||
return R.ok(bo.getId());
|
||||
}
|
||||
|
||||
@Log(title = "会议纪要", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit
|
||||
@PutMapping
|
||||
public R<Void> edit(@RequestBody OaMeetingMinutesBo bo) {
|
||||
return toAjax(service.updateByBo(bo));
|
||||
}
|
||||
|
||||
@Log(title = "会议纪要", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@NotEmpty @PathVariable Long[] ids) {
|
||||
return toAjax(service.deleteWithValidByIds(Arrays.asList(ids), true));
|
||||
}
|
||||
}
|
||||
58
ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java
Normal file
58
ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.ruoyi.oa.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* AI 审核记录(合同 / 简历)
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("oa_ai_review")
|
||||
public class OaAiReview extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
|
||||
/** 审核类型:contract 合同 / resume 简历 */
|
||||
private String reviewType;
|
||||
|
||||
/** 原始文件名 */
|
||||
private String fileName;
|
||||
|
||||
/** OSS 文件ID(原件留存,可空) */
|
||||
private Long ossId;
|
||||
|
||||
/** OSS 文件地址 */
|
||||
private String fileUrl;
|
||||
|
||||
/** 简历审核的目标岗位 */
|
||||
private String position;
|
||||
|
||||
/** 简历匹配度评分 0-100(合同为空) */
|
||||
private Integer matchScore;
|
||||
|
||||
/** 合同总体风险评级:高/中/低(简历为空) */
|
||||
private String riskLevel;
|
||||
|
||||
/** AI 审核结论摘要(列表展示,纯文本) */
|
||||
private String summary;
|
||||
|
||||
/** AI 审核结果(Markdown) */
|
||||
private String resultMd;
|
||||
|
||||
/** 使用的模型 */
|
||||
private String model;
|
||||
|
||||
/** 消耗 token */
|
||||
private Integer tokens;
|
||||
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ruoyi.oa.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 到货明细对象 oa_arrival_detail
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-06-12
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("oa_arrival_detail")
|
||||
public class OaArrivalDetail extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID=1L;
|
||||
|
||||
/**
|
||||
* 明细主键
|
||||
*/
|
||||
@TableId(value = "detail_id")
|
||||
private Long detailId;
|
||||
/**
|
||||
* 关联采购需求ID
|
||||
*/
|
||||
private Long requirementId;
|
||||
/**
|
||||
* 关联项目ID
|
||||
*/
|
||||
private Long projectId;
|
||||
/**
|
||||
* 项目类型:0=内贸 1=外贸
|
||||
*/
|
||||
private Integer tradeType;
|
||||
/**
|
||||
* 合同编号
|
||||
*/
|
||||
private String contractNo;
|
||||
/**
|
||||
* 物料名称
|
||||
*/
|
||||
private String goodsName;
|
||||
/**
|
||||
* 数量
|
||||
*/
|
||||
private BigDecimal quantity;
|
||||
/**
|
||||
* 单价
|
||||
*/
|
||||
private BigDecimal unitPrice;
|
||||
/**
|
||||
* 到货类型 (0 收,1 发)
|
||||
*/
|
||||
private Integer arrivalType;
|
||||
/**
|
||||
* 到货截止日期
|
||||
*/
|
||||
private Date deadline;
|
||||
/**
|
||||
* 发货地点
|
||||
*/
|
||||
private String sourceAddress;
|
||||
/**
|
||||
* 规划目的地
|
||||
*/
|
||||
private String targetAddress;
|
||||
/**
|
||||
* 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消)
|
||||
*/
|
||||
private Integer detailStatus;
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
/**
|
||||
* 手动备注
|
||||
*/
|
||||
private String remark;
|
||||
/**
|
||||
* 删除标志:0正常 1删除
|
||||
*/
|
||||
@TableLogic
|
||||
private Integer delFlag;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ruoyi.oa.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 会议纪要
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("oa_meeting_minutes")
|
||||
public class OaMeetingMinutes extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
|
||||
private String meetingCode;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private Date meetingDate;
|
||||
|
||||
/** sys_oa_project.project_id */
|
||||
private Long projectId;
|
||||
|
||||
private String meetingType;
|
||||
private String subject;
|
||||
private String location;
|
||||
|
||||
/** sys_user.user_id */
|
||||
private Long hostUserId;
|
||||
|
||||
/** 参会人员 user_id CSV */
|
||||
private String attendeeUserIds;
|
||||
|
||||
private String topic;
|
||||
private String discussion;
|
||||
private String decision;
|
||||
|
||||
/** 待办 JSON:[{assigneeUserId, content, deadline, status, taskId}] */
|
||||
private String tasksJson;
|
||||
|
||||
/** 1=保存时自动同步生成 sys_oa_task */
|
||||
private Integer syncTask;
|
||||
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.oa.domain.bo;
|
||||
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* AI 审核记录 查询 BO
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class OaAiReviewBo extends BaseEntity {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 审核类型:contract / resume */
|
||||
private String reviewType;
|
||||
|
||||
/** 关键字(文件名 / 岗位) */
|
||||
private String keyword;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.ruoyi.oa.domain.bo;
|
||||
|
||||
import com.ruoyi.common.core.validate.AddGroup;
|
||||
import com.ruoyi.common.core.validate.EditGroup;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 到货明细业务对象 oa_arrival_detail
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-06-12
|
||||
*/
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class OaArrivalDetailBo extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 明细主键
|
||||
*/
|
||||
private Long detailId;
|
||||
|
||||
/**
|
||||
* 关联采购需求ID
|
||||
*/
|
||||
private Long requirementId;
|
||||
|
||||
/**
|
||||
* 关联项目ID
|
||||
*/
|
||||
private Long projectId;
|
||||
|
||||
/**
|
||||
* 项目类型:0=内贸 1=外贸
|
||||
*/
|
||||
private Integer tradeType;
|
||||
|
||||
/**
|
||||
* 合同编号
|
||||
*/
|
||||
private String contractNo;
|
||||
|
||||
/**
|
||||
* 物料名称
|
||||
*/
|
||||
private String goodsName;
|
||||
|
||||
/**
|
||||
* 数量
|
||||
*/
|
||||
private BigDecimal quantity;
|
||||
|
||||
/**
|
||||
* 单价
|
||||
*/
|
||||
private BigDecimal unitPrice;
|
||||
|
||||
/**
|
||||
* 到货类型 (0 收,1 发)
|
||||
*/
|
||||
private Integer arrivalType;
|
||||
|
||||
/**
|
||||
* 到货截止日期
|
||||
*/
|
||||
private Date deadline;
|
||||
|
||||
/**
|
||||
* 发货地点
|
||||
*/
|
||||
private String sourceAddress;
|
||||
|
||||
/**
|
||||
* 规划目的地
|
||||
*/
|
||||
private String targetAddress;
|
||||
|
||||
/**
|
||||
* 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消)
|
||||
*/
|
||||
private Integer detailStatus;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 手动备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ruoyi.oa.domain.bo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 会议纪要 BO
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class OaMeetingMinutesBo extends BaseEntity {
|
||||
|
||||
private Long id;
|
||||
private String meetingCode;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private Date meetingDate;
|
||||
|
||||
private Long projectId;
|
||||
private String meetingType;
|
||||
private String subject;
|
||||
private String location;
|
||||
private Long hostUserId;
|
||||
private String attendeeUserIds;
|
||||
private String topic;
|
||||
private String discussion;
|
||||
private String decision;
|
||||
private String tasksJson;
|
||||
private Integer syncTask;
|
||||
|
||||
/** 关键字模糊(主题/地点/项目名) */
|
||||
private String keyword;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private Date dateFrom;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private Date dateTo;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.oa.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* AI 审核记录 VO
|
||||
*/
|
||||
@Data
|
||||
public class OaAiReviewVo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String reviewType;
|
||||
private String fileName;
|
||||
private Long ossId;
|
||||
private String fileUrl;
|
||||
private String position;
|
||||
private Integer matchScore;
|
||||
private String riskLevel;
|
||||
private String summary;
|
||||
private String resultMd;
|
||||
private String model;
|
||||
private Integer tokens;
|
||||
|
||||
private String createBy;
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.ruoyi.oa.domain.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.ruoyi.common.annotation.ExcelDictFormat;
|
||||
import com.ruoyi.common.convert.ExcelDictConvert;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import com.alibaba.excel.annotation.ExcelIgnore;
|
||||
import lombok.Data;
|
||||
import java.util.Date;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 到货明细视图对象 oa_arrival_detail
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-06-12
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
public class OaArrivalDetailVo extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 明细主键
|
||||
*/
|
||||
@ExcelProperty(value = "明细主键")
|
||||
private Long detailId;
|
||||
|
||||
/**
|
||||
* 关联采购需求ID
|
||||
*/
|
||||
@ExcelProperty(value = "关联采购需求ID")
|
||||
private Long requirementId;
|
||||
|
||||
/**
|
||||
* 关联项目ID
|
||||
*/
|
||||
@ExcelProperty(value = "关联项目ID")
|
||||
private Long projectId;
|
||||
|
||||
/**
|
||||
* 项目类型:0=内贸 1=外贸
|
||||
*/
|
||||
@ExcelProperty(value = "项目类型:0=内贸 1=外贸")
|
||||
private Integer tradeType;
|
||||
|
||||
/**
|
||||
* 合同编号
|
||||
*/
|
||||
@ExcelProperty(value = "合同编号")
|
||||
private String contractNo;
|
||||
|
||||
/**
|
||||
* 物料名称
|
||||
*/
|
||||
@ExcelProperty(value = "物料名称")
|
||||
private String goodsName;
|
||||
|
||||
/**
|
||||
* 数量
|
||||
*/
|
||||
@ExcelProperty(value = "数量")
|
||||
private BigDecimal quantity;
|
||||
|
||||
/**
|
||||
* 单价
|
||||
*/
|
||||
@ExcelProperty(value = "单价")
|
||||
private BigDecimal unitPrice;
|
||||
|
||||
/**
|
||||
* 到货类型 (0 收,1 发)
|
||||
*/
|
||||
@ExcelProperty(value = "到货类型 (0 收,1 发)")
|
||||
private Integer arrivalType;
|
||||
|
||||
/**
|
||||
* 到货截止日期
|
||||
*/
|
||||
@ExcelProperty(value = "到货截止日期")
|
||||
private Date deadline;
|
||||
|
||||
/**
|
||||
* 发货地点
|
||||
*/
|
||||
@ExcelProperty(value = "发货地点")
|
||||
private String sourceAddress;
|
||||
|
||||
/**
|
||||
* 规划目的地
|
||||
*/
|
||||
@ExcelProperty(value = "规划目的地")
|
||||
private String targetAddress;
|
||||
|
||||
/**
|
||||
* 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消)
|
||||
*/
|
||||
@ExcelProperty(value = "状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消)")
|
||||
private Integer detailStatus;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
@ExcelProperty(value = "描述")
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 手动备注
|
||||
*/
|
||||
@ExcelProperty(value = "手动备注")
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 关联需求完整信息(列表展示用,非数据库字段)
|
||||
*/
|
||||
@ExcelIgnore
|
||||
private OaRequirementsVo requirement;
|
||||
|
||||
/**
|
||||
* 关联项目完整信息(列表展示用,非数据库字段)
|
||||
*/
|
||||
@ExcelIgnore
|
||||
private SysOaProjectVo project;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ruoyi.oa.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 会议纪要 VO
|
||||
*/
|
||||
@Data
|
||||
public class OaMeetingMinutesVo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String meetingCode;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
|
||||
private Date meetingDate;
|
||||
|
||||
private Long projectId;
|
||||
/** 冗余:项目编号 / 名称(列表联表带出) */
|
||||
private String projectNum;
|
||||
private String projectName;
|
||||
|
||||
private String meetingType;
|
||||
private String subject;
|
||||
private String location;
|
||||
private Long hostUserId;
|
||||
/** 冗余:主持人昵称 */
|
||||
private String hostUserName;
|
||||
|
||||
private String attendeeUserIds;
|
||||
/** 冗余:参会人员昵称(逗号分隔) */
|
||||
private String attendeeUserNames;
|
||||
|
||||
private String topic;
|
||||
private String discussion;
|
||||
private String decision;
|
||||
private String tasksJson;
|
||||
private Integer syncTask;
|
||||
|
||||
private String createBy;
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
private String updateBy;
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import com.ruoyi.common.core.mapper.BaseMapperPlus;
|
||||
import com.ruoyi.oa.domain.OaAiReview;
|
||||
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
|
||||
|
||||
public interface OaAiReviewMapper extends BaseMapperPlus<OaAiReviewMapper, OaAiReview, OaAiReviewVo> {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import com.ruoyi.oa.domain.OaArrivalDetail;
|
||||
import com.ruoyi.oa.domain.vo.OaArrivalDetailVo;
|
||||
import com.ruoyi.common.core.mapper.BaseMapperPlus;
|
||||
|
||||
/**
|
||||
* 到货明细Mapper接口
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-06-12
|
||||
*/
|
||||
public interface OaArrivalDetailMapper extends BaseMapperPlus<OaArrivalDetailMapper, OaArrivalDetail, OaArrivalDetailVo> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import com.ruoyi.common.core.mapper.BaseMapperPlus;
|
||||
import com.ruoyi.oa.domain.OaMeetingMinutes;
|
||||
import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo;
|
||||
|
||||
public interface OaMeetingMinutesMapper extends BaseMapperPlus<OaMeetingMinutesMapper, OaMeetingMinutes, OaMeetingMinutesVo> {
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.oa.service;
|
||||
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
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;
|
||||
|
||||
public interface IOaAiReviewService {
|
||||
|
||||
/**
|
||||
* 上传合同/简历并进行 AI 审核,落库并返回结果
|
||||
*
|
||||
* @param file PDF / Word 文件
|
||||
* @param reviewType contract / resume
|
||||
* @param position 简历审核的目标岗位(合同可空)
|
||||
*/
|
||||
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);
|
||||
|
||||
Boolean deleteByIds(Collection<Long> ids);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ruoyi.oa.service;
|
||||
|
||||
import com.ruoyi.oa.domain.OaArrivalDetail;
|
||||
import com.ruoyi.oa.domain.vo.OaArrivalDetailVo;
|
||||
import com.ruoyi.oa.domain.bo.OaArrivalDetailBo;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 到货明细Service接口
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-06-12
|
||||
*/
|
||||
public interface IOaArrivalDetailService {
|
||||
|
||||
/**
|
||||
* 查询到货明细
|
||||
*/
|
||||
OaArrivalDetailVo queryById(Long detailId);
|
||||
|
||||
/**
|
||||
* 查询到货明细列表
|
||||
*/
|
||||
TableDataInfo<OaArrivalDetailVo> queryPageList(OaArrivalDetailBo bo, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 查询到货明细列表
|
||||
*/
|
||||
List<OaArrivalDetailVo> queryList(OaArrivalDetailBo bo);
|
||||
|
||||
/**
|
||||
* 新增到货明细
|
||||
*/
|
||||
Boolean insertByBo(OaArrivalDetailBo bo);
|
||||
|
||||
/**
|
||||
* 修改到货明细
|
||||
*/
|
||||
Boolean updateByBo(OaArrivalDetailBo bo);
|
||||
|
||||
/**
|
||||
* 校验并批量删除到货明细信息
|
||||
*/
|
||||
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ruoyi.oa.service;
|
||||
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo;
|
||||
import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface IOaMeetingMinutesService {
|
||||
|
||||
OaMeetingMinutesVo queryById(Long id);
|
||||
|
||||
TableDataInfo<OaMeetingMinutesVo> queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery);
|
||||
|
||||
List<OaMeetingMinutesVo> queryList(OaMeetingMinutesBo bo);
|
||||
|
||||
Boolean insertByBo(OaMeetingMinutesBo bo);
|
||||
|
||||
Boolean updateByBo(OaMeetingMinutesBo bo);
|
||||
|
||||
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.ruoyi.oa.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
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;
|
||||
import com.ruoyi.oa.aireview.MiMoProperties;
|
||||
import com.ruoyi.oa.domain.OaAiReview;
|
||||
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
|
||||
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
|
||||
import com.ruoyi.oa.mapper.OaAiReviewMapper;
|
||||
import com.ruoyi.oa.service.IOaAiReviewService;
|
||||
import com.ruoyi.system.domain.vo.SysOssVo;
|
||||
import com.ruoyi.system.service.ISysOssService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* AI 审核(合同 / 简历)
|
||||
*
|
||||
* @author wangyu
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OaAiReviewServiceImpl implements IOaAiReviewService {
|
||||
|
||||
private final OaAiReviewMapper baseMapper;
|
||||
private final MiMoClient miMoClient;
|
||||
private final MiMoProperties miMoProps;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
/** 文字内容少于该长度,认为是扫描件/图片版,PDF 走多模态 */
|
||||
private static final int MIN_TEXT_LEN = 30;
|
||||
/** 文档过长时截断,避免 prompt 过大 */
|
||||
private static final int MAX_TEXT_LEN = 24000;
|
||||
|
||||
private static final Pattern SCORE_PATTERN = Pattern.compile("匹配度评分[::\\s]*([0-9]{1,3})");
|
||||
private static final Pattern RISK_PATTERN = Pattern.compile("风险评级[::\\s]*([高中低])");
|
||||
|
||||
@Override
|
||||
public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) {
|
||||
Prepared p = prepareSync(file, reviewType, position);
|
||||
buildPrompt(p);
|
||||
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) {
|
||||
// 只在同步阶段做轻量校验 + 读取字节(multipart 必须在请求线程内消费);
|
||||
// 文档解析、渲染、大模型调用全部放到异步线程,任何异常都以 SSE error 事件返回,
|
||||
// 避免在返回 SseEmitter 之前抛异常被全局处理器包成 JSON(前端按流读取会“静默失败”)。
|
||||
Prepared p = prepareSync(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 {
|
||||
// 立即推送一个 start 事件,确认通道已打开(前端据此显示“已连接”)
|
||||
emitter.send(event("start", null));
|
||||
// 解析文档 + 构建提示词(可能抛异常,此处会转成 SSE error)
|
||||
buildPrompt(p);
|
||||
|
||||
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 {
|
||||
Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e;
|
||||
emitter.send(event("error", cause.getMessage()));
|
||||
} catch (Exception ignored) {}
|
||||
emitter.complete();
|
||||
}
|
||||
});
|
||||
worker.setDaemon(true);
|
||||
worker.setName("ai-review-stream");
|
||||
worker.start();
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private Map<String, Object> event(String type, String msg) {
|
||||
Map<String, Object> ev = new HashMap<>();
|
||||
ev.put("type", type);
|
||||
if (msg != null) ev.put("msg", msg);
|
||||
return ev;
|
||||
}
|
||||
|
||||
/** 同步阶段:校验 + 读取字节(不解析,快) */
|
||||
private Prepared prepareSync(MultipartFile file, String reviewType, String position) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException("请上传文件");
|
||||
}
|
||||
if (!"contract".equals(reviewType) && !"resume".equals(reviewType)) {
|
||||
throw new ServiceException("审核类型不正确");
|
||||
}
|
||||
String fileName = file.getOriginalFilename();
|
||||
if (StringUtils.isBlank(fileName)) {
|
||||
throw new ServiceException("文件名为空");
|
||||
}
|
||||
String lower = fileName.toLowerCase();
|
||||
if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) {
|
||||
throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件");
|
||||
}
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = file.getBytes();
|
||||
} catch (Exception e) {
|
||||
throw new ServiceException("读取文件失败");
|
||||
}
|
||||
Prepared p = new Prepared();
|
||||
p.reviewType = reviewType;
|
||||
p.position = position;
|
||||
p.fileName = fileName;
|
||||
p.bytes = bytes;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 解析文档 + 构建提示词(耗时/可能抛异常的部分) */
|
||||
private void buildPrompt(Prepared p) {
|
||||
p.system = "contract".equals(p.reviewType) ? contractSystemPrompt() : resumeSystemPrompt(p.position);
|
||||
String text = truncate(DocumentParseUtil.extractText(p.fileName, p.bytes));
|
||||
if (StringUtils.length(text) >= MIN_TEXT_LEN) {
|
||||
p.userText = "contract".equals(p.reviewType)
|
||||
? "以下是待审核的合同全文:\n\n" + text
|
||||
: "以下是待评估的简历内容:"
|
||||
+ (StringUtils.isNotBlank(p.position) ? "(目标岗位:" + p.position + ")" : "") + "\n\n" + text;
|
||||
p.images = null;
|
||||
} else if (DocumentParseUtil.isPdf(p.fileName)) {
|
||||
p.images = DocumentParseUtil.renderPdfImages(p.bytes, miMoProps.getMaxImagePages());
|
||||
p.userText = "contract".equals(p.reviewType)
|
||||
? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。"
|
||||
: "请评估以下图片中的简历(扫描件)。"
|
||||
+ (StringUtils.isNotBlank(p.position) ? "目标岗位:" + p.position + "。" : "");
|
||||
} else {
|
||||
throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF");
|
||||
}
|
||||
}
|
||||
|
||||
/** 落库(结论解析、摘要、创建人) */
|
||||
private OaAiReview persist(Prepared p, String result, SysOssVo oss, String username) {
|
||||
OaAiReview entity = new OaAiReview();
|
||||
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(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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (text == null) return "";
|
||||
return text.length() > MAX_TEXT_LEN ? text.substring(0, MAX_TEXT_LEN) : text;
|
||||
}
|
||||
|
||||
/** 从 Markdown 结果里提炼一段纯文本摘要,供列表展示 */
|
||||
private String buildSummary(String md) {
|
||||
if (StringUtils.isBlank(md)) return null;
|
||||
String text = md.replaceAll("(?m)^#+\\s*", "") // 标题符号
|
||||
.replaceAll("[*`>#\\-]", " ") // markdown 符号
|
||||
.replaceAll("\\s+", " ") // 折叠空白
|
||||
.trim();
|
||||
return text.length() > 160 ? text.substring(0, 160) : text;
|
||||
}
|
||||
|
||||
private Integer parseInt(Pattern p, String text, int max) {
|
||||
Matcher m = p.matcher(text == null ? "" : text);
|
||||
if (m.find()) {
|
||||
try {
|
||||
int v = Integer.parseInt(m.group(1));
|
||||
if (v >= 0 && v <= max) return v;
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String parseStr(Pattern p, String text) {
|
||||
Matcher m = p.matcher(text == null ? "" : text);
|
||||
return m.find() ? m.group(1) : null;
|
||||
}
|
||||
|
||||
private String contractSystemPrompt() {
|
||||
return "你是德睿福成套设备有限公司聘请的资深合同法务与商务谈判顾问。"
|
||||
+ "你的立场是【最大化保护并争取“我方”(德睿福)的利益】——审查时始终站在我方角度,"
|
||||
+ "识别对我方不利的安排,并提出利好我方的修改与补充建议。\n"
|
||||
+ "请用简体中文、Markdown 格式输出审核报告,包含以下小节(用二级标题):\n"
|
||||
+ "## 一、合同概要\n(合同类型、双方主体、标的、金额、期限等关键信息)\n"
|
||||
+ "## 二、总体风险评级\n(必须单独一行明确写出:`风险评级:高` 或 `风险评级:中` 或 `风险评级:低`,再附简要理由)\n"
|
||||
+ "## 三、对我方不利/存在风险的条款\n(逐条列出:① 原文摘录 ② 风险说明 ③ 利好我方的修改建议)\n"
|
||||
+ "## 四、建议补充的利好我方条款\n(如违约金、付款节点与比例、质保与验收、知识产权归属、保密、不可抗力、争议解决地与管辖等)\n"
|
||||
+ "## 五、关键条款审查\n(付款、交付、验收、违约责任、责任限制/赔偿上限、解除权等)\n"
|
||||
+ "## 六、谈判要点与优先级\n(按重要性排序,给出可直接用于谈判的要点)\n"
|
||||
+ "注意:若合同中我方实为乙方/供方,请据实判断我方身份后仍以争取我方利益为目标。";
|
||||
}
|
||||
|
||||
private String resumeSystemPrompt(String position) {
|
||||
String posLine = StringUtils.isNotBlank(position)
|
||||
? "目标岗位为【" + position + "】,请重点评估候选人与该岗位的匹配度。\n"
|
||||
: "未指定目标岗位,请综合判断其最适合的岗位方向并据此评估。\n";
|
||||
return "你是德睿福成套设备有限公司的资深招聘官与技术面试官。" + posLine
|
||||
+ "请用简体中文、Markdown 格式输出评估报告,包含以下小节(用二级标题):\n"
|
||||
+ "## 一、候选人概要\n(姓名、工作年限、学历、当前/最近岗位、期望等,能提取则填)\n"
|
||||
+ "## 二、岗位匹配度\n(必须单独一行明确写出:`匹配度评分:NN`,NN 为 0-100 的整数;随后给出评分理由,"
|
||||
+ "结合岗位所需的技能、经验、行业背景逐点对照)\n"
|
||||
+ "## 三、核心优势\n"
|
||||
+ "## 四、短板与风险点\n(经历空窗、跳槽频繁、技能缺口等)\n"
|
||||
+ "## 五、建议重点考察的面试问题\n(针对其经历与岗位要求,给出 5 个左右有针对性的问题)\n"
|
||||
+ "## 六、录用建议\n(明确给出:建议录用 / 谨慎考虑 / 不建议,并说明理由)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public TableDataInfo<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<OaAiReview> lqw = Wrappers.lambdaQuery();
|
||||
if (bo != null) {
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getReviewType()), OaAiReview::getReviewType, bo.getReviewType());
|
||||
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||
String kw = bo.getKeyword().trim();
|
||||
lqw.and(w -> w.like(OaAiReview::getFileName, kw).or().like(OaAiReview::getPosition, kw));
|
||||
}
|
||||
}
|
||||
lqw.orderByDesc(OaAiReview::getCreateTime);
|
||||
Page<OaAiReviewVo> page = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
||||
// 列表只需 summary,清空大字段 resultMd 减小响应体
|
||||
if (page.getRecords() != null) {
|
||||
page.getRecords().forEach(v -> v.setResultMd(null));
|
||||
}
|
||||
return TableDataInfo.build(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OaAiReviewVo queryById(Long id) {
|
||||
OaAiReviewVo vo = baseMapper.selectVoById(id);
|
||||
if (vo == null) throw new ServiceException("记录不存在或已删除");
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean deleteByIds(Collection<Long> ids) {
|
||||
return baseMapper.deleteBatchIds(ids) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.ruoyi.oa.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.oa.domain.bo.OaArrivalDetailBo;
|
||||
import com.ruoyi.oa.domain.vo.OaArrivalDetailVo;
|
||||
import com.ruoyi.oa.domain.vo.OaRequirementsVo;
|
||||
import com.ruoyi.oa.domain.vo.SysOaProjectVo;
|
||||
import com.ruoyi.oa.domain.OaArrivalDetail;
|
||||
import com.ruoyi.oa.domain.OaRequirements;
|
||||
import com.ruoyi.oa.domain.SysOaProject;
|
||||
import com.ruoyi.oa.mapper.OaArrivalDetailMapper;
|
||||
import com.ruoyi.oa.mapper.OaRequirementsMapper;
|
||||
import com.ruoyi.oa.mapper.SysOaProjectMapper;
|
||||
import com.ruoyi.oa.service.IOaArrivalDetailService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 到货明细Service业务层处理
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-06-12
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class OaArrivalDetailServiceImpl implements IOaArrivalDetailService {
|
||||
|
||||
private final OaArrivalDetailMapper baseMapper;
|
||||
|
||||
private final OaRequirementsMapper requirementsMapper;
|
||||
|
||||
private final SysOaProjectMapper projectMapper;
|
||||
|
||||
/**
|
||||
* 查询到货明细
|
||||
*/
|
||||
@Override
|
||||
public OaArrivalDetailVo queryById(Long detailId){
|
||||
OaArrivalDetailVo vo = baseMapper.selectVoById(detailId);
|
||||
if (vo != null) {
|
||||
fillRelatedVo(vo);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询到货明细列表(分页)
|
||||
*/
|
||||
@Override
|
||||
public TableDataInfo<OaArrivalDetailVo> queryPageList(OaArrivalDetailBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<OaArrivalDetail> lqw = buildQueryWrapper(bo);
|
||||
Page<OaArrivalDetailVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
||||
// 批量填充关联名称,避免 N+1
|
||||
batchFillRelatedVos(result.getRecords());
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询到货明细列表(不分页)
|
||||
*/
|
||||
@Override
|
||||
public List<OaArrivalDetailVo> queryList(OaArrivalDetailBo bo) {
|
||||
LambdaQueryWrapper<OaArrivalDetail> lqw = buildQueryWrapper(bo);
|
||||
List<OaArrivalDetailVo> list = baseMapper.selectVoList(lqw);
|
||||
// 批量填充关联名称
|
||||
batchFillRelatedVos(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<OaArrivalDetail> buildQueryWrapper(OaArrivalDetailBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
LambdaQueryWrapper<OaArrivalDetail> lqw = Wrappers.lambdaQuery();
|
||||
lqw.eq(bo.getRequirementId() != null, OaArrivalDetail::getRequirementId, bo.getRequirementId());
|
||||
lqw.eq(bo.getProjectId() != null, OaArrivalDetail::getProjectId, bo.getProjectId());
|
||||
lqw.eq(bo.getTradeType() != null, OaArrivalDetail::getTradeType, bo.getTradeType());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getContractNo()), OaArrivalDetail::getContractNo, bo.getContractNo());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getGoodsName()), OaArrivalDetail::getGoodsName, bo.getGoodsName());
|
||||
lqw.eq(bo.getQuantity() != null, OaArrivalDetail::getQuantity, bo.getQuantity());
|
||||
lqw.eq(bo.getUnitPrice() != null, OaArrivalDetail::getUnitPrice, bo.getUnitPrice());
|
||||
lqw.eq(bo.getArrivalType() != null, OaArrivalDetail::getArrivalType, bo.getArrivalType());
|
||||
lqw.eq(bo.getDeadline() != null, OaArrivalDetail::getDeadline, bo.getDeadline());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getSourceAddress()), OaArrivalDetail::getSourceAddress, bo.getSourceAddress());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getTargetAddress()), OaArrivalDetail::getTargetAddress, bo.getTargetAddress());
|
||||
lqw.eq(bo.getDetailStatus() != null, OaArrivalDetail::getDetailStatus, bo.getDetailStatus());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getDescription()), OaArrivalDetail::getDescription, bo.getDescription());
|
||||
lqw.orderByDesc(OaArrivalDetail::getCreateTime);
|
||||
return lqw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充关联的需求VO和项目VO
|
||||
*/
|
||||
private void batchFillRelatedVos(List<OaArrivalDetailVo> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有需求ID
|
||||
Set<Long> requirementIds = list.stream()
|
||||
.map(OaArrivalDetailVo::getRequirementId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 收集所有项目ID
|
||||
Set<Long> projectIds = list.stream()
|
||||
.map(OaArrivalDetailVo::getProjectId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 批量查询需求完整VO
|
||||
Map<Long, OaRequirementsVo> requirementMap = Collections.emptyMap();
|
||||
if (!requirementIds.isEmpty()) {
|
||||
List<OaRequirementsVo> reqVoList = requirementsMapper.selectVoBatchIds(requirementIds);
|
||||
if (reqVoList != null) {
|
||||
requirementMap = reqVoList.stream()
|
||||
.collect(Collectors.toMap(OaRequirementsVo::getRequirementId,
|
||||
Function.identity(), (a, b) -> a));
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询项目完整VO
|
||||
Map<Long, SysOaProjectVo> projectMap = Collections.emptyMap();
|
||||
if (!projectIds.isEmpty()) {
|
||||
List<SysOaProjectVo> projVoList = projectMapper.selectVoBatchIds(projectIds);
|
||||
if (projVoList != null) {
|
||||
projectMap = projVoList.stream()
|
||||
.collect(Collectors.toMap(SysOaProjectVo::getProjectId,
|
||||
Function.identity(), (a, b) -> a));
|
||||
}
|
||||
}
|
||||
|
||||
// 回填完整 VO 对象
|
||||
for (OaArrivalDetailVo vo : list) {
|
||||
if (vo.getRequirementId() != null) {
|
||||
vo.setRequirement(requirementMap.get(vo.getRequirementId()));
|
||||
}
|
||||
if (vo.getProjectId() != null) {
|
||||
vo.setProject(projectMap.get(vo.getProjectId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条填充关联VO
|
||||
*/
|
||||
private void fillRelatedVo(OaArrivalDetailVo vo) {
|
||||
if (vo.getRequirementId() != null) {
|
||||
vo.setRequirement(requirementsMapper.selectVoById(vo.getRequirementId()));
|
||||
}
|
||||
if (vo.getProjectId() != null) {
|
||||
vo.setProject(projectMapper.selectVoById(vo.getProjectId()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增到货明细
|
||||
*/
|
||||
@Override
|
||||
public Boolean insertByBo(OaArrivalDetailBo bo) {
|
||||
// 如果前端传了需求ID,自动查询需求表获取项目ID,再查项目表获取贸易类型
|
||||
autoFillByRequirement(bo);
|
||||
|
||||
OaArrivalDetail add = BeanUtil.toBean(bo, OaArrivalDetail.class);
|
||||
validEntityBeforeSave(add);
|
||||
boolean flag = baseMapper.insert(add) > 0;
|
||||
if (flag) {
|
||||
bo.setDetailId(add.getDetailId());
|
||||
}
|
||||
return flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据需求ID自动填充项目ID和贸易类型
|
||||
*/
|
||||
private void autoFillByRequirement(OaArrivalDetailBo bo) {
|
||||
if (bo.getRequirementId() == null) {
|
||||
return;
|
||||
}
|
||||
// 查询需求表获取项目ID
|
||||
OaRequirements requirement = requirementsMapper.selectById(bo.getRequirementId());
|
||||
if (requirement == null) {
|
||||
return;
|
||||
}
|
||||
Long projectId = requirement.getProjectId();
|
||||
if (projectId != null && bo.getProjectId() == null) {
|
||||
bo.setProjectId(projectId);
|
||||
}
|
||||
// 查询项目表获取贸易类型(仅当未设置时)
|
||||
if (bo.getTradeType() == null && projectId != null) {
|
||||
SysOaProject project = projectMapper.selectById(projectId);
|
||||
if (project != null && project.getTradeType() != null) {
|
||||
bo.setTradeType(project.getTradeType().intValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改到货明细
|
||||
*/
|
||||
@Override
|
||||
public Boolean updateByBo(OaArrivalDetailBo bo) {
|
||||
OaArrivalDetail update = BeanUtil.toBean(bo, OaArrivalDetail.class);
|
||||
validEntityBeforeSave(update);
|
||||
return baseMapper.updateById(update) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存前的数据校验
|
||||
*/
|
||||
private void validEntityBeforeSave(OaArrivalDetail entity){
|
||||
//TODO 做一些数据校验,如唯一约束
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除到货明细
|
||||
*/
|
||||
@Override
|
||||
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
|
||||
if(isValid){
|
||||
//TODO 做一些业务上的校验,判断是否需要校验
|
||||
}
|
||||
return baseMapper.deleteBatchIds(ids) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.ruoyi.oa.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.oa.domain.OaMeetingMinutes;
|
||||
import com.ruoyi.oa.domain.SysOaProject;
|
||||
import com.ruoyi.oa.domain.SysOaTask;
|
||||
import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo;
|
||||
import com.ruoyi.oa.domain.bo.SysOaTaskBo;
|
||||
import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo;
|
||||
import com.ruoyi.oa.mapper.OaMeetingMinutesMapper;
|
||||
import com.ruoyi.oa.mapper.SysOaProjectMapper;
|
||||
import com.ruoyi.oa.mapper.SysOaTaskMapper;
|
||||
import com.ruoyi.oa.service.IOaMeetingMinutesService;
|
||||
import com.ruoyi.oa.service.ISysOaTaskService;
|
||||
import com.ruoyi.system.mapper.SysUserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 会议纪要
|
||||
*
|
||||
* 待办同步说明:tasks_json 中"有负责人且有内容"的条目会通过 ISysOaTaskService
|
||||
* 生成/更新 sys_oa_task(复用任务模块的操作日志、IM 通知逻辑),任务ID回写进 JSON。
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
|
||||
|
||||
private final OaMeetingMinutesMapper baseMapper;
|
||||
private final SysOaProjectMapper projectMapper;
|
||||
private final SysOaTaskMapper taskMapper;
|
||||
private final ISysOaTaskService taskService;
|
||||
private final SysUserMapper userMapper;
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
/** sys_oa_task.state:0执行中 2执行完成(1等待验收/15延期申请由任务模块流转,会议页不使用) */
|
||||
private static final Long TASK_STATE_DOING = 0L;
|
||||
private static final Long TASK_STATE_DONE = 2L;
|
||||
|
||||
@Override
|
||||
public OaMeetingMinutesVo queryById(Long id) {
|
||||
OaMeetingMinutesVo vo = baseMapper.selectVoById(id);
|
||||
if (vo == null) {
|
||||
throw new ServiceException("会议纪要不存在或已删除");
|
||||
}
|
||||
enrich(Collections.singletonList(vo));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TableDataInfo<OaMeetingMinutesVo> queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<OaMeetingMinutes> lqw = buildQueryWrapper(bo);
|
||||
Page<OaMeetingMinutesVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
||||
enrich(result.getRecords());
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OaMeetingMinutesVo> queryList(OaMeetingMinutesBo bo) {
|
||||
List<OaMeetingMinutesVo> list = baseMapper.selectVoList(buildQueryWrapper(bo));
|
||||
enrich(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<OaMeetingMinutes> buildQueryWrapper(OaMeetingMinutesBo bo) {
|
||||
LambdaQueryWrapper<OaMeetingMinutes> lqw = Wrappers.lambdaQuery();
|
||||
if (bo != null) {
|
||||
lqw.eq(bo.getProjectId() != null, OaMeetingMinutes::getProjectId, bo.getProjectId());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getMeetingType()), OaMeetingMinutes::getMeetingType, bo.getMeetingType());
|
||||
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||
String kw = bo.getKeyword().trim();
|
||||
lqw.and(w -> w.like(OaMeetingMinutes::getSubject, kw)
|
||||
.or().like(OaMeetingMinutes::getLocation, kw)
|
||||
.or().like(OaMeetingMinutes::getMeetingCode, kw));
|
||||
}
|
||||
lqw.ge(bo.getDateFrom() != null, OaMeetingMinutes::getMeetingDate, bo.getDateFrom());
|
||||
lqw.le(bo.getDateTo() != null, OaMeetingMinutes::getMeetingDate, bo.getDateTo());
|
||||
}
|
||||
lqw.orderByDesc(OaMeetingMinutes::getMeetingDate)
|
||||
.orderByDesc(OaMeetingMinutes::getCreateTime);
|
||||
return lqw;
|
||||
}
|
||||
|
||||
/** 填充 项目名/编号、主持人/参会人/待办负责人 昵称 */
|
||||
private void enrich(List<OaMeetingMinutesVo> list) {
|
||||
if (list == null || list.isEmpty()) return;
|
||||
Set<Long> projectIds = new HashSet<>();
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
Map<Long, ArrayNode> taskNodes = new HashMap<>();
|
||||
for (OaMeetingMinutesVo v : list) {
|
||||
if (v == null) continue;
|
||||
if (v.getProjectId() != null) projectIds.add(v.getProjectId());
|
||||
if (v.getHostUserId() != null) userIds.add(v.getHostUserId());
|
||||
userIds.addAll(parseLongCsv(v.getAttendeeUserIds()));
|
||||
ArrayNode arr = parseTaskArray(v.getTasksJson());
|
||||
if (arr != null) {
|
||||
taskNodes.put(v.getId(), arr);
|
||||
for (JsonNode n : arr) {
|
||||
Long uid = longOf(n, "assigneeUserId");
|
||||
if (uid != null) userIds.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<Long, SysOaProject> pMap = projectIds.isEmpty() ? Collections.emptyMap()
|
||||
: projectMapper.selectList(new QueryWrapper<SysOaProject>().in("project_id", projectIds))
|
||||
.stream().collect(Collectors.toMap(SysOaProject::getProjectId, p -> p, (a, b) -> a));
|
||||
Map<Long, SysUser> uMap = userIds.isEmpty() ? Collections.emptyMap()
|
||||
: userMapper.selectList(new QueryWrapper<SysUser>().in("user_id", userIds))
|
||||
.stream().collect(Collectors.toMap(SysUser::getUserId, u -> u, (a, b) -> a));
|
||||
|
||||
for (OaMeetingMinutesVo v : list) {
|
||||
if (v == null) continue;
|
||||
if (v.getProjectId() != null) {
|
||||
SysOaProject p = pMap.get(v.getProjectId());
|
||||
if (p != null) {
|
||||
v.setProjectName(p.getProjectName());
|
||||
v.setProjectNum(p.getProjectNum());
|
||||
}
|
||||
}
|
||||
if (v.getHostUserId() != null) {
|
||||
SysUser u = uMap.get(v.getHostUserId());
|
||||
if (u != null) v.setHostUserName(u.getNickName());
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Long uid : parseLongCsv(v.getAttendeeUserIds())) {
|
||||
SysUser u = uMap.get(uid);
|
||||
if (u != null) names.add(u.getNickName());
|
||||
}
|
||||
if (!names.isEmpty()) v.setAttendeeUserNames(String.join(",", names));
|
||||
// 待办条目写入 assigneeName,前端展示/导出用
|
||||
ArrayNode arr = taskNodes.get(v.getId());
|
||||
if (arr != null) {
|
||||
for (JsonNode n : arr) {
|
||||
if (!n.isObject()) continue;
|
||||
Long uid = longOf(n, "assigneeUserId");
|
||||
SysUser u = uid == null ? null : uMap.get(uid);
|
||||
((ObjectNode) n).put("assigneeName", u == null ? null : u.getNickName());
|
||||
}
|
||||
try {
|
||||
v.setTasksJson(json.writeValueAsString(arr));
|
||||
} catch (Exception e) {
|
||||
log.warn("会议纪要[{}] tasksJson 序列化失败", v.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean insertByBo(OaMeetingMinutesBo bo) {
|
||||
OaMeetingMinutes add = BeanUtil.toBean(bo, OaMeetingMinutes.class);
|
||||
add.setId(null);
|
||||
// 时间戳+3位随机数,避免同一秒并发保存撞唯一键
|
||||
add.setMeetingCode("MT-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())
|
||||
+ RandomUtil.randomNumbers(3));
|
||||
if (StringUtils.isBlank(add.getMeetingType())) add.setMeetingType("other");
|
||||
if (add.getSyncTask() == null) add.setSyncTask(1);
|
||||
validBeforeSave(add);
|
||||
boolean ok = baseMapper.insert(add) > 0;
|
||||
if (ok) {
|
||||
bo.setId(add.getId());
|
||||
if (Integer.valueOf(1).equals(add.getSyncTask())) {
|
||||
String updated = syncTasks(add);
|
||||
if (updated != null && !updated.equals(add.getTasksJson())) {
|
||||
patchTasksJson(add.getId(), updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean updateByBo(OaMeetingMinutesBo bo) {
|
||||
if (bo.getId() == null) throw new ServiceException("缺少纪要ID");
|
||||
OaMeetingMinutes old = baseMapper.selectById(bo.getId());
|
||||
if (old == null) throw new ServiceException("会议纪要不存在或已删除");
|
||||
OaMeetingMinutes upd = BeanUtil.toBean(bo, OaMeetingMinutes.class);
|
||||
if (StringUtils.isBlank(upd.getMeetingType())) upd.setMeetingType("other");
|
||||
validBeforeSave(upd);
|
||||
if (Integer.valueOf(1).equals(upd.getSyncTask())) {
|
||||
upd.setTasksJson(syncTasks(upd));
|
||||
}
|
||||
// 可清空字段(解绑项目、清空人员/内容等)显式 set,避免 MyBatis-Plus 忽略 null 导致清空不生效
|
||||
LambdaUpdateWrapper<OaMeetingMinutes> luw = Wrappers.<OaMeetingMinutes>lambdaUpdate()
|
||||
.set(OaMeetingMinutes::getProjectId, upd.getProjectId())
|
||||
.set(OaMeetingMinutes::getHostUserId, upd.getHostUserId())
|
||||
.set(OaMeetingMinutes::getAttendeeUserIds, upd.getAttendeeUserIds())
|
||||
.set(OaMeetingMinutes::getLocation, upd.getLocation())
|
||||
.set(OaMeetingMinutes::getTopic, upd.getTopic())
|
||||
.set(OaMeetingMinutes::getDiscussion, upd.getDiscussion())
|
||||
.set(OaMeetingMinutes::getDecision, upd.getDecision())
|
||||
.set(OaMeetingMinutes::getTasksJson, upd.getTasksJson())
|
||||
.eq(OaMeetingMinutes::getId, upd.getId());
|
||||
OaMeetingMinutes entity = new OaMeetingMinutes();
|
||||
entity.setMeetingDate(upd.getMeetingDate());
|
||||
entity.setMeetingType(upd.getMeetingType());
|
||||
entity.setSubject(upd.getSubject());
|
||||
entity.setSyncTask(upd.getSyncTask());
|
||||
return baseMapper.update(entity, luw) > 0;
|
||||
}
|
||||
|
||||
private void patchTasksJson(Long id, String tasksJson) {
|
||||
baseMapper.update(null, Wrappers.<OaMeetingMinutes>lambdaUpdate()
|
||||
.set(OaMeetingMinutes::getTasksJson, tasksJson)
|
||||
.eq(OaMeetingMinutes::getId, id));
|
||||
}
|
||||
|
||||
private void validBeforeSave(OaMeetingMinutes e) {
|
||||
if (e.getMeetingDate() == null) throw new ServiceException("请选择会议日期");
|
||||
if (StringUtils.isBlank(e.getSubject())) throw new ServiceException("请输入会议主题");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将待办同步到 sys_oa_task(通过任务服务,带操作日志和 IM 通知):
|
||||
* - 无内容或无负责人的条目仅作纪要记录,不生成任务
|
||||
* - 已有 taskId 且任务仍存在 → 更新;否则新建并把 taskId 写回 JSON
|
||||
* - 同步失败不阻塞纪要保存,仅记录日志
|
||||
*/
|
||||
private String syncTasks(OaMeetingMinutes meeting) {
|
||||
if (StringUtils.isBlank(meeting.getTasksJson())) return meeting.getTasksJson();
|
||||
try {
|
||||
JsonNode root = json.readTree(meeting.getTasksJson());
|
||||
if (!root.isArray()) return meeting.getTasksJson();
|
||||
ArrayNode arr = (ArrayNode) root;
|
||||
for (JsonNode n : arr) {
|
||||
if (!n.isObject()) continue;
|
||||
ObjectNode o = (ObjectNode) n;
|
||||
String content = textOf(o, "content");
|
||||
Long assignee = longOf(o, "assigneeUserId");
|
||||
if (StringUtils.isBlank(content) || assignee == null) continue;
|
||||
|
||||
String status = textOf(o, "status");
|
||||
boolean done = "done".equals(status);
|
||||
Long existTaskId = longOf(o, "taskId");
|
||||
SysOaTask exist = existTaskId == null ? null : taskMapper.selectById(existTaskId);
|
||||
|
||||
SysOaTaskBo t = new SysOaTaskBo();
|
||||
t.setProjectId(meeting.getProjectId());
|
||||
t.setTaskTitle(StringUtils.substring(content, 0, 200));
|
||||
t.setContent("来自会议纪要「" + meeting.getSubject() + "」");
|
||||
t.setFinishTime(parseDay(textOf(o, "deadline")));
|
||||
t.setState(done ? TASK_STATE_DONE : TASK_STATE_DOING);
|
||||
if (done) t.setCompletedTime(new Date());
|
||||
if (exist == null) {
|
||||
t.setBeginTime(meeting.getMeetingDate());
|
||||
t.setWorkerIds(String.valueOf(assignee));
|
||||
t.setStatus(0L);
|
||||
taskService.insertByBo(t);
|
||||
if (t.getTaskId() != null) o.put("taskId", t.getTaskId());
|
||||
} else {
|
||||
t.setTaskId(existTaskId);
|
||||
t.setWorkerId(assignee);
|
||||
taskService.updateByBo(t);
|
||||
}
|
||||
}
|
||||
return json.writeValueAsString(arr);
|
||||
} catch (Exception e) {
|
||||
log.warn("会议纪要[{}]待办同步OA任务失败", meeting.getMeetingCode(), e);
|
||||
return meeting.getTasksJson();
|
||||
}
|
||||
}
|
||||
|
||||
private Date parseDay(String s) {
|
||||
if (StringUtils.isBlank(s)) return null;
|
||||
try { return new SimpleDateFormat("yyyy-MM-dd").parse(s); }
|
||||
catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private ArrayNode parseTaskArray(String s) {
|
||||
if (StringUtils.isBlank(s)) return null;
|
||||
try {
|
||||
JsonNode root = json.readTree(s);
|
||||
return root.isArray() ? (ArrayNode) root : null;
|
||||
} catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private String textOf(JsonNode n, String k) {
|
||||
JsonNode v = n.get(k);
|
||||
return v == null || v.isNull() ? null : v.asText();
|
||||
}
|
||||
|
||||
private Long longOf(JsonNode n, String k) {
|
||||
JsonNode v = n.get(k);
|
||||
if (v == null || v.isNull()) return null;
|
||||
try { return v.isNumber() ? v.asLong() : Long.parseLong(v.asText()); }
|
||||
catch (NumberFormatException e) { return null; }
|
||||
}
|
||||
|
||||
private List<Long> parseLongCsv(String csv) {
|
||||
if (StringUtils.isBlank(csv)) return Collections.emptyList();
|
||||
List<Long> r = new ArrayList<>();
|
||||
for (String s : csv.split(",")) {
|
||||
String t = s.trim();
|
||||
if (t.isEmpty()) continue;
|
||||
try { r.add(Long.parseLong(t)); } catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
|
||||
return baseMapper.deleteBatchIds(ids) > 0;
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,8 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService {
|
||||
add.setOriginFinishTime(add.getFinishTime());
|
||||
add.setWorkerId(workerId);
|
||||
flag = baseMapper.insert(add) > 0;
|
||||
// 回填任务ID,供调用方(如会议纪要待办同步)建立关联
|
||||
bo.setTaskId(add.getTaskId());
|
||||
if (flag) {
|
||||
operationLogService.recordLog(add.getProjectId(), 3, add.getTaskId(),
|
||||
add.getTaskTitle(), 1, "新增任务: " + add.getTaskTitle(), null, null);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.oa.mapper.OaArrivalDetailMapper">
|
||||
|
||||
<resultMap type="com.ruoyi.oa.domain.OaArrivalDetail" id="OaArrivalDetailResult">
|
||||
<result property="detailId" column="detail_id"/>
|
||||
<result property="requirementId" column="requirement_id"/>
|
||||
<result property="projectId" column="project_id"/>
|
||||
<result property="tradeType" column="trade_type"/>
|
||||
<result property="contractNo" column="contract_no"/>
|
||||
<result property="goodsName" column="goods_name"/>
|
||||
<result property="quantity" column="quantity"/>
|
||||
<result property="unitPrice" column="unit_price"/>
|
||||
<result property="arrivalType" column="arrival_type"/>
|
||||
<result property="deadline" column="deadline"/>
|
||||
<result property="sourceAddress" column="source_address"/>
|
||||
<result property="targetAddress" column="target_address"/>
|
||||
<result property="detailStatus" column="detail_status"/>
|
||||
<result property="description" column="description"/>
|
||||
<result property="remark" column="remark"/>
|
||||
<result property="createBy" column="create_by"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="updateBy" column="update_by"/>
|
||||
<result property="updateTime" column="update_time"/>
|
||||
<result property="delFlag" column="del_flag"/>
|
||||
</resultMap>
|
||||
|
||||
|
||||
</mapper>
|
||||
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' })
|
||||
}
|
||||
44
ruoyi-ui/src/api/oa/arrivalDetail.js
Normal file
44
ruoyi-ui/src/api/oa/arrivalDetail.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询到货明细列表
|
||||
export function listArrivalDetail(query) {
|
||||
return request({
|
||||
url: '/oa/arrivalDetail/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询到货明细详细
|
||||
export function getArrivalDetail(detailId) {
|
||||
return request({
|
||||
url: '/oa/arrivalDetail/' + detailId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 新增到货明细
|
||||
export function addArrivalDetail(data) {
|
||||
return request({
|
||||
url: '/oa/arrivalDetail',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 修改到货明细
|
||||
export function updateArrivalDetail(data) {
|
||||
return request({
|
||||
url: '/oa/arrivalDetail',
|
||||
method: 'put',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除到货明细
|
||||
export function delArrivalDetail(detailId) {
|
||||
return request({
|
||||
url: '/oa/arrivalDetail/' + detailId,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
21
ruoyi-ui/src/api/oa/meetingMinutes.js
Normal file
21
ruoyi-ui/src/api/oa/meetingMinutes.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listMeetingMinutes (query) {
|
||||
return request({ url: '/oa/meetingMinutes/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getMeetingMinutes (id) {
|
||||
return request({ url: '/oa/meetingMinutes/' + id, method: 'get' })
|
||||
}
|
||||
|
||||
export function addMeetingMinutes (data) {
|
||||
return request({ url: '/oa/meetingMinutes', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateMeetingMinutes (data) {
|
||||
return request({ url: '/oa/meetingMinutes', method: 'put', data })
|
||||
}
|
||||
|
||||
export function delMeetingMinutes (ids) {
|
||||
return request({ url: '/oa/meetingMinutes/' + ids, method: 'delete' })
|
||||
}
|
||||
@@ -65,6 +65,11 @@ body {
|
||||
.el-range-editor .el-range-separator { line-height: 24px !important; font-size: 12px !important; }
|
||||
.el-input__icon { line-height: 26px !important; }
|
||||
.el-input__suffix-inner .el-input__icon { line-height: 26px !important; }
|
||||
/* 上面的 padding: 0 8px !important 会盖掉 element 给带图标输入框预留的 30px,
|
||||
导致占位文字/内容压在前后缀图标上(如日期选择器、带搜索图标的输入框),这里补回图标位 */
|
||||
.el-input--prefix .el-input__inner { padding-left: 28px !important; }
|
||||
.el-input--suffix .el-input__inner { padding-right: 28px !important; }
|
||||
.el-input--mini .el-input__icon { line-height: 22px !important; }
|
||||
.el-form-item__content { line-height: 26px; }
|
||||
|
||||
/* 按钮:默认 26px 高,mini 22px,medium 28px */
|
||||
|
||||
@@ -160,6 +160,37 @@ export const constantRoutes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/hint",
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: "meeting/add",
|
||||
component: () => import("@/views/oa/meeting/edit"),
|
||||
name: "addMeetingMinutes",
|
||||
meta: { title: "新增会议纪要", activeMenu: "/hint/meeting" },
|
||||
},
|
||||
{
|
||||
path: "meeting/edit/:id(\\d+)",
|
||||
component: () => import("@/views/oa/meeting/edit"),
|
||||
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"),
|
||||
name: "aiReviewDetail",
|
||||
meta: { title: "审核详情", activeMenu: "/hint/aiReview" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/claim",
|
||||
component: Layout,
|
||||
|
||||
312
ruoyi-ui/src/views/oa/aiReview/add.vue
Normal file
312
ruoyi-ui/src/views/oa/aiReview/add.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<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,
|
||||
eventCount: 0,
|
||||
|
||||
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(), 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; }
|
||||
|
||||
.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>
|
||||
110
ruoyi-ui/src/views/oa/aiReview/detail.vue
Normal file
110
ruoyi-ui/src/views/oa/aiReview/detail.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="app-container ai-review-detail" v-loading="loading">
|
||||
<el-card shadow="never" class="panel">
|
||||
<div slot="header" class="hd">
|
||||
<div class="hd-left">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
|
||||
<span class="title"><i class="el-icon-document-checked" /> 审核详情</span>
|
||||
</div>
|
||||
<div v-if="info" class="hd-right">
|
||||
<el-tag size="small" :type="info.reviewType === 'contract' ? 'warning' : 'success'">
|
||||
{{ info.reviewType === 'contract' ? '合同审核' : '简历审核' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="info.reviewType === 'resume' && info.matchScore != null"
|
||||
size="small" type="success" effect="dark">匹配度 {{ info.matchScore }}</el-tag>
|
||||
<el-tag v-if="info.reviewType === 'contract' && info.riskLevel"
|
||||
size="small" :type="riskTagType(info.riskLevel)" effect="dark">{{ info.riskLevel }}风险</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions v-if="info" :column="3" size="small" border class="meta">
|
||||
<el-descriptions-item label="文件名">
|
||||
<span>{{ info.fileName }}</span>
|
||||
<el-button v-if="info.fileUrl" type="text" size="mini" icon="el-icon-download"
|
||||
style="margin-left:8px" @click="downloadFile">下载原件</el-button>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="info.reviewType === 'resume'" label="目标岗位">
|
||||
{{ info.position || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模型">{{ info.model || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核时间">{{ info.createTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核人">{{ info.createBy || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="info" class="md-body" v-html="renderedMd" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAiReview } from '@/api/oa/aiReview'
|
||||
const marked = require('marked')
|
||||
|
||||
export default {
|
||||
name: 'OaAiReviewDetail',
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
info: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderedMd () {
|
||||
if (!this.info || !this.info.resultMd) return ''
|
||||
try { return marked(this.info.resultMd) } catch (e) { return this.info.resultMd }
|
||||
}
|
||||
},
|
||||
created () {
|
||||
marked.setOptions({ breaks: true })
|
||||
const id = this.$route.params.id
|
||||
if (id) this.load(id)
|
||||
else this.loading = false
|
||||
},
|
||||
methods: {
|
||||
load (id) {
|
||||
this.loading = true
|
||||
getAiReview(id).then(res => { this.info = res.data })
|
||||
.finally(() => { this.loading = false })
|
||||
},
|
||||
goBack () {
|
||||
this.$router.push('/hint/aiReview')
|
||||
},
|
||||
downloadFile () {
|
||||
if (this.info && this.info.fileUrl) window.open(this.info.fileUrl, '_blank')
|
||||
},
|
||||
riskTagType (r) {
|
||||
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-review-detail { padding: 10px; }
|
||||
.panel { ::v-deep .el-card__header { padding: 10px 14px; } }
|
||||
.hd { display: flex; justify-content: space-between; align-items: center;
|
||||
.hd-left { display: flex; align-items: center; gap: 12px;
|
||||
.title { font-weight: 600; font-size: 14px; color: #303133; i { color: #409eff; margin-right: 4px; } }
|
||||
}
|
||||
.hd-right { display: flex; align-items: center; gap: 8px; }
|
||||
}
|
||||
.meta { margin-bottom: 16px; }
|
||||
|
||||
.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>
|
||||
136
ruoyi-ui/src/views/oa/aiReview/index.vue
Normal file
136
ruoyi-ui/src/views/oa/aiReview/index.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 搜索 -->
|
||||
<el-form :model="query" ref="queryForm" size="small" :inline="true" label-width="68px">
|
||||
<el-form-item label="类型" prop="reviewType">
|
||||
<el-select v-model="query.reviewType" placeholder="全部" clearable style="width: 120px" @change="handleQuery">
|
||||
<el-option label="合同" value="contract" />
|
||||
<el-option label="简历" value="resume" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键字" prop="keyword">
|
||||
<el-input v-model="query.keyword" placeholder="文件名 / 岗位" clearable style="width: 220px"
|
||||
@keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="primary" plain icon="el-icon-magic-stick" size="mini" @click="openUpload">新增审核</el-button>
|
||||
</el-col>
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table v-loading="loading" :data="list" @row-dblclick="goDetail">
|
||||
<el-table-column label="类型" align="center" width="80">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="scope.row.reviewType === 'contract' ? 'warning' : 'success'">
|
||||
{{ scope.row.reviewType === 'contract' ? '合同' : '简历' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件名" align="left" prop="fileName" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="岗位" align="center" prop="position" width="130" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.position">{{ scope.row.position }}</span>
|
||||
<span v-else style="color:#c0c4cc">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结论" align="center" width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.reviewType === 'resume' && scope.row.matchScore != null"
|
||||
size="mini" type="success" effect="dark">匹配 {{ scope.row.matchScore }}</el-tag>
|
||||
<el-tag v-else-if="scope.row.reviewType === 'contract' && scope.row.riskLevel"
|
||||
size="mini" :type="riskTagType(scope.row.riskLevel)" effect="dark">
|
||||
{{ scope.row.riskLevel }}风险</el-tag>
|
||||
<span v-else style="color:#c0c4cc">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核摘要" align="left" prop="summary" min-width="280" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="summary-cell">{{ scope.row.summary || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核时间" align="center" prop="createTime" width="150" />
|
||||
<el-table-column label="操作" align="center" width="130" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-view" @click="goDetail(scope.row)">详情</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" :total="total"
|
||||
:page.sync="query.pageNum" :limit.sync="query.pageSize" @pagination="getList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listAiReview, delAiReview } from '@/api/oa/aiReview'
|
||||
|
||||
export default {
|
||||
name: 'OaAiReview',
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
showSearch: true,
|
||||
total: 0,
|
||||
list: [],
|
||||
query: { pageNum: 1, pageSize: 10, keyword: '', reviewType: '' }
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.getList()
|
||||
},
|
||||
activated () {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
getList () {
|
||||
this.loading = true
|
||||
listAiReview(this.query).then(res => {
|
||||
this.list = res.rows || []
|
||||
this.total = res.total || 0
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
handleQuery () {
|
||||
this.query.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery () {
|
||||
this.resetForm('queryForm')
|
||||
this.query.reviewType = ''
|
||||
this.handleQuery()
|
||||
},
|
||||
goDetail (row) {
|
||||
this.$router.push('/hint/aiReview/detail/' + row.id)
|
||||
},
|
||||
handleDelete (row) {
|
||||
this.$modal.confirm(`确认删除「${row.fileName}」的审核记录?`).then(() => {
|
||||
return delAiReview(row.id)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('已删除')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
openUpload () {
|
||||
this.$router.push('/hint/aiReview/add')
|
||||
},
|
||||
|
||||
riskTagType (r) {
|
||||
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.summary-cell { color: #606266; font-size: 12px; }
|
||||
</style>
|
||||
464
ruoyi-ui/src/views/oa/arrivalDetail/index.vue
Normal file
464
ruoyi-ui/src/views/oa/arrivalDetail/index.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
||||
<el-form-item label="采购需求" prop="requirementId">
|
||||
<el-select v-model="queryParams.requirementId" placeholder="请选择采购需求, 输入关键字搜索" filterable remote
|
||||
:remote-method="loadRequirementOptions" :loading="requirementLoading" clearable style="width: 200px">
|
||||
<el-option v-for="r in requirementOptions" :key="r.requirementId" :label="r.title" :value="r.requirementId">
|
||||
<span style="float: left">{{ r.title }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 12px">{{ r.projectName || '无项目' }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联项目" prop="projectId">
|
||||
<project-select v-model="queryParams.projectId" style="width: 200px" @change="onQueryProjectChange" />
|
||||
</el-form-item>
|
||||
<el-form-item label="合同编号" prop="contractNo">
|
||||
<el-input v-model="queryParams.contractNo" placeholder="请输入合同编号" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="物料名称" prop="goodsName">
|
||||
<el-input v-model="queryParams.goodsName" placeholder="请输入物料名称" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="queryParams.description" placeholder="请输入描述" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内贸/外贸" prop="tradeType">
|
||||
<el-select v-model="queryParams.tradeType" placeholder="请选择" clearable style="width: 100px">
|
||||
<el-option label="内贸" :value="0" />
|
||||
<el-option label="外贸" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="发货地点" prop="sourceAddress">
|
||||
<el-input v-model="queryParams.sourceAddress" placeholder="请输入发货地点" clearable
|
||||
@keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规划目的地" prop="targetAddress">
|
||||
<el-input v-model="queryParams.targetAddress" placeholder="请输入规划目的地" clearable
|
||||
@keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single"
|
||||
@click="handleUpdate">修改</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple"
|
||||
@click="handleDelete">删除</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
|
||||
</el-col>
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="arrivalDetailList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="采购需求" align="center" min-width="160" show-overflow-tooltip>
|
||||
<template slot-scope="{ row }">
|
||||
<span>{{ (row.requirement && row.requirement.title) || row.requirementId }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联项目" align="center" min-width="160" show-overflow-tooltip>
|
||||
<template slot-scope="{ row }">
|
||||
<span>{{ (row.project && row.project.projectName) || row.projectId }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目类型" align="center" width="80">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag :type="row.tradeType === 0 ? '' : 'warning'" size="small">{{ row.tradeType === 0 ? '内贸' : '外贸'
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合同编号" align="center" prop="contractNo" />
|
||||
<el-table-column label="物料名称" align="center" prop="goodsName" />
|
||||
<el-table-column label="数量" align="center" prop="quantity" />
|
||||
<el-table-column label="单价" align="center" prop="unitPrice" />
|
||||
<el-table-column label="描述" align="center" prop="description" />
|
||||
<el-table-column label="到货类型" align="center" prop="arrivalType" width="80">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag :type="row.arrivalType === 0 ? 'success' : 'warning'" size="small">{{ row.arrivalType === 0 ? '收' :
|
||||
'发' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到货截止日期" align="center" prop="deadline" width="180">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.deadline, '{y}-{m}-{d}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货地点" align="center" prop="sourceAddress" />
|
||||
<el-table-column label="规划目的地" align="center" prop="targetAddress" />
|
||||
<el-table-column label="状态" align="center" prop="detailStatus" width="140">
|
||||
<template slot-scope="{ row }">
|
||||
<el-select v-model="row.detailStatus" size="mini" placeholder="选择状态"
|
||||
@change="val => onDetailStatusChange(row, val)">
|
||||
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-truck" style="color:#409eff"
|
||||
v-if="scope.row.arrivalType === 0" @click="handleShip(scope.row)">发货</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
||||
@pagination="getList" />
|
||||
|
||||
<!-- 添加或修改到货明细对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="采购需求" prop="requirementId">
|
||||
<el-select v-model="form.requirementId" placeholder="请选择采购需求, 输入关键字搜索" filterable remote
|
||||
:remote-method="loadRequirementOptions" :loading="requirementLoading" clearable style="width: 100%"
|
||||
@change="onRequirementChange">
|
||||
<el-option v-for="r in requirementOptions" :key="r.requirementId" :label="r.title" :value="r.requirementId">
|
||||
<span style="float: left">{{ r.title }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 12px">{{ r.projectName || '无项目' }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联项目" prop="projectId">
|
||||
<project-select v-model="form.projectId" style="width: 100%" @change="onFormProjectChange" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内贸/外贸" prop="tradeType">
|
||||
<el-select v-model="form.tradeType" placeholder="请选择" style="width: 100%">
|
||||
<el-option label="内贸" :value="0" />
|
||||
<el-option label="外贸" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="合同编号" prop="contractNo">
|
||||
<el-input v-model="form.contractNo" placeholder="请输入合同编号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="物料名称" prop="goodsName">
|
||||
<el-input v-model="form.goodsName" placeholder="请输入物料名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数量" prop="quantity">
|
||||
<el-input v-model="form.quantity" placeholder="请输入数量" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单价" prop="unitPrice">
|
||||
<el-input v-model="form.unitPrice" placeholder="请输入单价" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" placeholder="请输入内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="到货截止日期" prop="deadline">
|
||||
<el-date-picker clearable v-model="form.deadline" type="datetime" value-format="yyyy-MM-dd HH:mm:ss"
|
||||
placeholder="请选择到货截止日期">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="发货地点" prop="sourceAddress">
|
||||
<el-input v-model="form.sourceAddress" type="textarea" placeholder="请输入内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规划目的地" prop="targetAddress" v-if="form.detailId">
|
||||
<el-input v-model="form.targetAddress" type="textarea" placeholder="请输入内容" />
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 发货对话框 -->
|
||||
<el-dialog :title="'发货 - ' + shipRow.goodsName" :visible.sync="shipOpen" width="400px" append-to-body>
|
||||
<el-form ref="shipForm" :model="shipForm" :rules="shipRules" label-width="100px">
|
||||
<el-form-item label="规划目的地" prop="targetAddress">
|
||||
<el-input v-model="shipForm.targetAddress" type="textarea" placeholder="请输入规划目的地" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="submitShip">确 定</el-button>
|
||||
<el-button @click="shipOpen = false">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listArrivalDetail, getArrivalDetail, delArrivalDetail, addArrivalDetail, updateArrivalDetail } from "@/api/oa/arrivalDetail";
|
||||
import { listRequirements } from "@/api/oa/requirement";
|
||||
import ProjectSelect from "@/components/fad-service/ProjectSelect";
|
||||
|
||||
export default {
|
||||
name: "ArrivalDetail",
|
||||
components: { ProjectSelect },
|
||||
data() {
|
||||
return {
|
||||
// 按钮loading
|
||||
buttonLoading: false,
|
||||
// 遮罩层
|
||||
loading: true,
|
||||
// 选中数组
|
||||
ids: [],
|
||||
// 非单个禁用
|
||||
single: true,
|
||||
// 非多个禁用
|
||||
multiple: true,
|
||||
// 显示搜索条件
|
||||
showSearch: true,
|
||||
// 总条数
|
||||
total: 0,
|
||||
// 到货明细表格数据
|
||||
arrivalDetailList: [],
|
||||
// 弹出层标题
|
||||
title: "",
|
||||
// 是否显示弹出层
|
||||
open: false,
|
||||
// 发货弹窗
|
||||
shipOpen: false,
|
||||
shipRow: {},
|
||||
shipForm: {},
|
||||
shipRules: {
|
||||
targetAddress: [
|
||||
{ required: true, message: "规划目的地不能为空", trigger: "blur" }
|
||||
]
|
||||
},
|
||||
// 需求下拉选项
|
||||
requirementOptions: [],
|
||||
requirementLoading: false,
|
||||
// 状态选项
|
||||
statusOptions: [
|
||||
{ value: 0, label: '待发货' },
|
||||
{ value: 1, label: '运输中' },
|
||||
{ value: 2, label: '已到货' },
|
||||
{ value: 3, label: '异常/拒收' },
|
||||
{ value: 4, label: '取消' },
|
||||
],
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
requirementId: undefined,
|
||||
projectId: undefined,
|
||||
tradeType: undefined,
|
||||
contractNo: undefined,
|
||||
goodsName: undefined,
|
||||
quantity: undefined,
|
||||
unitPrice: undefined,
|
||||
arrivalType: undefined,
|
||||
deadline: undefined,
|
||||
sourceAddress: undefined,
|
||||
targetAddress: undefined,
|
||||
detailStatus: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
// 表单参数
|
||||
form: {},
|
||||
// 表单校验
|
||||
rules: {}
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getList();
|
||||
this.loadRequirementOptions();
|
||||
},
|
||||
methods: {
|
||||
/** 查询到货明细列表 */
|
||||
getList() {
|
||||
this.loading = true;
|
||||
listArrivalDetail(this.queryParams).then(response => {
|
||||
this.arrivalDetailList = response.rows;
|
||||
this.total = response.total;
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
/** 远程搜索采购需求 */
|
||||
loadRequirementOptions(keyword) {
|
||||
this.requirementLoading = true;
|
||||
listRequirements({ pageNum: 1, pageSize: 20, title: keyword || undefined }).then(res => {
|
||||
this.requirementOptions = res.rows || [];
|
||||
}).finally(() => {
|
||||
this.requirementLoading = false;
|
||||
});
|
||||
},
|
||||
/** 选中需求后自动填充关联项目 */
|
||||
onRequirementChange(requirementId) {
|
||||
if (!requirementId) return;
|
||||
const r = this.requirementOptions.find(x => x.requirementId === requirementId);
|
||||
if (r && r.projectId && !this.form.projectId) {
|
||||
this.form.projectId = r.projectId;
|
||||
}
|
||||
},
|
||||
/** 搜索表单选择项目后自动填充内外贸类型 */
|
||||
onQueryProjectChange(val, selectedProject) {
|
||||
if (selectedProject) {
|
||||
this.queryParams.tradeType = selectedProject.tradeType;
|
||||
} else {
|
||||
this.queryParams.tradeType = undefined;
|
||||
}
|
||||
},
|
||||
/** 弹窗表单选择项目后自动填充内外贸类型 */
|
||||
onFormProjectChange(val, selectedProject) {
|
||||
if (selectedProject) {
|
||||
this.form.tradeType = selectedProject.tradeType;
|
||||
} else {
|
||||
this.form.tradeType = undefined;
|
||||
}
|
||||
},
|
||||
// 取消按钮
|
||||
cancel() {
|
||||
this.open = false;
|
||||
this.reset();
|
||||
},
|
||||
// 表单重置
|
||||
reset() {
|
||||
this.form = {
|
||||
detailId: undefined,
|
||||
requirementId: undefined,
|
||||
projectId: undefined,
|
||||
tradeType: undefined,
|
||||
contractNo: undefined,
|
||||
goodsName: undefined,
|
||||
quantity: undefined,
|
||||
unitPrice: undefined,
|
||||
arrivalType: undefined,
|
||||
deadline: undefined,
|
||||
sourceAddress: undefined,
|
||||
targetAddress: undefined,
|
||||
detailStatus: undefined,
|
||||
description: undefined,
|
||||
remark: undefined,
|
||||
createBy: undefined,
|
||||
createTime: undefined,
|
||||
updateBy: undefined,
|
||||
updateTime: undefined,
|
||||
delFlag: undefined
|
||||
};
|
||||
this.resetForm("form");
|
||||
},
|
||||
/** 搜索按钮操作 */
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1;
|
||||
this.getList();
|
||||
},
|
||||
/** 重置按钮操作 */
|
||||
resetQuery() {
|
||||
this.resetForm("queryForm");
|
||||
this.handleQuery();
|
||||
},
|
||||
// 多选框选中数据
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map(item => item.detailId)
|
||||
this.single = selection.length !== 1
|
||||
this.multiple = !selection.length
|
||||
},
|
||||
/** 新增按钮操作 */
|
||||
handleAdd() {
|
||||
this.reset();
|
||||
this.form.arrivalType = 0;
|
||||
this.form.detailStatus = 0;
|
||||
this.open = true;
|
||||
this.title = "添加到货明细";
|
||||
},
|
||||
/** 修改按钮操作 */
|
||||
handleUpdate(row) {
|
||||
this.loading = true;
|
||||
this.reset();
|
||||
const detailId = row.detailId || this.ids
|
||||
getArrivalDetail(detailId).then(response => {
|
||||
this.loading = false;
|
||||
this.form = response.data;
|
||||
this.open = true;
|
||||
this.title = "修改到货明细";
|
||||
});
|
||||
},
|
||||
/** 提交按钮 */
|
||||
submitForm() {
|
||||
this.$refs["form"].validate(valid => {
|
||||
if (valid) {
|
||||
this.buttonLoading = true;
|
||||
if (this.form.detailId != null) {
|
||||
updateArrivalDetail(this.form).then(response => {
|
||||
this.$modal.msgSuccess("修改成功");
|
||||
this.open = false;
|
||||
this.getList();
|
||||
}).finally(() => {
|
||||
this.buttonLoading = false;
|
||||
});
|
||||
} else {
|
||||
addArrivalDetail(this.form).then(response => {
|
||||
this.$modal.msgSuccess("新增成功");
|
||||
this.open = false;
|
||||
this.getList();
|
||||
}).finally(() => {
|
||||
this.buttonLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
/** 删除按钮操作 */
|
||||
handleDelete(row) {
|
||||
const detailIds = row.detailId || this.ids;
|
||||
this.$modal.confirm('是否确认删除到货明细编号为"' + detailIds + '"的数据项?').then(() => {
|
||||
this.loading = true;
|
||||
return delArrivalDetail(detailIds);
|
||||
}).then(() => {
|
||||
this.loading = false;
|
||||
this.getList();
|
||||
this.$modal.msgSuccess("删除成功");
|
||||
}).catch(() => {
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
/** 表格内状态快捷修改 */
|
||||
onDetailStatusChange(row, newVal) {
|
||||
row.detailStatus = Number(newVal);
|
||||
updateArrivalDetail(row).then(() => {
|
||||
this.$message.success(`状态已更新为「${this.statusLabel(newVal)}」`);
|
||||
});
|
||||
},
|
||||
statusLabel(val) {
|
||||
const m = { 0: '待发货', 1: '运输中', 2: '已到货', 3: '异常/拒收', 4: '取消' };
|
||||
return m[val] ?? String(val);
|
||||
},
|
||||
/** 发货按钮操作 */
|
||||
handleShip(row) {
|
||||
this.shipRow = row;
|
||||
this.shipForm = { targetAddress: row.targetAddress || '' };
|
||||
this.shipOpen = true;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.shipForm) this.$refs.shipForm.clearValidate();
|
||||
});
|
||||
},
|
||||
/** 提交发货 */
|
||||
submitShip() {
|
||||
this.$refs["shipForm"].validate(valid => {
|
||||
if (valid) {
|
||||
const row = this.shipRow;
|
||||
row.arrivalType = 1;
|
||||
row.targetAddress = this.shipForm.targetAddress;
|
||||
updateArrivalDetail(row).then(() => {
|
||||
this.$modal.msgSuccess("发货成功");
|
||||
this.shipOpen = false;
|
||||
this.getList();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
/** 导出按钮操作 */
|
||||
handleExport() {
|
||||
this.download('oa/arrivalDetail/export', {
|
||||
...this.queryParams
|
||||
}, `arrivalDetail_${new Date().getTime()}.xlsx`)
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
468
ruoyi-ui/src/views/oa/meeting/edit.vue
Normal file
468
ruoyi-ui/src/views/oa/meeting/edit.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<template>
|
||||
<div class="app-container meeting-edit">
|
||||
<!-- 顶部操作栏 -->
|
||||
<el-card shadow="never" class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
|
||||
<span class="brand-title">{{ isEdit ? '编辑会议纪要' : '新增会议纪要' }}</span>
|
||||
<span v-if="form.meetingCode" class="hd-code">{{ form.meetingCode }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-checkbox v-model="form.syncTask" :true-label="1" :false-label="0" class="sync-chk">
|
||||
<span style="font-size:12px">保存时同步生成 OA 任务</span>
|
||||
</el-checkbox>
|
||||
<el-button size="small" type="primary" icon="el-icon-document-checked"
|
||||
:loading="saving" @click="cmdSave">保存</el-button>
|
||||
<el-button size="small" icon="el-icon-download" @click="cmdExport">导出</el-button>
|
||||
<el-button size="small" icon="el-icon-printer" @click="cmdPrint">打印</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="editor-card" v-loading="loadingDetail">
|
||||
<!-- 元数据 -->
|
||||
<el-form :model="form" label-width="80px" size="small" class="meta-form">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="会议日期" required>
|
||||
<el-date-picker v-model="form.meetingDate" type="date" value-format="yyyy-MM-dd"
|
||||
style="width:100%" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="项目">
|
||||
<project-select v-model="form.projectId" placeholder="不选则为非项目会议" clearable style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="会议类型">
|
||||
<el-select v-model="form.meetingType" style="width:100%">
|
||||
<el-option v-for="t in dict.type.oa_meeting_type" :key="t.value" :value="t.value" :label="t.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-form-item label="会议主题" required>
|
||||
<el-input v-model="form.subject" maxlength="200" placeholder="输入会议主题" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="会议地点">
|
||||
<el-input v-model="form.location" maxlength="100" placeholder="会议室 / 线上" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="主持人">
|
||||
<el-tag v-if="form.hostUserId" closable @close="clearHost">
|
||||
{{ form.hostUserName || ('#' + form.hostUserId) }}
|
||||
</el-tag>
|
||||
<el-button type="text" @click="pickHost">{{ form.hostUserId ? '更换' : '点击选择' }}</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-form-item label="参会人员">
|
||||
<user-select v-model="form.attendeeUserIds" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<!-- 4 结构化区块 -->
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd"><span class="sec-num">一</span> 会议议题</div>
|
||||
<el-input type="textarea" :rows="4" v-model="form.topic" placeholder="1. 2. 3." />
|
||||
</div>
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd"><span class="sec-num">二</span> 讨论内容</div>
|
||||
<el-input type="textarea" :rows="5" v-model="form.discussion" placeholder="记录讨论要点和各方意见..." />
|
||||
</div>
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd"><span class="sec-num">三</span> 决议事项</div>
|
||||
<el-input type="textarea" :rows="4" v-model="form.decision" placeholder="记录会议达成的决议和结论..." />
|
||||
</div>
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd">
|
||||
<span class="sec-num">四</span> 待办事项
|
||||
<span class="sec-tip">填了负责人和内容的待办,保存时按上方开关同步为 OA 任务并通知负责人</span>
|
||||
<el-button type="text" icon="el-icon-plus" class="add-task" @click="addTask">添加待办</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.tasks.length === 0" class="task-empty">尚未添加待办</div>
|
||||
<div v-for="(t, i) in form.tasks" :key="i" class="task-row">
|
||||
<div class="task-line">
|
||||
<div class="tf tf-assignee">
|
||||
<label>负责人</label>
|
||||
<div>
|
||||
<el-tag v-if="t.assigneeUserId" size="small" closable @close="clearAssignee(t)">
|
||||
{{ t.assigneeName || ('#' + t.assigneeUserId) }}
|
||||
</el-tag>
|
||||
<el-button type="text" size="mini" @click="pickAssignee(i)">
|
||||
{{ t.assigneeUserId ? '更换' : '选择' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tf tf-content">
|
||||
<label>任务内容</label>
|
||||
<el-input v-model="t.content" size="mini" maxlength="200" placeholder="任务描述..." />
|
||||
</div>
|
||||
<div class="tf tf-deadline">
|
||||
<label>截止日期</label>
|
||||
<el-date-picker v-model="t.deadline" type="date" size="mini"
|
||||
value-format="yyyy-MM-dd" style="width:100%" placeholder="日期" />
|
||||
</div>
|
||||
<div class="tf tf-status">
|
||||
<label>状态</label>
|
||||
<el-select v-model="t.status" size="mini" style="width:100%">
|
||||
<el-option v-for="o in dict.type.oa_meeting_task_status" :key="o.value"
|
||||
:value="o.value" :label="o.label" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="tf tf-act">
|
||||
<el-tag v-if="t.taskId" size="mini" type="success">已同步</el-tag>
|
||||
<el-button type="text" icon="el-icon-delete" style="color:#f56c6c"
|
||||
@click="removeTask(i)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 人员单选弹窗(主持人 / 待办负责人共用) -->
|
||||
<user-single-select v-model="userPickerVisible" @onSelected="onUserPicked" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getMeetingMinutes, addMeetingMinutes, updateMeetingMinutes
|
||||
} from '@/api/oa/meetingMinutes'
|
||||
import UserSelect from '@/components/UserSelect'
|
||||
import UserSingleSelect from '@/components/UserSelect/single'
|
||||
import ProjectSelect from '@/components/fad-service/ProjectSelect'
|
||||
|
||||
function localToday () {
|
||||
const d = new Date()
|
||||
const p = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||
}
|
||||
|
||||
function emptyForm () {
|
||||
return {
|
||||
id: null,
|
||||
meetingCode: '',
|
||||
meetingDate: localToday(),
|
||||
projectId: null,
|
||||
meetingType: 'other',
|
||||
subject: '',
|
||||
location: '',
|
||||
hostUserId: null,
|
||||
hostUserName: '',
|
||||
attendeeUserIds: '',
|
||||
attendeeUserNames: '',
|
||||
topic: '',
|
||||
discussion: '',
|
||||
decision: '',
|
||||
tasks: [],
|
||||
syncTask: 1
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'OaMeetingEdit',
|
||||
components: { UserSelect, UserSingleSelect, ProjectSelect },
|
||||
dicts: ['oa_meeting_type', 'oa_meeting_task_status'],
|
||||
data () {
|
||||
return {
|
||||
saving: false,
|
||||
loadingDetail: false,
|
||||
form: emptyForm(),
|
||||
|
||||
// 人员单选弹窗当前服务对象:'host' 或待办行下标
|
||||
userPickerVisible: false,
|
||||
userPickerTarget: 'host'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isEdit () { return !!this.form.id }
|
||||
},
|
||||
created () {
|
||||
const id = this.$route.params.id
|
||||
if (id) this.loadMinutes(id)
|
||||
},
|
||||
watch: {
|
||||
// 新增/编辑两个路由共用本组件,路由切换时组件实例可能被复用,created 不会重新触发
|
||||
'$route' (to) {
|
||||
if (to.name === 'addMeetingMinutes') {
|
||||
this.form = emptyForm()
|
||||
} else if (to.name === 'editMeetingMinutes' && to.params.id
|
||||
&& String(this.form.id) !== String(to.params.id)) {
|
||||
this.loadMinutes(to.params.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goBack () {
|
||||
this.$router.push('/hint/meeting')
|
||||
},
|
||||
|
||||
// ============ 人员选择 ============
|
||||
pickHost () {
|
||||
this.userPickerTarget = 'host'
|
||||
this.userPickerVisible = true
|
||||
},
|
||||
clearHost () {
|
||||
this.form.hostUserId = null
|
||||
this.form.hostUserName = ''
|
||||
},
|
||||
pickAssignee (i) {
|
||||
this.userPickerTarget = i
|
||||
this.userPickerVisible = true
|
||||
},
|
||||
clearAssignee (t) {
|
||||
t.assigneeUserId = null
|
||||
t.assigneeName = ''
|
||||
},
|
||||
onUserPicked (row) {
|
||||
if (!row) return
|
||||
if (this.userPickerTarget === 'host') {
|
||||
this.form.hostUserId = row.userId
|
||||
this.form.hostUserName = row.nickName
|
||||
} else {
|
||||
const t = this.form.tasks[this.userPickerTarget]
|
||||
if (t) {
|
||||
t.assigneeUserId = row.userId
|
||||
t.assigneeName = row.nickName
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============ 待办 ============
|
||||
addTask () {
|
||||
this.form.tasks.push({
|
||||
assigneeUserId: null, assigneeName: '',
|
||||
content: '', deadline: '', status: 'pending', taskId: null
|
||||
})
|
||||
},
|
||||
removeTask (i) {
|
||||
const t = this.form.tasks[i]
|
||||
if (t.taskId) {
|
||||
this.$modal.confirm('该待办已同步为 OA 任务,移除后任务本身不会删除,仅与纪要解除关联。继续?')
|
||||
.then(() => this.form.tasks.splice(i, 1))
|
||||
.catch(() => {})
|
||||
} else {
|
||||
this.form.tasks.splice(i, 1)
|
||||
}
|
||||
},
|
||||
|
||||
// ============ 保存 ============
|
||||
async cmdSave () {
|
||||
if (!this.form.meetingDate) return this.$modal.msgError('请选择会议日期')
|
||||
if (!this.form.subject) return this.$modal.msgError('请输入会议主题')
|
||||
const cleanTasks = (this.form.tasks || []).map(t => ({
|
||||
assigneeUserId: t.assigneeUserId,
|
||||
assigneeName: t.assigneeName,
|
||||
content: t.content,
|
||||
deadline: t.deadline,
|
||||
status: t.status,
|
||||
taskId: t.taskId || null
|
||||
}))
|
||||
const payload = { ...this.form, tasksJson: JSON.stringify(cleanTasks) }
|
||||
delete payload.tasks
|
||||
this.saving = true
|
||||
try {
|
||||
let id = this.form.id
|
||||
if (id) {
|
||||
await updateMeetingMinutes(payload)
|
||||
} else {
|
||||
const res = await addMeetingMinutes(payload)
|
||||
id = res.data
|
||||
// 切到编辑路由,刷新/再保存都是更新而不是再次新增(先置 id 避免路由 watch 重复加载)
|
||||
if (id) {
|
||||
this.form.id = id
|
||||
this.$router.replace('/hint/meeting/edit/' + id)
|
||||
}
|
||||
}
|
||||
this.$modal.msgSuccess('纪要已保存' + (this.form.syncTask ? ',待办已同步到 OA 任务' : ''))
|
||||
if (id) await this.loadMinutes(id)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// ============ 加载 ============
|
||||
async loadMinutes (id) {
|
||||
this.loadingDetail = true
|
||||
try {
|
||||
const res = await getMeetingMinutes(id)
|
||||
const m = res.data
|
||||
if (!m) return
|
||||
this.form = {
|
||||
id: m.id,
|
||||
meetingCode: m.meetingCode,
|
||||
meetingDate: m.meetingDate,
|
||||
projectId: m.projectId,
|
||||
meetingType: m.meetingType || 'other',
|
||||
subject: m.subject || '',
|
||||
location: m.location || '',
|
||||
hostUserId: m.hostUserId || null,
|
||||
hostUserName: m.hostUserName || '',
|
||||
attendeeUserIds: m.attendeeUserIds || '',
|
||||
attendeeUserNames: m.attendeeUserNames || '',
|
||||
topic: m.topic || '',
|
||||
discussion: m.discussion || '',
|
||||
decision: m.decision || '',
|
||||
tasks: this.parseTasks(m.tasksJson),
|
||||
syncTask: m.syncTask == null ? 1 : m.syncTask
|
||||
}
|
||||
} finally {
|
||||
this.loadingDetail = false
|
||||
}
|
||||
},
|
||||
parseTasks (s) {
|
||||
if (!s) return []
|
||||
try {
|
||||
const a = JSON.parse(s)
|
||||
if (!Array.isArray(a)) return []
|
||||
return a.map(t => ({
|
||||
assigneeUserId: t.assigneeUserId || null,
|
||||
assigneeName: t.assigneeName || '',
|
||||
content: t.content || '',
|
||||
deadline: t.deadline || '',
|
||||
status: t.status || 'pending',
|
||||
taskId: t.taskId || null
|
||||
}))
|
||||
} catch (e) { return [] }
|
||||
},
|
||||
|
||||
dictLabel (dictKey, v) {
|
||||
const hit = (this.dict.type[dictKey] || []).find(t => t.value === v)
|
||||
return hit ? hit.label : (v || '-')
|
||||
},
|
||||
|
||||
// ============ 导出 / 打印 ============
|
||||
cmdExport () {
|
||||
if (!this.form.subject) return this.$modal.msgError('无内容可导出')
|
||||
const d = this.form
|
||||
const lines = []
|
||||
lines.push('德睿福成套设备有限公司 · 会议纪要')
|
||||
lines.push('='.repeat(50))
|
||||
lines.push('编号: ' + (d.meetingCode || '-'))
|
||||
lines.push('日期: ' + d.meetingDate + ' 类型: ' + this.dictLabel('oa_meeting_type', d.meetingType))
|
||||
lines.push('主题: ' + d.subject)
|
||||
lines.push('地点: ' + (d.location || '-'))
|
||||
lines.push('主持: ' + (d.hostUserName || '-'))
|
||||
lines.push('参会: ' + (d.attendeeUserNames || '-'))
|
||||
lines.push('='.repeat(50))
|
||||
lines.push('')
|
||||
lines.push('一、会议议题'); lines.push('-'.repeat(30)); lines.push(d.topic || '(无)'); lines.push('')
|
||||
lines.push('二、讨论内容'); lines.push('-'.repeat(30)); lines.push(d.discussion || '(无)'); lines.push('')
|
||||
lines.push('三、决议事项'); lines.push('-'.repeat(30)); lines.push(d.decision || '(无)'); lines.push('')
|
||||
lines.push('四、待办事项'); lines.push('-'.repeat(30))
|
||||
if (d.tasks && d.tasks.length) {
|
||||
d.tasks.forEach(t => {
|
||||
lines.push(' • [' + (t.assigneeName || '未指派') + '] ' + (t.content || '') +
|
||||
' | 截止:' + (t.deadline || '-') + ' | 状态:' + this.dictLabel('oa_meeting_task_status', t.status))
|
||||
})
|
||||
} else { lines.push('(无)') }
|
||||
lines.push(''); lines.push('='.repeat(50))
|
||||
lines.push('导出时间: ' + new Date().toLocaleString('zh-CN'))
|
||||
const blob = new Blob(['' + lines.join('\n')], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = '会议纪要_' + d.meetingDate + '_' + d.subject + '.txt'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
this.$modal.msgSuccess('已导出')
|
||||
},
|
||||
cmdPrint () {
|
||||
if (!this.form.subject) return this.$modal.msgError('无内容')
|
||||
const d = this.form
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
||||
let taskHtml = '(无)'
|
||||
if (d.tasks && d.tasks.length) {
|
||||
const rows = d.tasks.map(t =>
|
||||
`<tr><td>${esc(t.assigneeName || '未指派')}</td><td>${esc(t.content)}</td>` +
|
||||
`<td>${esc(t.deadline || '-')}</td><td>${esc(this.dictLabel('oa_meeting_task_status', t.status))}</td></tr>`
|
||||
).join('')
|
||||
taskHtml = `<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%">
|
||||
<tr style="background:#eee"><th>负责人</th><th>任务内容</th><th>截止</th><th>状态</th></tr>${rows}</table>`
|
||||
}
|
||||
const w = window.open('', '', 'width=800,height=700')
|
||||
w.document.write(
|
||||
`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>会议纪要</title>
|
||||
<style>body{font-family:"Microsoft YaHei",sans-serif;padding:40px;max-width:760px;margin:0 auto;line-height:1.8}
|
||||
h1{text-align:center;font-size:20px}.sub{text-align:center;color:#888;margin-bottom:20px}
|
||||
.meta{font-size:13px;margin-bottom:18px;border-bottom:1px solid #ddd;padding-bottom:10px}
|
||||
.meta span{margin-right:18px}.sect{font-size:14px;margin:14px 0 6px;font-weight:700}
|
||||
.body{white-space:pre-wrap;font-size:13px;margin-bottom:16px}
|
||||
@media print{body{padding:20px}}</style></head><body>
|
||||
<h1>德睿福成套设备有限公司</h1><div class="sub">会 议 纪 要 ${esc(d.meetingCode || '')}</div>
|
||||
<div class="meta"><span>📅 ${esc(d.meetingDate)}</span>
|
||||
<span>📝 ${esc(d.subject)}</span>
|
||||
<span>🏷 ${esc(this.dictLabel('oa_meeting_type', d.meetingType))}</span></div>
|
||||
<div class="meta"><span>📍 ${esc(d.location || '-')}</span>
|
||||
<span>🎤 主持:${esc(d.hostUserName || '-')}</span></div>
|
||||
<div class="meta"><span>👥 参会:${esc(d.attendeeUserNames || '-')}</span></div>
|
||||
<div class="sect">一、会议议题</div><div class="body">${esc(d.topic || '(无)')}</div>
|
||||
<div class="sect">二、讨论内容</div><div class="body">${esc(d.discussion || '(无)')}</div>
|
||||
<div class="sect">三、决议事项</div><div class="body">${esc(d.decision || '(无)')}</div>
|
||||
<div class="sect">四、待办事项</div><div class="body">${taskHtml}</div>
|
||||
</body></html>`
|
||||
)
|
||||
w.document.close()
|
||||
setTimeout(() => w.print(), 400)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.meeting-edit { padding: 8px; }
|
||||
.topbar { margin-bottom: 8px; ::v-deep .el-card__body { padding: 10px 14px; } }
|
||||
.topbar-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px;
|
||||
.brand-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.hd-code { font-family: monospace; font-size: 12px; color: #909399; }
|
||||
}
|
||||
.actions { display: flex; align-items: center; gap: 6px; }
|
||||
.sync-chk { margin-right: 6px; }
|
||||
|
||||
.editor-card { ::v-deep .el-card__body { padding: 12px 16px; } }
|
||||
|
||||
.meta-form {
|
||||
::v-deep .el-form-item { margin-bottom: 8px; }
|
||||
}
|
||||
|
||||
.sec-block { margin-top: 10px; }
|
||||
.sec-hd {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; background: #f4f7fc; border-radius: 4px 4px 0 0;
|
||||
font-size: 13px; font-weight: 600; color: #409eff;
|
||||
.sec-num { display: inline-block; min-width: 18px; height: 18px; line-height: 18px;
|
||||
text-align: center; background: #409eff; color: #fff; border-radius: 3px; font-size: 11px; }
|
||||
.sec-tip { color: #909399; font-weight: normal; font-size: 11px; margin-left: 8px; }
|
||||
.add-task { margin-left: auto; }
|
||||
}
|
||||
|
||||
.task-empty {
|
||||
border: 1px dashed #dcdfe6; border-top: none;
|
||||
padding: 14px; text-align: center; color: #c0c4cc; font-size: 12px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.task-row {
|
||||
border: 1px solid #ebeef5; border-top: none; padding: 8px 10px;
|
||||
background: #fff;
|
||||
&:last-child { border-radius: 0 0 4px 4px; }
|
||||
}
|
||||
.task-line {
|
||||
display: grid;
|
||||
grid-template-columns: 170px 1fr 140px 110px 100px;
|
||||
gap: 8px; align-items: start;
|
||||
.tf {
|
||||
label { display: block; font-size: 11px; color: #909399; margin-bottom: 2px; }
|
||||
&.tf-act { display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding-top: 16px; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
ruoyi-ui/src/views/oa/meeting/index.vue
Normal file
157
ruoyi-ui/src/views/oa/meeting/index.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 搜索 -->
|
||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
||||
<el-form-item label="关键字" prop="keyword">
|
||||
<el-input v-model="queryParams.keyword" placeholder="编号 / 主题 / 地点" clearable
|
||||
style="width: 200px" @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目" prop="projectId">
|
||||
<project-select v-model="queryParams.projectId" placeholder="选择项目" clearable style="width: 280px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议类型" prop="meetingType">
|
||||
<el-select v-model="queryParams.meetingType" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="t in dict.type.oa_meeting_type" :key="t.value" :value="t.value" :label="t.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="会议日期">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="-"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" style="width: 240px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
|
||||
</el-col>
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table v-loading="loading" :data="list" @row-dblclick="handleEdit">
|
||||
<el-table-column label="会议编号" align="center" prop="meetingCode" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="会议日期" align="center" prop="meetingDate" width="100" />
|
||||
<el-table-column label="类型" align="center" width="90">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="dict.type.oa_meeting_type" :value="scope.row.meetingType" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会议主题" align="left" prop="subject" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="项目" align="center" prop="projectName" min-width="140" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.projectName">{{ scope.row.projectName }}</span>
|
||||
<span v-else style="color:#c0c4cc">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主持人" align="center" prop="hostUserName" width="90" />
|
||||
<el-table-column label="地点" align="center" prop="location" width="110" show-overflow-tooltip />
|
||||
<el-table-column label="待办" align="center" width="90">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="taskCount(scope.row)">
|
||||
{{ syncedCount(scope.row) }}/{{ taskCount(scope.row) }} 已同步
|
||||
</span>
|
||||
<span v-else style="color:#c0c4cc">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="140" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" :total="total"
|
||||
:page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listMeetingMinutes, delMeetingMinutes } from '@/api/oa/meetingMinutes'
|
||||
import ProjectSelect from '@/components/fad-service/ProjectSelect'
|
||||
|
||||
export default {
|
||||
name: 'OaMeeting',
|
||||
components: { ProjectSelect },
|
||||
dicts: ['oa_meeting_type'],
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
showSearch: true,
|
||||
total: 0,
|
||||
list: [],
|
||||
dateRange: [],
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
meetingType: '',
|
||||
projectId: null
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.getList()
|
||||
},
|
||||
activated () {
|
||||
// 从编辑页返回时刷新
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
getList () {
|
||||
this.loading = true
|
||||
const q = { ...this.queryParams }
|
||||
if (this.dateRange && this.dateRange.length === 2) {
|
||||
q.dateFrom = this.dateRange[0]
|
||||
q.dateTo = this.dateRange[1]
|
||||
}
|
||||
listMeetingMinutes(q).then(res => {
|
||||
this.list = res.rows || []
|
||||
this.total = res.total || 0
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
handleQuery () {
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery () {
|
||||
this.dateRange = []
|
||||
this.queryParams.projectId = null
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
taskCount (row) {
|
||||
return this.parseTasks(row).length
|
||||
},
|
||||
syncedCount (row) {
|
||||
return this.parseTasks(row).filter(t => t.taskId).length
|
||||
},
|
||||
parseTasks (row) {
|
||||
if (!row.tasksJson) return []
|
||||
try {
|
||||
const a = JSON.parse(row.tasksJson)
|
||||
return Array.isArray(a) ? a : []
|
||||
} catch (e) { return [] }
|
||||
},
|
||||
handleAdd () {
|
||||
this.$router.push('/hint/meeting/add')
|
||||
},
|
||||
handleEdit (row) {
|
||||
this.$router.push('/hint/meeting/edit/' + row.id)
|
||||
},
|
||||
handleDelete (row) {
|
||||
this.$modal.confirm(`确认删除「${row.subject}」?已同步的 OA 任务不受影响。`).then(() => {
|
||||
return delMeetingMinutes(row.id)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('已删除')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
61
sql/oa_ai_review.sql
Normal file
61
sql/oa_ai_review.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- =====================================================
|
||||
-- AI 智能审核(合同 / 简历)
|
||||
-- - 使用小米 MiMo 多模态大模型(mimo-v2.5)
|
||||
-- - 合同:站在“我方”立场审查,找出不利条款 + 利好我方的修改建议
|
||||
-- - 简历:评估候选人与目标岗位匹配度
|
||||
-- 本脚本可重复执行(幂等)。
|
||||
-- 注意:sys_menu 主键为雪花ID(非自增),必须显式指定。
|
||||
-- =====================================================
|
||||
|
||||
-- ---------------- 审核记录表 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `oa_ai_review` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`review_type` varchar(20) NOT NULL COMMENT '类型 contract合同 / resume简历',
|
||||
`file_name` varchar(255) DEFAULT NULL COMMENT '原始文件名',
|
||||
`oss_id` bigint(20) DEFAULT NULL COMMENT 'OSS文件ID(原件留存)',
|
||||
`file_url` varchar(500) DEFAULT NULL COMMENT 'OSS文件地址',
|
||||
`position` varchar(255) DEFAULT NULL COMMENT '简历目标岗位',
|
||||
`match_score` int(11) DEFAULT NULL COMMENT '简历匹配度评分 0-100',
|
||||
`risk_level` varchar(10) DEFAULT NULL COMMENT '合同风险评级 高/中/低',
|
||||
`summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要(列表展示)',
|
||||
`result_md` longtext COMMENT 'AI 审核结果(Markdown)',
|
||||
`model` varchar(50) DEFAULT NULL COMMENT '使用的模型',
|
||||
`tokens` int(11) DEFAULT NULL COMMENT '消耗 token',
|
||||
`create_by` varchar(64) DEFAULT NULL,
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_by` varchar(64) DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`del_flag` char(1) DEFAULT '0' COMMENT '删除标志:0正常 2删除(mybatis-plus logicDeleteValue=2)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_type` (`review_type`),
|
||||
KEY `idx_create_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 审核记录(合同/简历)';
|
||||
|
||||
-- 若表已存在(旧版本),补加 summary 列(MySQL 不支持 ADD COLUMN IF NOT EXISTS,重复执行报错可忽略):
|
||||
-- ALTER TABLE `oa_ai_review` ADD COLUMN `summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要(列表展示)' AFTER `risk_level`;
|
||||
|
||||
-- ---------------- 菜单:信息 > AI审核 ----------------
|
||||
-- 父菜单 1774989374680858626 = 「信息」
|
||||
INSERT IGNORE INTO `sys_menu`
|
||||
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`)
|
||||
VALUES
|
||||
(2063910000000000001, 'AI审核', 1774989374680858626, 4,
|
||||
'aiReview', 'oa/aiReview/index', 'C', '0', '0',
|
||||
NULL, 'eye-open', 'admin', NOW());
|
||||
|
||||
-- ---------------- 角色授权(与「信息」下兄弟菜单一致的角色集) ----------------
|
||||
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
|
||||
VALUES
|
||||
(1743186990678077442, 2063910000000000001), -- 总经理
|
||||
(1743204526291349506, 2063910000000000001), -- 技术总监
|
||||
(1743205028123045890, 2063910000000000001), -- 信息化部
|
||||
(1852970465740505090, 2063910000000000001), -- 普通员工
|
||||
(1859257980152692738, 2063910000000000001), -- 职工
|
||||
(1859548445766717441, 2063910000000000001), -- 后勤
|
||||
(1893987128812761089, 2063910000000000001), -- 新员工临时身份
|
||||
(1914212623781187585, 2063910000000000001), -- 技术总工
|
||||
(1914213026883162113, 2063910000000000001), -- 设计主任
|
||||
(1925062159919448065, 2063910000000000001); -- 外贸专责
|
||||
|
||||
-- ---------------- 校验 ----------------
|
||||
SELECT menu_id, menu_name, path, component, icon FROM sys_menu WHERE menu_id = 2063910000000000001;
|
||||
94
sql/oa_meeting.sql
Normal file
94
sql/oa_meeting.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- =====================================================
|
||||
-- 智能会议纪要 (Smart Meeting Minutes)
|
||||
-- - 会议可绑定 sys_oa_project.project_id,也可不绑定(非项目会议)
|
||||
-- - 主持人/参会/待办负责人 统一存 user_id
|
||||
-- - 待办在保存时可同步生成 sys_oa_task(带操作日志和 IM 通知)
|
||||
-- 本脚本可重复执行(幂等)。
|
||||
-- 注意:sys_dict_type/sys_dict_data/sys_menu 主键为雪花ID(非自增),必须显式指定。
|
||||
-- =====================================================
|
||||
|
||||
-- ---------------- 会议纪要表 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `oa_meeting_minutes` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`meeting_code` varchar(32) NOT NULL COMMENT '会议编号 MT-yyyyMMddHHmmss+3位随机',
|
||||
`meeting_date` date NOT NULL COMMENT '会议日期',
|
||||
`project_id` bigint(20) DEFAULT NULL COMMENT '关联 sys_oa_project.project_id(可空=非项目会议)',
|
||||
`meeting_type` varchar(20) DEFAULT 'other' COMMENT '类型 字典 oa_meeting_type',
|
||||
`subject` varchar(500) NOT NULL COMMENT '会议主题',
|
||||
`location` varchar(255) DEFAULT NULL COMMENT '会议地点',
|
||||
`host_user_id` bigint(20) DEFAULT NULL COMMENT '主持人 sys_user.user_id',
|
||||
`attendee_user_ids` varchar(1000) DEFAULT NULL COMMENT '参会人员 user_id 列表(逗号分隔)',
|
||||
`topic` text COMMENT '会议议题',
|
||||
`discussion` text COMMENT '讨论内容',
|
||||
`decision` text COMMENT '决议事项',
|
||||
`tasks_json` text COMMENT '待办 JSON:[{assigneeUserId,assigneeName,content,deadline,status,taskId}]',
|
||||
`sync_task` tinyint(1) DEFAULT 1 COMMENT '是否将待办同步为 OA 任务',
|
||||
`create_by` varchar(64) DEFAULT NULL,
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_by` varchar(64) DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`del_flag` char(1) DEFAULT '0' COMMENT '删除标志:0正常 2删除(mybatis-plus logicDeleteValue=2)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_meeting_code` (`meeting_code`, `del_flag`),
|
||||
KEY `idx_date` (`meeting_date`),
|
||||
KEY `idx_project` (`project_id`),
|
||||
KEY `idx_type` (`meeting_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会议纪要';
|
||||
|
||||
-- ---------------- 清理早期错误数据(dict_id/dict_code 误插为 0) ----------------
|
||||
DELETE FROM `sys_dict_type` WHERE `dict_id` = 0 AND `dict_type` IN ('oa_meeting_type', 'oa_meeting_task_status');
|
||||
DELETE FROM `sys_dict_data` WHERE `dict_code` = 0 AND `dict_type` IN ('oa_meeting_type', 'oa_meeting_task_status');
|
||||
|
||||
-- ---------------- 字典:会议类型 ----------------
|
||||
INSERT IGNORE INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`)
|
||||
VALUES (2063900000000000001, '会议类型', 'oa_meeting_type', '0', 'admin', NOW(), '智能会议纪要-会议类型');
|
||||
|
||||
INSERT IGNORE INTO `sys_dict_data`
|
||||
(`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`)
|
||||
VALUES
|
||||
(2063900000000000011, 1, '技术评审', 'tech', 'oa_meeting_type', '', 'info', 'N', '0', 'admin', NOW(), ''),
|
||||
(2063900000000000012, 2, '项目推进', 'project', 'oa_meeting_type', '', 'primary', 'N', '0', 'admin', NOW(), ''),
|
||||
(2063900000000000013, 3, '客户沟通', 'client', 'oa_meeting_type', '', 'warning', 'N', '0', 'admin', NOW(), ''),
|
||||
(2063900000000000014, 4, '周例会', 'weekly', 'oa_meeting_type', '', 'success', 'N', '0', 'admin', NOW(), ''),
|
||||
(2063900000000000015, 5, '其他', 'other', 'oa_meeting_type', '', 'default', 'Y', '0', 'admin', NOW(), '');
|
||||
|
||||
-- ---------------- 字典:待办状态 ----------------
|
||||
INSERT IGNORE INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`)
|
||||
VALUES (2063900000000000002, '会议待办状态', 'oa_meeting_task_status', '0', 'admin', NOW(), '智能会议纪要-待办状态');
|
||||
|
||||
INSERT IGNORE INTO `sys_dict_data`
|
||||
(`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`)
|
||||
VALUES
|
||||
(2063900000000000021, 1, '待办', 'pending', 'oa_meeting_task_status', '', 'info', 'Y', '0', 'admin', NOW(), ''),
|
||||
(2063900000000000022, 2, '进行中', 'progress', 'oa_meeting_task_status', '', 'primary', 'N', '0', 'admin', NOW(), ''),
|
||||
(2063900000000000023, 3, '已完成', 'done', 'oa_meeting_task_status', '', 'success', 'N', '0', 'admin', NOW(), '');
|
||||
|
||||
-- ---------------- 菜单:信息 > 会议纪要 ----------------
|
||||
-- 父菜单 1774989374680858626 = 「信息」
|
||||
INSERT IGNORE INTO `sys_menu`
|
||||
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`)
|
||||
VALUES
|
||||
(2063809716454174722, '会议纪要', 1774989374680858626, 3,
|
||||
'meeting', 'oa/meeting/index', 'C', '0', '0',
|
||||
NULL, 'documentation', 'admin', NOW());
|
||||
|
||||
UPDATE `sys_menu` SET `icon` = 'documentation' WHERE `menu_id` = 2063809716454174722 AND (`icon` = '#' OR `icon` IS NULL);
|
||||
|
||||
-- ---------------- 角色授权(与「信息」下兄弟菜单一致的角色集) ----------------
|
||||
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
|
||||
VALUES
|
||||
(1743186990678077442, 2063809716454174722), -- 总经理
|
||||
(1743204526291349506, 2063809716454174722), -- 技术总监
|
||||
(1743205028123045890, 2063809716454174722), -- 信息化部
|
||||
(1852970465740505090, 2063809716454174722), -- 普通员工
|
||||
(1859257980152692738, 2063809716454174722), -- 职工
|
||||
(1859548445766717441, 2063809716454174722), -- 后勤
|
||||
(1893987128812761089, 2063809716454174722), -- 新员工临时身份
|
||||
(1914212623781187585, 2063809716454174722), -- 技术总工
|
||||
(1914213026883162113, 2063809716454174722), -- 设计主任
|
||||
(1925062159919448065, 2063809716454174722); -- 外贸专责
|
||||
|
||||
-- ---------------- 校验 ----------------
|
||||
SELECT menu_id, menu_name, path, component, icon FROM sys_menu WHERE menu_id = 2063809716454174722;
|
||||
SELECT dict_type, dict_label, dict_value FROM sys_dict_data
|
||||
WHERE dict_type IN ('oa_meeting_type', 'oa_meeting_task_status') ORDER BY dict_type, dict_sort;
|
||||
Reference in New Issue
Block a user