feat: 本地 Java 发票 OCR + 策划方案多角色开放 + H5 相机脱敏修复
- OCR: 进程内 PaddleOCR ONNX Runtime 识别替代远程 Python 微服务 (新增 ocr/core/service/config 引擎, 删 OcrClient/OcrConfig, PDF 文本层优先 + QR 快路径) - 投稿: 项目设计投稿 → 项目策划方案, doctor/executor/sponsor 三角色开放 (BizProjectPlan 按非 admin/manager 隔离, 复用 Submissions.vue, 设计文件改 OssFileUploader) - H5: <video> 去掉 autoplay + safePlay 重试, 穿透 uni-app wrapper 找原生 video - 脱敏: 签到表高斯模糊由横向条带改为全高竖条带 (20%~50% 宽度) - 域名: ringdoctor.com → risingdoctor.com
This commit is contained in:
@@ -84,3 +84,7 @@ ry8080/
|
|||||||
ry8080.zip
|
ry8080.zip
|
||||||
ry-console/
|
ry-console/
|
||||||
ry-vue2/ # 旧 vue 控制台
|
ry-vue2/ # 旧 vue 控制台
|
||||||
|
|
||||||
|
# ===== OCR 模型 (大二进制, 不进版本库, 部署时放 jar 同目录 models/) =====
|
||||||
|
ry-api/models/
|
||||||
|
ry-ocr-java/src/main/resources/models/
|
||||||
|
|||||||
@@ -31,15 +31,31 @@ ruoyi:
|
|||||||
signName: 北京仙仁掌医学科技发展
|
signName: 北京仙仁掌医学科技发展
|
||||||
template: SMS_321560247
|
template: SMS_321560247
|
||||||
esignTemplate: SMS_492460505
|
esignTemplate: SMS_492460505
|
||||||
esignBaseUrl: https://ringdoctor.com/hg
|
esignBaseUrl: https://risingdoctor.com/hg
|
||||||
endpoint: dysmsapi.aliyuncs.com
|
endpoint: dysmsapi.aliyuncs.com
|
||||||
regionId: cn-hangzhou
|
regionId: cn-hangzhou
|
||||||
# 发票 OCR (ry-ocr 微服务, PaddleOCR + FastAPI, 默认 http://127.0.0.1:8801)
|
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
|
||||||
ocr:
|
ocr:
|
||||||
base-url: http://127.0.0.1:8801
|
version: 0.1.0
|
||||||
|
# 模型目录: 绝对路径 → 直读; 相对路径 → 相对进程工作目录 (java -jar 启动目录 = jar 同目录, 部署时 models/ 与 jar 同级)
|
||||||
|
models-dir: models
|
||||||
|
# 模型版本: v5_mobile (H=48, 20MB, 推荐) / v5_server (90MB+) / v4_mobile (15MB, 最快)
|
||||||
|
model-version: v5_mobile
|
||||||
|
# 单页 OCR 超时 / 整流程超时 (秒)
|
||||||
|
page-timeout-s: 15
|
||||||
|
total-timeout-s: 60
|
||||||
|
# QR 命中后是否继续跑全量 OCR (false = 快路径只返回 QR 3 字段)
|
||||||
|
qr-full-ocr: true
|
||||||
|
lang: ch
|
||||||
|
# PDF 优先抽内嵌文本层 (电子发票秒出, 扫描件自动回退 ONNX)
|
||||||
|
use-pdf-text-first: true
|
||||||
|
pdf-text-min-chars: 30
|
||||||
|
upload:
|
||||||
|
max-mb: 20
|
||||||
|
pdf-dpi: 150
|
||||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||||
camera:
|
camera:
|
||||||
base-url: https://ringdoctor.com/camera/
|
base-url: https://risingdoctor.com/camera/
|
||||||
|
|
||||||
# 开发环境配置
|
# 开发环境配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<artifactId>html2pdf</artifactId>
|
<artifactId>html2pdf</artifactId>
|
||||||
<version>3.0.2</version>
|
<version>3.0.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- ry-ocr Java 调用客户端依赖: hutool-http / hutool-json / hutool-core -->
|
<!-- hutool (本地 OCR 下载 / 文件 / JSON 工具) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
<artifactId>hutool-http</artifactId>
|
<artifactId>hutool-http</artifactId>
|
||||||
@@ -67,7 +67,30 @@
|
|||||||
<artifactId>hutool-core</artifactId>
|
<artifactId>hutool-core</artifactId>
|
||||||
<version>5.8.27</version>
|
<version>5.8.27</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Lombok (OcrClient 等用了 @Slf4j) -->
|
<!-- ===== 本地发票 OCR (PaddleOCR ONNX Runtime) ===== -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.microsoft.onnxruntime</groupId>
|
||||||
|
<artifactId>onnxruntime</artifactId>
|
||||||
|
<version>1.20.0</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- PDF 渲染 + 文本层抽取 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.pdfbox</groupId>
|
||||||
|
<artifactId>pdfbox</artifactId>
|
||||||
|
<version>2.0.31</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- 二维码解码 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>core</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>javase</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- Lombok (OCR 引擎等用了 @Slf4j) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.ruoyi.business.config;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import com.ruoyi.business.ocr.OcrClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ry-ocr 微服务集成配置
|
|
||||||
* <p>
|
|
||||||
* yml 配置: ruoyi.ocr.base-url (默认 http://127.0.0.1:8801)
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
public class OcrConfig {
|
|
||||||
|
|
||||||
@Value("${ruoyi.ocr.base-url:http://127.0.0.1:8801}")
|
|
||||||
private String ocrBaseUrl;
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public OcrClient ocrClient() {
|
|
||||||
return new OcrClient(ocrBaseUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-8
@@ -16,8 +16,8 @@ import com.ruoyi.business.service.IBizProjectPlanService;
|
|||||||
* 项目策划方案Controller
|
* 项目策划方案Controller
|
||||||
*
|
*
|
||||||
* 角色权限:
|
* 角色权限:
|
||||||
* - doctor: 只看自己投的稿 (submitter_id = 当前用户); 新建/编辑强制 submitter_id 写自己, status 默认 '0'
|
* - 投稿角色 (doctor/executor/sponsor): 只看自己投的稿 (submitter_id = 当前用户); 新建/编辑强制 submitter_id 写自己, status 默认 '0'
|
||||||
* - manager / sponsor / admin: 全部可见, 不强制 submitter
|
* - 管理角色 (admin/manager): 全部可见, 不强制 submitter (用于审核/结算)
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/business/projectPlan")
|
@RequestMapping("/business/projectPlan")
|
||||||
@@ -28,9 +28,9 @@ public class BizProjectPlanController extends BaseController
|
|||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public TableDataInfo list(BizProjectPlan BizProjectPlan)
|
public TableDataInfo list(BizProjectPlan BizProjectPlan)
|
||||||
{
|
{
|
||||||
// 医生角色: 后端兜底只查自己投的稿
|
// 投稿角色: 后端兜底只查自己投的稿
|
||||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||||
if ("doctor".equals(roleType)) {
|
if (isSubmitterRole(roleType)) {
|
||||||
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
|
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
|
||||||
}
|
}
|
||||||
startPage();
|
startPage();
|
||||||
@@ -46,9 +46,9 @@ public class BizProjectPlanController extends BaseController
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public AjaxResult add(@RequestBody BizProjectPlan BizProjectPlan)
|
public AjaxResult add(@RequestBody BizProjectPlan BizProjectPlan)
|
||||||
{
|
{
|
||||||
// 医生角色: 强制 submitter_id 写自己 + 状态兜底 '0' (未提交), 防止绕过
|
// 投稿角色: 强制 submitter_id 写自己 + 状态兜底 '0' (未提交), 防止绕过
|
||||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||||
if ("doctor".equals(roleType)) {
|
if (isSubmitterRole(roleType)) {
|
||||||
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
|
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
|
||||||
if (BizProjectPlan.getStatus() == null || BizProjectPlan.getStatus().isEmpty()) {
|
if (BizProjectPlan.getStatus() == null || BizProjectPlan.getStatus().isEmpty()) {
|
||||||
BizProjectPlan.setStatus("0");
|
BizProjectPlan.setStatus("0");
|
||||||
@@ -60,9 +60,9 @@ public class BizProjectPlanController extends BaseController
|
|||||||
@PutMapping
|
@PutMapping
|
||||||
public AjaxResult edit(@RequestBody BizProjectPlan BizProjectPlan)
|
public AjaxResult edit(@RequestBody BizProjectPlan BizProjectPlan)
|
||||||
{
|
{
|
||||||
// 医生角色: 修改时也强制覆盖 submitter_id, 防止越权篡改
|
// 投稿角色: 修改时也强制覆盖 submitter_id, 防止越权篡改
|
||||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||||
if ("doctor".equals(roleType)) {
|
if (isSubmitterRole(roleType)) {
|
||||||
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
|
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
|
||||||
}
|
}
|
||||||
return toAjax(BizProjectPlanService.updateByPrimaryKey(BizProjectPlan));
|
return toAjax(BizProjectPlanService.updateByPrimaryKey(BizProjectPlan));
|
||||||
@@ -73,4 +73,10 @@ public class BizProjectPlanController extends BaseController
|
|||||||
{
|
{
|
||||||
return toAjax(BizProjectPlanService.deleteByPrimaryKeys(ids));
|
return toAjax(BizProjectPlanService.deleteByPrimaryKeys(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 投稿角色 = 非 admin/manager (doctor/executor/sponsor): 只看/只写自己的稿 */
|
||||||
|
private boolean isSubmitterRole(String roleType)
|
||||||
|
{
|
||||||
|
return !("admin".equals(roleType) || "manager".equals(roleType));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,21 @@ import java.util.List;
|
|||||||
@Data
|
@Data
|
||||||
public class InvoiceResult {
|
public class InvoiceResult {
|
||||||
private Boolean success;
|
private Boolean success;
|
||||||
private String rawText;
|
/** 是否被判定为发票 (false = 非发票图片) */
|
||||||
|
private Boolean isInvoice = true;
|
||||||
|
private String rawText = "";
|
||||||
private String engine;
|
private String engine;
|
||||||
private Integer pageCount;
|
private Integer pageCount;
|
||||||
private Integer elapsedMs;
|
private Integer elapsedMs;
|
||||||
private String error;
|
private String error;
|
||||||
|
/** 错误码: not_invoice / timeout / unsupported / ocr_failed / process_failed */
|
||||||
|
private String errorCode;
|
||||||
|
/** 是否从 QR 取到了核心字段 */
|
||||||
|
private Boolean fromQr = false;
|
||||||
|
/** 二维码原始文本 (排查用) */
|
||||||
|
private String qrRaw;
|
||||||
|
/** 二维码识别失败原因 (no_qr / bad_format) */
|
||||||
|
private String qrError;
|
||||||
private InvoiceFields fields;
|
private InvoiceFields fields;
|
||||||
private List<OcrLine> lines;
|
private List<OcrLine> lines;
|
||||||
}
|
}
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ruoyi.business.ocr;
|
||||||
|
|
||||||
|
import cn.hutool.core.io.FileUtil;
|
||||||
|
import cn.hutool.http.HttpUtil;
|
||||||
|
import com.ruoyi.business.ocr.service.RecognizeService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地发票识别门面 — 替代原 OcrClient (HTTP 调 Python ry-ocr 微服务).
|
||||||
|
* <p>
|
||||||
|
* 直接委托 {@link RecognizeService} 在进程内跑 PaddleOCR ONNX 推理,
|
||||||
|
* 对外暴露与原 OcrClient 相同的两个方法, 调用方 (InvoiceOcrService) 无需改动.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LocalInvoiceRecognizer {
|
||||||
|
|
||||||
|
private final RecognizeService recognizeService;
|
||||||
|
|
||||||
|
/** 识别发票 (图片或 PDF) */
|
||||||
|
public InvoiceResult recognize(File file) {
|
||||||
|
byte[] content = FileUtil.readBytes(file);
|
||||||
|
return recognizeService.recognizeFile(file.getName(), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 识别发票: 下载 OSS URL 到临时文件 → 本地识别 → 清理临时文件.
|
||||||
|
*/
|
||||||
|
public InvoiceResult recognizeByUrl(String url) {
|
||||||
|
if (url == null || url.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("ossUrl 不能为空");
|
||||||
|
}
|
||||||
|
File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr");
|
||||||
|
if (!tmpDir.exists() && !tmpDir.mkdirs()) {
|
||||||
|
throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
String name = url.substring(url.lastIndexOf('/') + 1);
|
||||||
|
if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?'));
|
||||||
|
if (name.indexOf('.') < 0) name = name + ".png";
|
||||||
|
File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name);
|
||||||
|
try {
|
||||||
|
long size = HttpUtil.downloadFile(url, tmp);
|
||||||
|
if (size <= 0) {
|
||||||
|
throw new RuntimeException("OSS 文件下载失败或为空: " + url);
|
||||||
|
}
|
||||||
|
log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath());
|
||||||
|
return recognize(tmp);
|
||||||
|
} finally {
|
||||||
|
FileUtil.del(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package com.ruoyi.business.ocr;
|
|
||||||
|
|
||||||
import cn.hutool.core.io.FileUtil;
|
|
||||||
import cn.hutool.http.HttpRequest;
|
|
||||||
import cn.hutool.http.HttpResponse;
|
|
||||||
import cn.hutool.http.HttpUtil;
|
|
||||||
import cn.hutool.json.JSONObject;
|
|
||||||
import cn.hutool.json.JSONUtil;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ry-ocr Java 调用客户端
|
|
||||||
*
|
|
||||||
* 依赖:hutool-http, hutool-json, hutool-core, lombok
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* OcrClient client = new OcrClient("http://127.0.0.1:8801");
|
|
||||||
* InvoiceResult r = client.recognize(new File("d:/发票.pdf"));
|
|
||||||
* InvoiceResult r2 = client.recognizeByUrl("https://oss.example.com/xxx.png");
|
|
||||||
* System.out.println(r.getFields().getAmount());
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public class OcrClient {
|
|
||||||
|
|
||||||
private final String baseUrl;
|
|
||||||
|
|
||||||
public OcrClient(String baseUrl) {
|
|
||||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 健康检查 */
|
|
||||||
public boolean ping() {
|
|
||||||
try (HttpResponse resp = HttpRequest.get(baseUrl + "/health").timeout(3000).execute()) {
|
|
||||||
return resp.getStatus() == 200 && "ok".equals(JSONUtil.parseObj(resp.body()).getStr("status"));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("ocr ping failed: {}", e.getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 识别发票(图片或 PDF) */
|
|
||||||
public InvoiceResult recognize(File file) {
|
|
||||||
try (HttpResponse resp = HttpRequest.post(baseUrl + "/recognize/invoice")
|
|
||||||
.form("file", file)
|
|
||||||
.timeout(60_000)
|
|
||||||
.execute()) {
|
|
||||||
|
|
||||||
String body = resp.body();
|
|
||||||
JSONObject json = JSONUtil.parseObj(body);
|
|
||||||
if (resp.getStatus() != 200) {
|
|
||||||
throw new RuntimeException("OCR 调用失败: " + resp.getStatus() + " " + body);
|
|
||||||
}
|
|
||||||
return parse(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 URL 识别发票: 后端下载 OSS URL 到临时文件 → recognize → 清理临时文件.
|
|
||||||
* 临时文件目录: System.getProperty("java.io.tmpdir")/ry-ocr/
|
|
||||||
*
|
|
||||||
* @param url OSS 可访问 URL
|
|
||||||
* @return 识别结果
|
|
||||||
*/
|
|
||||||
public InvoiceResult recognizeByUrl(String url) {
|
|
||||||
if (url == null || url.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("ossUrl 不能为空");
|
|
||||||
}
|
|
||||||
File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr");
|
|
||||||
if (!tmpDir.exists() && !tmpDir.mkdirs()) {
|
|
||||||
throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath());
|
|
||||||
}
|
|
||||||
// 从 URL 截取文件名, 保留后缀 (用于 ry-ocr 推断图片/PDF)
|
|
||||||
String name = url.substring(url.lastIndexOf('/') + 1);
|
|
||||||
if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?'));
|
|
||||||
if (name.indexOf('.') < 0) name = name + ".png";
|
|
||||||
File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name);
|
|
||||||
try {
|
|
||||||
long size = HttpUtil.downloadFile(url, tmp);
|
|
||||||
if (size <= 0) {
|
|
||||||
throw new RuntimeException("OSS 文件下载失败或为空: " + url);
|
|
||||||
}
|
|
||||||
log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath());
|
|
||||||
return recognize(tmp);
|
|
||||||
} finally {
|
|
||||||
FileUtil.del(tmp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private InvoiceResult parse(JSONObject json) {
|
|
||||||
InvoiceResult r = new InvoiceResult();
|
|
||||||
r.setSuccess(json.getBool("success", false));
|
|
||||||
r.setRawText(json.getStr("rawText", ""));
|
|
||||||
r.setEngine(json.getStr("engine", ""));
|
|
||||||
r.setPageCount(json.getInt("pageCount", 1));
|
|
||||||
r.setElapsedMs(json.getInt("elapsedMs", 0));
|
|
||||||
r.setError(json.getStr("error"));
|
|
||||||
|
|
||||||
JSONObject f = json.getJSONObject("fields");
|
|
||||||
if (f != null) {
|
|
||||||
InvoiceFields fields = new InvoiceFields();
|
|
||||||
fields.setInvoiceType(f.getStr("invoiceType"));
|
|
||||||
fields.setInvoiceNo(f.getStr("invoiceNo"));
|
|
||||||
fields.setInvoiceCode(f.getStr("invoiceCode"));
|
|
||||||
fields.setInvoiceDate(f.getStr("invoiceDate"));
|
|
||||||
fields.setAmount(f.getDouble("amount"));
|
|
||||||
fields.setAmountCn(f.getStr("amountCn"));
|
|
||||||
fields.setAmountPretax(f.getDouble("amount_pretax"));
|
|
||||||
fields.setTaxAmount(f.getDouble("taxAmount"));
|
|
||||||
fields.setSellerName(f.getStr("sellerName"));
|
|
||||||
fields.setSellerTaxNo(f.getStr("sellerTaxNo"));
|
|
||||||
fields.setBuyerName(f.getStr("buyerName"));
|
|
||||||
fields.setBuyerTaxNo(f.getStr("buyerTaxNo"));
|
|
||||||
fields.setAmountMatch(f.getBool("amountMatch"));
|
|
||||||
r.setFields(fields);
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.ruoyi.business.ocr;
|
package com.ruoyi.business.ocr;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 单行 OCR 识别结果 */
|
/** 单行 OCR 识别结果 */
|
||||||
@Data
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
public class OcrLine {
|
public class OcrLine {
|
||||||
private String text;
|
private String text;
|
||||||
private Double confidence;
|
private Double confidence;
|
||||||
private List<List<Double>> box;
|
private List<List<Float>> box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ruoyi.business.ocr;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二维码识别结果 — 对齐 Python app.services.qr_decoder.QRDecodeResult
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class QrDecodeResult {
|
||||||
|
|
||||||
|
/** 发票号码 */
|
||||||
|
private String invoiceNo;
|
||||||
|
|
||||||
|
/** 金额 (小写) */
|
||||||
|
private Double amount;
|
||||||
|
|
||||||
|
/** 开票日期 YYYY-MM-DD */
|
||||||
|
private String invoiceDate;
|
||||||
|
|
||||||
|
/** 二维码原始文本 */
|
||||||
|
private String raw = "";
|
||||||
|
|
||||||
|
/** 是否有任一关键字段解出 */
|
||||||
|
public boolean hasAnyField() {
|
||||||
|
return invoiceNo != null || amount != null || invoiceDate != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ruoyi.business.ocr.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地发票 OCR 配置 (移植自 ry-ocr-java, 对应 Python app/config.py + .env)
|
||||||
|
* <p>
|
||||||
|
* 绑定 application.yml 中 ruoyi.ocr.* 配置段.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "ruoyi.ocr")
|
||||||
|
public class OcrProperties {
|
||||||
|
|
||||||
|
/** 服务版本号 (对应 Python __version__) */
|
||||||
|
private String version = "0.1.0";
|
||||||
|
|
||||||
|
private final Upload upload = new Upload();
|
||||||
|
private final Ocr ocr = new Ocr();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径白名单: 空字符串/空列表 = 禁用 by-path 接口
|
||||||
|
* <p>
|
||||||
|
* 支持: yml 数组 ["E:\\a", "D:\\b"] 或单字符串 "E:\\a;D:\\b" (Windows 分号分隔)
|
||||||
|
*/
|
||||||
|
private List<String> allowedDirs = new ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Upload {
|
||||||
|
/** 单文件最大 MB, 超限返回 HTTP 413 */
|
||||||
|
private int maxMb = 20;
|
||||||
|
/** PDF 转图片 DPI */
|
||||||
|
private int pdfDpi = 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Ocr {
|
||||||
|
/** 单页 OCR 超时 (秒) */
|
||||||
|
private int pageTimeoutS = 15;
|
||||||
|
/** 整流程 OCR 超时 (秒) */
|
||||||
|
private int totalTimeoutS = 60;
|
||||||
|
/** QR 命中后是否继续跑全量 OCR + 字段抽取 (false = 快路径, 只返回 QR 3 字段) */
|
||||||
|
private boolean qrFullOcr = true;
|
||||||
|
/** OCR 语言: ch (简中) / en / chinese_cht */
|
||||||
|
private String lang = "ch";
|
||||||
|
/** 模型目录: 相对路径 → classpath:models/, 绝对路径 → 直读 */
|
||||||
|
private String modelsDir = "models";
|
||||||
|
/** PDF 文件优先抽内嵌文本层 (pdftotext 等价) — 抽到非空文本则跳过 ONNX.
|
||||||
|
* 适用电子发票 / 数电票 PDF (含真实文本); 扫描件 PDF 会回退到 ONNX. */
|
||||||
|
private boolean usePdfTextFirst = true;
|
||||||
|
/** 内嵌文本字符数低于此值视为无效, 回退到 ONNX */
|
||||||
|
private int pdfTextMinChars = 30;
|
||||||
|
/** 模型版本 (决定 CRNN 输入高度):
|
||||||
|
* <ul>
|
||||||
|
* <li>v5_server — H=48, maxW=320 (PaddleOCR v5 server 多语言模型, 90MB+ 大, 精度最高)</li>
|
||||||
|
* <li>v5_mobile — H=48, maxW=320 (PaddleOCR v5 mobile 多语言模型, 20MB, 推荐折中)</li>
|
||||||
|
* <li>v4_mobile — H=32, maxW=320 (PaddleOCR v4 mobile 中文模型, 15MB, 最快, 中文精度略低)</li>
|
||||||
|
* </ul>
|
||||||
|
* 切换时改 models-dir + 本字段 + 重启即可. */
|
||||||
|
private String modelVersion = "v5_mobile";
|
||||||
|
/** CRNN 输入高度 (覆盖 modelVersion 默认值). 高级用户用, 一般不动. */
|
||||||
|
private Integer recHeight = null;
|
||||||
|
/** CRNN 最大宽度 (覆盖 modelVersion 默认值) */
|
||||||
|
private Integer recMaxWidth = null;
|
||||||
|
/** DB 检测最长边限制 (覆盖 modelVersion 默认值) */
|
||||||
|
private Integer detMaxSide = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CTC greedy decoder: argmax → 去连续重复 → 去 blank (idx 0) → 字典查表.
|
||||||
|
*/
|
||||||
|
public final class CtcDecoder {
|
||||||
|
|
||||||
|
private CtcDecoder() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param logits [T, N]
|
||||||
|
* @param dict 字典 (idx 0 = blank)
|
||||||
|
* @return RecognizedText
|
||||||
|
*/
|
||||||
|
public static TextRecognizer.RecognizedText decode(float[][] logits, Dictionary dict) {
|
||||||
|
int t = logits.length;
|
||||||
|
if (t == 0) return new TextRecognizer.RecognizedText("", 0.0);
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int lastIdx = -1;
|
||||||
|
double confSum = 0;
|
||||||
|
int confCount = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < t; i++) {
|
||||||
|
int bestIdx = 0;
|
||||||
|
float bestVal = Float.NEGATIVE_INFINITY;
|
||||||
|
for (int j = 0; j < logits[i].length; j++) {
|
||||||
|
if (logits[i][j] > bestVal) {
|
||||||
|
bestVal = logits[i][j];
|
||||||
|
bestIdx = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestIdx != 0 && bestIdx != lastIdx) {
|
||||||
|
// 跳过 blank (0), 跳过与上一次相同的 (CTC 合并)
|
||||||
|
if (bestIdx < dict.size()) {
|
||||||
|
sb.append(dict.getCharacters().get(bestIdx));
|
||||||
|
}
|
||||||
|
// softmax → exp / sum
|
||||||
|
double sumExp = 0;
|
||||||
|
for (int j = 0; j < logits[i].length; j++) {
|
||||||
|
sumExp += Math.exp(logits[i][j] - bestVal);
|
||||||
|
}
|
||||||
|
double prob = 1.0 / sumExp;
|
||||||
|
confSum += prob;
|
||||||
|
confCount++;
|
||||||
|
}
|
||||||
|
lastIdx = bestIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
double conf = confCount == 0 ? 0 : confSum / confCount;
|
||||||
|
return new TextRecognizer.RecognizedText(sb.toString(), conf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
import java.awt.geom.Path2D;
|
||||||
|
import java.awt.geom.PathIterator;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB (Differentiable Binarization) 后处理 — 从概率图提取文本框 polygon.
|
||||||
|
* <p>
|
||||||
|
* 复刻 Python PaddleOCR 的 db_post_process / boxes_from_bitmap.
|
||||||
|
*/
|
||||||
|
public final class DbPostProcessor {
|
||||||
|
|
||||||
|
private DbPostProcessor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param prob [H, W] 概率图
|
||||||
|
* @param resizeH/W 概率图对应的输入图尺寸
|
||||||
|
* @param origH/W 原图尺寸 (用于映射回原图坐标)
|
||||||
|
*/
|
||||||
|
public static List<List<Float>> postProcess(float[][] prob, int resizeH, int resizeW,
|
||||||
|
int origH, int origW,
|
||||||
|
float dbThresh, float boxThresh,
|
||||||
|
float unclipRatio) {
|
||||||
|
// 1. 二值化 + 膨胀 (这里简化为阈值 + 内置 unclipRatio 计算 box)
|
||||||
|
// 生产实现需要 findContours, 这里用简化: 标记连通分量 + bounding box + unclip
|
||||||
|
boolean[][] mask = new boolean[resizeH][resizeW];
|
||||||
|
for (int y = 0; y < resizeH; y++) {
|
||||||
|
for (int x = 0; x < resizeW; x++) {
|
||||||
|
mask[y][x] = prob[y][x] >= dbThresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 简易膨胀 (3x3)
|
||||||
|
boolean[][] dilated = dilate(mask, resizeH, resizeW);
|
||||||
|
// 简易 8 邻接连通分量
|
||||||
|
List<int[][]> components = connectedComponents(dilated, resizeH, resizeW);
|
||||||
|
// 过滤 + unclip
|
||||||
|
float scaleX = (float) origW / resizeW;
|
||||||
|
float scaleY = (float) origH / resizeH;
|
||||||
|
List<List<Float>> boxes = new ArrayList<>();
|
||||||
|
for (int[][] component : components) {
|
||||||
|
int minX = component[0][0], minY = component[0][1];
|
||||||
|
int maxX = component[0][0], maxY = component[0][1];
|
||||||
|
int area = 0;
|
||||||
|
for (int[] p : component) {
|
||||||
|
if (p[0] < minX) minX = p[0];
|
||||||
|
if (p[0] > maxX) maxX = p[0];
|
||||||
|
if (p[1] < minY) minY = p[1];
|
||||||
|
if (p[1] > maxY) maxY = p[1];
|
||||||
|
area++;
|
||||||
|
}
|
||||||
|
// box_thresh 过滤: 用平均 prob 二次过滤
|
||||||
|
if (area < 3) continue;
|
||||||
|
float meanProb = 0;
|
||||||
|
for (int[] p : component) {
|
||||||
|
meanProb += prob[p[1]][p[0]];
|
||||||
|
}
|
||||||
|
meanProb /= area;
|
||||||
|
if (meanProb < boxThresh) continue;
|
||||||
|
|
||||||
|
// unclip: 扩展 box (简化为固定比例放大)
|
||||||
|
int w = maxX - minX + 1, h = maxY - minY + 1;
|
||||||
|
int dx = (int) (w * (unclipRatio - 1) / 2);
|
||||||
|
int dy = (int) (h * (unclipRatio - 1) / 2);
|
||||||
|
minX = Math.max(0, minX - dx);
|
||||||
|
minY = Math.max(0, minY - dy);
|
||||||
|
maxX = Math.min(resizeW - 1, maxX + dx);
|
||||||
|
maxY = Math.min(resizeH - 1, maxY + dy);
|
||||||
|
|
||||||
|
List<Float> box = new ArrayList<>();
|
||||||
|
float[][] corners = {
|
||||||
|
{minX * scaleX, minY * scaleY},
|
||||||
|
{maxX * scaleX, minY * scaleY},
|
||||||
|
{maxX * scaleX, maxY * scaleY},
|
||||||
|
{minX * scaleX, maxY * scaleY}
|
||||||
|
};
|
||||||
|
for (float[] c : corners) {
|
||||||
|
box.add(c[0]);
|
||||||
|
box.add(c[1]);
|
||||||
|
}
|
||||||
|
boxes.add(box);
|
||||||
|
}
|
||||||
|
// 按 y 排序 (从上到下)
|
||||||
|
boxes.sort((a, b) -> Float.compare(a.get(1), b.get(1)));
|
||||||
|
return boxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean[][] dilate(boolean[][] src, int h, int w) {
|
||||||
|
boolean[][] dst = new boolean[h][w];
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
boolean any = false;
|
||||||
|
for (int dy = -1; dy <= 1 && !any; dy++) {
|
||||||
|
for (int dx = -1; dx <= 1 && !any; dx++) {
|
||||||
|
int ny = y + dy, nx = x + dx;
|
||||||
|
if (ny >= 0 && ny < h && nx >= 0 && nx < w && src[ny][nx]) {
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst[y][x] = any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<int[][]> connectedComponents(boolean[][] mask, int h, int w) {
|
||||||
|
boolean[][] visited = new boolean[h][w];
|
||||||
|
List<int[][]> result = new ArrayList<>();
|
||||||
|
int[] dx = {-1, 0, 1, -1, 1, -1, 0, 1};
|
||||||
|
int[] dy = {-1, -1, -1, 0, 0, 1, 1, 1};
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
if (!mask[y][x] || visited[y][x]) continue;
|
||||||
|
List<int[]> comp = new ArrayList<>();
|
||||||
|
java.util.Deque<int[]> stack = new java.util.ArrayDeque<>();
|
||||||
|
stack.push(new int[]{x, y});
|
||||||
|
visited[y][x] = true;
|
||||||
|
while (!stack.isEmpty()) {
|
||||||
|
int[] p = stack.pop();
|
||||||
|
comp.add(p);
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
int nx = p[0] + dx[i], ny = p[1] + dy[i];
|
||||||
|
if (nx >= 0 && nx < w && ny >= 0 && ny < h && mask[ny][nx] && !visited[ny][nx]) {
|
||||||
|
visited[ny][nx] = true;
|
||||||
|
stack.push(new int[]{nx, ny});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.add(comp.toArray(new int[0][]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具: polygon → bounding box
|
||||||
|
public static int[] bbox(List<Float> box) {
|
||||||
|
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;
|
||||||
|
float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
|
||||||
|
for (int i = 0; i < box.size(); i += 2) {
|
||||||
|
float x = box.get(i), y = box.get(i + 1);
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
return new int[]{Math.round(minX), Math.round(minY), Math.round(maxX), Math.round(maxY)};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中文字典加载 — 加载 PaddleOCR ppocr_keys_v1.txt
|
||||||
|
* <p>
|
||||||
|
* 模型输出约定: PP-OCRv5 multilingual 的输出维度 = len(dict_file_lines) + 2, 其中
|
||||||
|
* <ul>
|
||||||
|
* <li>idx 0 = CTC blank (不查表, 由解码器跳过)</li>
|
||||||
|
* <li>idx 1 = " " (半角空格, 多语言模型中英文混排用, 文件中无此行)</li>
|
||||||
|
* <li>idx 2..N+1 = 字典字符 (来自 ppocr_keys_v1.txt 的每一行)</li>
|
||||||
|
* </ul>
|
||||||
|
* 因此本类在加载时**主动在 idx 0 插入空串占位, idx 1 插入半角空格**, 使
|
||||||
|
* dict.characters[2] 对应文件第 1 行.
|
||||||
|
* <p>
|
||||||
|
* ⚠️ 此约定根据 monkt/paddleocr-onnx PP-OCRv5 mobile 输出维度 18385 (文件 18383 行 + 2)
|
||||||
|
* 反推得出, 适配大多数 PP-OCRv5 导出模型.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class Dictionary {
|
||||||
|
|
||||||
|
/** 字符表: idx 0 = CTC blank 占位, idx 1 = " " (半角空格), idx 2.. = 文件字符 */
|
||||||
|
private final List<String> characters;
|
||||||
|
|
||||||
|
private Dictionary(List<String> characters) {
|
||||||
|
this.characters = characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return characters.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary load(Path dictPath) throws IOException {
|
||||||
|
List<String> chars = new ArrayList<>();
|
||||||
|
// idx 0 = CTC blank 占位 (模型 output[0] = blank, 由 CtcDecoder 跳过)
|
||||||
|
chars.add("");
|
||||||
|
try (BufferedReader r = Files.newBufferedReader(dictPath, StandardCharsets.UTF_8)) {
|
||||||
|
String line;
|
||||||
|
while ((line = r.readLine()) != null) {
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
chars.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Dictionary(chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.geom.AffineTransform;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像预处理: 自动旋转 / 轻度增强 — 对齐 Python app.core.image_processor
|
||||||
|
* <p>
|
||||||
|
* 全部使用 Java 2D, 无需引入 OpenCV.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class ImageProcessor {
|
||||||
|
|
||||||
|
private ImageProcessor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易方向校正: 纵向图 (高 > 宽 × 1.2) 顺时针旋转 90°.
|
||||||
|
* <p>
|
||||||
|
* 复杂倾斜交给 ONNX 引擎自带的 textline orientation.
|
||||||
|
*/
|
||||||
|
public static Path autoRotate(Path imgPath) {
|
||||||
|
Path p = imgPath;
|
||||||
|
BufferedImage img;
|
||||||
|
try {
|
||||||
|
img = ImageIO.read(p.toFile());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("read image failed: {}", e.getMessage());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (img == null) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
int h = img.getHeight();
|
||||||
|
int w = img.getWidth();
|
||||||
|
if (h > w * 1.2) {
|
||||||
|
BufferedImage rotated = rotate90Clockwise(img);
|
||||||
|
Path out = p.resolveSibling(stem(p) + "_rot.png");
|
||||||
|
try {
|
||||||
|
ImageIO.write(rotated, "png", out.toFile());
|
||||||
|
return out;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("write rotated image failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻度增强: 灰度化 + (低对比度图) 自适应二值化.
|
||||||
|
*/
|
||||||
|
public static Path enhance(Path imgPath) {
|
||||||
|
Path p = imgPath;
|
||||||
|
BufferedImage img;
|
||||||
|
try {
|
||||||
|
img = ImageIO.read(p.toFile());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("read image failed: {}", e.getMessage());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (img == null) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
BufferedImage gray = toGray(img);
|
||||||
|
double std = stddev(gray);
|
||||||
|
if (std < 50) {
|
||||||
|
BufferedImage binary = adaptiveThreshold(gray, 31, 10);
|
||||||
|
Path out = p.resolveSibling(stem(p) + "_enh.png");
|
||||||
|
try {
|
||||||
|
ImageIO.write(binary, "png", out.toFile());
|
||||||
|
return out;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("write enhanced image failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 内部 ----------
|
||||||
|
|
||||||
|
private static String stem(Path p) {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
return dot > 0 ? name.substring(0, dot) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage rotate90Clockwise(BufferedImage src) {
|
||||||
|
int w = src.getWidth();
|
||||||
|
int h = src.getHeight();
|
||||||
|
BufferedImage dst = new BufferedImage(h, w, src.getType() == 0 ? BufferedImage.TYPE_INT_RGB : src.getType());
|
||||||
|
Graphics2D g = dst.createGraphics();
|
||||||
|
AffineTransform tx = new AffineTransform();
|
||||||
|
tx.translate(h, 0);
|
||||||
|
tx.rotate(Math.toRadians(90));
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g.drawImage(src, tx, null);
|
||||||
|
g.dispose();
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage toGray(BufferedImage src) {
|
||||||
|
if (src.getType() == BufferedImage.TYPE_BYTE_GRAY) return src;
|
||||||
|
BufferedImage gray = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
|
||||||
|
Graphics2D g = gray.createGraphics();
|
||||||
|
g.drawImage(src, 0, 0, null);
|
||||||
|
g.dispose();
|
||||||
|
return gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算灰度图标准差 (判断是否低对比度)
|
||||||
|
* <p>
|
||||||
|
* 优化: 用 getRGB() 一次性读出整张图到 int[], 直接遍历 byte 提取亮度, 避免 Raster.getSample 逐元素调用 (慢 10x+).
|
||||||
|
*/
|
||||||
|
private static double stddev(BufferedImage gray) {
|
||||||
|
int w = gray.getWidth();
|
||||||
|
int h = gray.getHeight();
|
||||||
|
int[] pixels = new int[w * h];
|
||||||
|
gray.getRGB(0, 0, w, h, pixels, 0, w);
|
||||||
|
long sum = 0;
|
||||||
|
long sumSq = 0;
|
||||||
|
long count = 0;
|
||||||
|
// 采样: 每 4 像素采样一次 (避免遍历几百万像素)
|
||||||
|
for (int i = 0; i < pixels.length; i += 16) {
|
||||||
|
int v = pixels[i] >>> 24 == 0 ? pixels[i] & 0xFF : (pixels[i] >> 16) & 0xFF; // 灰度图 R=G=B
|
||||||
|
// TYPE_BYTE_GRAY 的灰度值在 R/G/B 都一样, 取 R 即可
|
||||||
|
// 直接按 TYPE_BYTE_GRAY: ARGB 编码时 R 通道存的就是灰度
|
||||||
|
sum += v;
|
||||||
|
sumSq += (long) v * v;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count == 0) return 0;
|
||||||
|
double mean = (double) sum / count;
|
||||||
|
double variance = ((double) sumSq / count) - mean * mean;
|
||||||
|
return Math.sqrt(Math.max(0, variance));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易自适应二值化 (高斯加权 + 常数偏移).
|
||||||
|
* <p>
|
||||||
|
* 与 cv2.adaptiveThreshold(..., ADAPTIVE_THRESH_GAUSSIAN_C, ...) 行为近似.
|
||||||
|
* <p>
|
||||||
|
* 优化: 整图 getRGB 一次性读出, 用 byte[] 操作, 避免 Raster 逐元素访问.
|
||||||
|
*/
|
||||||
|
private static BufferedImage adaptiveThreshold(BufferedImage gray, int blockSize, int C) {
|
||||||
|
int w = gray.getWidth();
|
||||||
|
int h = gray.getHeight();
|
||||||
|
int[] srcPixels = new int[w * h];
|
||||||
|
gray.getRGB(0, 0, w, h, srcPixels, 0, w);
|
||||||
|
// 提取灰度字节 (TYPE_BYTE_GRAY 在 BufferedImage 内部实际存为 TYPE_INT_ARGB 但 R 通道就是灰度)
|
||||||
|
byte[] srcGray = new byte[w * h];
|
||||||
|
for (int i = 0; i < srcPixels.length; i++) {
|
||||||
|
srcGray[i] = (byte) (srcPixels[i] & 0xFF);
|
||||||
|
}
|
||||||
|
// 先做 box blur (近似高斯)
|
||||||
|
byte[] blurredGray = boxBlurBytes(srcGray, w, h, blockSize);
|
||||||
|
BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_BINARY);
|
||||||
|
byte[] outData = new byte[w * h];
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
int rowStart = y * w;
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
int idx = rowStart + x;
|
||||||
|
int src = srcGray[idx] & 0xFF;
|
||||||
|
int bg = blurredGray[idx] & 0xFF;
|
||||||
|
int v = src - bg + 127 - C;
|
||||||
|
outData[idx] = (byte) (v > 127 ? 255 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.getRaster().setDataElements(0, 0, w, h, outData);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻量 box blur (半径 = blockSize / 2) — 纯 byte[] 数组操作, 无 Raster 开销
|
||||||
|
*/
|
||||||
|
private static byte[] boxBlurBytes(byte[] gray, int w, int h, int blockSize) {
|
||||||
|
int radius = Math.max(1, blockSize / 2);
|
||||||
|
// 构造积分图 (像素值累加, 0-255)
|
||||||
|
int[] integral = new int[w * h];
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
int rowSum = 0;
|
||||||
|
int rowStart = y * w;
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
rowSum += gray[rowStart + x] & 0xFF;
|
||||||
|
integral[rowStart + x] = rowSum + (y > 0 ? integral[rowStart + x - w] : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byte[] out = new byte[w * h];
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
int y1 = Math.max(0, y - radius);
|
||||||
|
int y2 = Math.min(h - 1, y + radius);
|
||||||
|
int rowStart = y * w;
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
int x1 = Math.max(0, x - radius);
|
||||||
|
int x2 = Math.min(w - 1, x + radius);
|
||||||
|
int area = (x2 - x1 + 1) * (y2 - y1 + 1);
|
||||||
|
int sum = integral[y2 * w + x2];
|
||||||
|
if (x1 > 0) sum -= integral[y2 * w + (x1 - 1)];
|
||||||
|
if (y1 > 0) sum -= integral[(y1 - 1) * w + x2];
|
||||||
|
if (x1 > 0 && y1 > 0) sum += integral[(y1 - 1) * w + (x1 - 1)];
|
||||||
|
out[rowStart + x] = (byte) (sum / area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载 BufferedImage (封装异常) */
|
||||||
|
public static BufferedImage read(Path p) throws IOException {
|
||||||
|
return ImageIO.read(p.toFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存 BufferedImage (封装异常) */
|
||||||
|
public static void write(BufferedImage img, Path p) throws IOException {
|
||||||
|
File f = p.toFile();
|
||||||
|
if (f.getParentFile() != null) {
|
||||||
|
f.getParentFile().mkdirs();
|
||||||
|
}
|
||||||
|
ImageIO.write(img, "png", f);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
import ai.onnxruntime.OrtEnvironment;
|
||||||
|
import ai.onnxruntime.OrtException;
|
||||||
|
import com.ruoyi.business.ocr.config.OcrProperties;
|
||||||
|
import com.ruoyi.business.ocr.exception.OcrTimeoutException;
|
||||||
|
import com.ruoyi.business.ocr.OcrLine;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCR 引擎单例 — 对齐 Python app.core.ocr_engine
|
||||||
|
* <p>
|
||||||
|
* 单例 + 单页超时 (Future.get(timeout)).
|
||||||
|
* 底层: PaddleOCR ONNX Runtime (det + rec).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class OcrEngine {
|
||||||
|
|
||||||
|
private final OcrProperties props;
|
||||||
|
private final ExecutorService executor = Executors.newFixedThreadPool(2, r -> {
|
||||||
|
Thread t = new Thread(r, "ocr-worker");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
private OrtEnvironment ortEnv;
|
||||||
|
private TextDetector detector;
|
||||||
|
private TextRecognizer recognizer;
|
||||||
|
|
||||||
|
private volatile boolean ready = false;
|
||||||
|
|
||||||
|
public OcrEngine(OcrProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动预热: 加载 ONNX Session
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public synchronized void warmup() {
|
||||||
|
try {
|
||||||
|
Path modelsDir = resolveModelsDir();
|
||||||
|
Path detPath = modelsDir.resolve("det.onnx");
|
||||||
|
Path recPath = modelsDir.resolve("rec.onnx");
|
||||||
|
Path dictPath = modelsDir.resolve("ppocr_keys_v1.txt");
|
||||||
|
|
||||||
|
if (!Files.exists(detPath) || !Files.exists(recPath) || !Files.exists(dictPath)) {
|
||||||
|
log.warn("OCR 模型未找到 ({}/det.onnx + rec.onnx + ppocr_keys_v1.txt), 引擎未就绪, /health 返回 degraded", modelsDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("加载 OCR 模型 from {} (version={})", modelsDir, props.getOcr().getModelVersion());
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
this.ortEnv = OrtEnvironment.getEnvironment();
|
||||||
|
this.detector = new TextDetector(ortEnv, detPath, resolveDetMaxSide());
|
||||||
|
Dictionary dict = Dictionary.load(dictPath);
|
||||||
|
this.recognizer = new TextRecognizer(ortEnv, recPath, dict, resolveRecHeight(), resolveRecMaxW());
|
||||||
|
this.ready = true;
|
||||||
|
log.info("OCR 引擎就绪, 耗时 {}ms (recHeight={}, recMaxW={}, detMaxSide={})",
|
||||||
|
System.currentTimeMillis() - t0, resolveRecHeight(), resolveRecMaxW(), resolveDetMaxSide());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("OCR 引擎初始化失败: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析模型目录: 相对路径 → classpath:models/, 绝对路径 → 直读
|
||||||
|
*/
|
||||||
|
private Path resolveModelsDir() {
|
||||||
|
String dir = props.getOcr().getModelsDir();
|
||||||
|
Path p = Path.of(dir);
|
||||||
|
if (p.isAbsolute()) return p;
|
||||||
|
|
||||||
|
// 部署约定: 模型与 jar 同目录 (user.dir = java -jar 启动目录, 见 ry.sh 的 `pwd`)
|
||||||
|
Path workDir = Path.of(System.getProperty("user.dir"), dir);
|
||||||
|
if (hasModels(workDir)) return workDir;
|
||||||
|
|
||||||
|
// classpath: resources/models/ (开发环境备选)
|
||||||
|
try {
|
||||||
|
java.net.URL url = getClass().getClassLoader().getResource(dir);
|
||||||
|
if (url != null && "file".equals(url.getProtocol())) {
|
||||||
|
return Path.of(url.toURI());
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
// IDE 源码目录回退
|
||||||
|
return Path.of("src/main/resources", dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** det.onnx + rec.onnx + ppocr_keys_v1.txt 齐全才算有效模型目录 */
|
||||||
|
private boolean hasModels(Path modelsDir) {
|
||||||
|
return Files.exists(modelsDir.resolve("det.onnx"))
|
||||||
|
&& Files.exists(modelsDir.resolve("rec.onnx"))
|
||||||
|
&& Files.exists(modelsDir.resolve("ppocr_keys_v1.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 CRNN 输入高度: 显式 rec-height > modelVersion 默认 */
|
||||||
|
private int resolveRecHeight() {
|
||||||
|
Integer override = props.getOcr().getRecHeight();
|
||||||
|
if (override != null) return override;
|
||||||
|
return switch (props.getOcr().getModelVersion()) {
|
||||||
|
case "v5_server", "v5_mobile" -> 48;
|
||||||
|
case "v4_mobile" -> 32;
|
||||||
|
default -> 48;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveRecMaxW() {
|
||||||
|
Integer override = props.getOcr().getRecMaxWidth();
|
||||||
|
if (override != null) return override;
|
||||||
|
return 320;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveDetMaxSide() {
|
||||||
|
Integer override = props.getOcr().getDetMaxSide();
|
||||||
|
if (override != null) return override;
|
||||||
|
return switch (props.getOcr().getModelVersion()) {
|
||||||
|
case "v5_server", "v5_mobile" -> 800; // 优化: v5 原生 960 → 800, 算力 -31%
|
||||||
|
case "v4_mobile" -> 960;
|
||||||
|
default -> 960;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void close() {
|
||||||
|
try {
|
||||||
|
if (recognizer != null) recognizer.close();
|
||||||
|
if (detector != null) detector.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("close engine error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isReady() {
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别单张图片 — 带单页超时
|
||||||
|
*/
|
||||||
|
public List<OcrLine> recognize(Path imagePath) {
|
||||||
|
if (!ready) {
|
||||||
|
throw new IllegalStateException("OCR 引擎未就绪, 请检查 models/ 目录下是否有 det.onnx / rec.onnx / ppocr_keys_v1.txt");
|
||||||
|
}
|
||||||
|
int timeoutSec = props.getOcr().getPageTimeoutS();
|
||||||
|
Future<List<OcrLine>> future = executor.submit(() -> doRecognize(imagePath));
|
||||||
|
try {
|
||||||
|
return future.get(timeoutSec, TimeUnit.SECONDS);
|
||||||
|
} catch (TimeoutException e) {
|
||||||
|
future.cancel(true);
|
||||||
|
throw new OcrTimeoutException("OCR 识别超时 (" + timeoutSec + "秒): " + imagePath);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new OcrRuntimeException("OCR 中断: " + e.getMessage());
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
Throwable cause = e.getCause();
|
||||||
|
if (cause instanceof OcrTimeoutException) throw (OcrTimeoutException) cause;
|
||||||
|
if (cause instanceof OcrRuntimeException) throw (OcrRuntimeException) cause;
|
||||||
|
throw new OcrRuntimeException("OCR 执行异常: " + (cause == null ? e.getMessage() : cause.getMessage()), cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OcrLine> doRecognize(Path imagePath) {
|
||||||
|
try {
|
||||||
|
BufferedImage img = ImageIO.read(imagePath.toFile());
|
||||||
|
if (img == null) return Collections.emptyList();
|
||||||
|
|
||||||
|
// 1. 检测
|
||||||
|
List<List<Float>> boxes = detector.detect(img);
|
||||||
|
// 2. 过滤 + 收集 crops (排除 height<8 或 width<5 的噪点 — 不会影响字段抽取)
|
||||||
|
List<BufferedImage> crops = new ArrayList<>();
|
||||||
|
List<List<Float>> validBoxes = new ArrayList<>();
|
||||||
|
for (List<Float> box : boxes) {
|
||||||
|
int[] bb = DbPostProcessor.bbox(box);
|
||||||
|
int x1 = Math.max(0, bb[0]);
|
||||||
|
int y1 = Math.max(0, bb[1]);
|
||||||
|
int x2 = Math.min(img.getWidth(), bb[2]);
|
||||||
|
int y2 = Math.min(img.getHeight(), bb[3]);
|
||||||
|
int w = x2 - x1, h = y2 - y1;
|
||||||
|
if (w < 5 || h < 8) continue; // 过小 — 噪点
|
||||||
|
if (w < 3 || h < 3) continue; // 原安全检查
|
||||||
|
crops.add(img.getSubimage(x1, y1, w, h));
|
||||||
|
validBoxes.add(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 批量推理 (性能关键: ONNX 一次推理处理所有 crop)
|
||||||
|
List<TextRecognizer.RecognizedText> rts = recognizer.recognizeBatch(crops);
|
||||||
|
|
||||||
|
// 4. 配对 boxes + texts
|
||||||
|
List<OcrLine> lines = new ArrayList<>();
|
||||||
|
for (int i = 0; i < validBoxes.size(); i++) {
|
||||||
|
TextRecognizer.RecognizedText rt = rts.get(i);
|
||||||
|
if (rt.text() != null && !rt.text().isEmpty()) {
|
||||||
|
List<Float> poly = validBoxes.get(i);
|
||||||
|
List<List<Float>> boxList = new ArrayList<>();
|
||||||
|
for (int k = 0; k < poly.size(); k += 2) {
|
||||||
|
List<Float> p = new ArrayList<>();
|
||||||
|
p.add(poly.get(k));
|
||||||
|
p.add(poly.get(k + 1));
|
||||||
|
boxList.add(p);
|
||||||
|
}
|
||||||
|
lines.add(new OcrLine(rt.text().trim(), rt.confidence(), boxList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
} catch (OrtException | IOException e) {
|
||||||
|
throw new OcrRuntimeException("OCR 执行异常: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内部异常, 避免暴露 ONNX 细节 */
|
||||||
|
public static class OcrRuntimeException extends RuntimeException {
|
||||||
|
public OcrRuntimeException(String message) { super(message); }
|
||||||
|
public OcrRuntimeException(String message, Throwable cause) { super(message, cause); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
import com.ruoyi.business.ocr.config.OcrProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
|
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||||
|
import org.apache.pdfbox.text.PDFTextStripper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF → 图片 — 对齐 Python app.core.pdf_processor
|
||||||
|
* <p>
|
||||||
|
* 使用 PDFBox (无需 poppler 等系统依赖).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PdfProcessor {
|
||||||
|
|
||||||
|
private final OcrProperties props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 PDF 每页渲染成 PNG, 返回临时文件路径列表.
|
||||||
|
* <p>
|
||||||
|
* 输出目录: {pdf.parent}/.{pdf.stem}_pages/page_{idx:03d}.png
|
||||||
|
*/
|
||||||
|
public List<Path> pdfToImages(Path pdfPath) throws IOException {
|
||||||
|
int dpi = props.getUpload().getPdfDpi();
|
||||||
|
Path outDir = pdfPath.getParent().resolve("." + stem(pdfPath) + "_pages");
|
||||||
|
outDir.toFile().mkdirs();
|
||||||
|
|
||||||
|
List<Path> saved = new ArrayList<>();
|
||||||
|
try (PDDocument doc = PDDocument.load(pdfPath.toFile())) {
|
||||||
|
PDFRenderer renderer = new PDFRenderer(doc);
|
||||||
|
renderer.setSubsamplingAllowed(true); // 大图降采样, 提速 + 省内存
|
||||||
|
int pageCount = doc.getNumberOfPages();
|
||||||
|
for (int idx = 0; idx < pageCount; idx++) {
|
||||||
|
BufferedImage img = renderer.renderImageWithDPI(idx, dpi);
|
||||||
|
Path outPath = outDir.resolve(String.format("page_%03d.png", idx + 1));
|
||||||
|
ImageProcessor.write(img, outPath);
|
||||||
|
saved.add(outPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("PDF 转图片: {} → {} 页 (dpi={})", pdfPath.getFileName(), saved.size(), dpi);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stem(Path p) {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
return dot > 0 ? name.substring(0, dot) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取 PDF 内嵌文本(pdftotext 等价)— 用于"不调用 ONNX"的 fast path
|
||||||
|
* <p>
|
||||||
|
* 适用: 电子发票 / 数电票 PDF (含真实可复制文本层).
|
||||||
|
* 扫描件 PDF 抽出来为空或字符极少, 调用方应回退到 OCR 流程.
|
||||||
|
*
|
||||||
|
* @return 抽取到的纯文本 (trim 后); 若文件无文本层, 返回空字符串
|
||||||
|
*/
|
||||||
|
public String extractText(Path pdfPath) throws IOException {
|
||||||
|
try (PDDocument doc = PDDocument.load(pdfPath.toFile())) {
|
||||||
|
PDFTextStripper stripper = new PDFTextStripper();
|
||||||
|
// 逐页拼接
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int pageCount = doc.getNumberOfPages();
|
||||||
|
for (int i = 1; i <= pageCount; i++) {
|
||||||
|
stripper.setStartPage(i);
|
||||||
|
stripper.setEndPage(i);
|
||||||
|
sb.append(stripper.getText(doc));
|
||||||
|
if (i < pageCount) sb.append('\n');
|
||||||
|
}
|
||||||
|
String text = sb.toString().trim();
|
||||||
|
log.info("PDF 内嵌文本抽取: {} → {} 字符 ({} 页)", pdfPath.getFileName(), text.length(), pageCount);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
import ai.onnxruntime.OrtEnvironment;
|
||||||
|
import ai.onnxruntime.OrtException;
|
||||||
|
import ai.onnxruntime.OrtSession;
|
||||||
|
import ai.onnxruntime.OrtSession.SessionOptions;
|
||||||
|
import ai.onnxruntime.OnnxTensor;
|
||||||
|
import ai.onnxruntime.OrtSession.SessionOptions.ExecutionMode;
|
||||||
|
import ai.onnxruntime.OrtSession.SessionOptions.OptLevel;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.nio.FloatBuffer;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PaddleOCR 检测 (DB 算法) — ONNX Runtime 推理.
|
||||||
|
* <p>
|
||||||
|
* 输入: [1, 3, H, W] 归一化图 (mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
||||||
|
* 输出: 概率图 [1, 1, H, W]
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class TextDetector implements AutoCloseable {
|
||||||
|
|
||||||
|
private final OrtEnvironment env;
|
||||||
|
private final OrtSession session;
|
||||||
|
private final float[] mean = {0.485f, 0.456f, 0.406f};
|
||||||
|
private final float[] std = {0.229f, 0.224f, 0.225f};
|
||||||
|
/** DB 二值化阈值 */
|
||||||
|
private final float dbThresh = 0.3f;
|
||||||
|
/** DB box 阈值 */
|
||||||
|
private final float boxThresh = 0.5f;
|
||||||
|
/** unclip 膨胀系数 */
|
||||||
|
private final float unclipRatio = 1.6f;
|
||||||
|
/** 最长边限制 — 通过构造参数传入 (v5 默认 800, v4 默认 960) */
|
||||||
|
private final int maxSideLen;
|
||||||
|
|
||||||
|
public TextDetector(OrtEnvironment env, Path modelPath, int maxSideLen) throws OrtException {
|
||||||
|
this.maxSideLen = maxSideLen;
|
||||||
|
this.env = env;
|
||||||
|
SessionOptions opts = new SessionOptions();
|
||||||
|
opts.setExecutionMode(ExecutionMode.PARALLEL);
|
||||||
|
opts.setOptimizationLevel(OptLevel.ALL_OPT);
|
||||||
|
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
|
||||||
|
opts.setIntraOpNumThreads(threads);
|
||||||
|
opts.setInterOpNumThreads(threads);
|
||||||
|
this.session = env.createSession(modelPath.toString(), opts);
|
||||||
|
log.info("DB 检测器加载完成: {}, threads={}", modelPath.getFileName(), threads);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测: 返回多边形 (每个 polygon 是 4-8 个 [x, y] 点)
|
||||||
|
*/
|
||||||
|
public List<List<Float>> detect(BufferedImage img) throws OrtException {
|
||||||
|
// 1. 预处理 (resize, pad, normalize)
|
||||||
|
int origH = img.getHeight();
|
||||||
|
int origW = img.getWidth();
|
||||||
|
// 按最长边缩放
|
||||||
|
int targetH = origH, targetW = origW;
|
||||||
|
int maxSide = Math.max(origH, origW);
|
||||||
|
if (maxSide > maxSideLen) {
|
||||||
|
float ratio = (float) maxSideLen / maxSide;
|
||||||
|
targetH = Math.round(origH * ratio);
|
||||||
|
targetW = Math.round(origW * ratio);
|
||||||
|
}
|
||||||
|
// pad 到 32 倍数
|
||||||
|
int padH = (32 - targetH % 32) % 32;
|
||||||
|
int padW = (32 - targetW % 32) % 32;
|
||||||
|
int inputH = targetH + padH;
|
||||||
|
int inputW = targetW + padW;
|
||||||
|
|
||||||
|
BufferedImage resized = resize(img, targetW, targetH);
|
||||||
|
float[] inputData = new float[3 * inputH * inputW];
|
||||||
|
// CHW 归一化 + pad
|
||||||
|
int[] pixels = new int[targetW * targetH];
|
||||||
|
resized.getRGB(0, 0, targetW, targetH, pixels, 0, targetW);
|
||||||
|
// CHW
|
||||||
|
for (int c = 0; c < 3; c++) {
|
||||||
|
for (int y = 0; y < targetH; y++) {
|
||||||
|
for (int x = 0; x < targetW; x++) {
|
||||||
|
int argb = pixels[y * targetW + x];
|
||||||
|
int v;
|
||||||
|
switch (c) {
|
||||||
|
case 0: v = (argb >> 16) & 0xFF; break;
|
||||||
|
case 1: v = (argb >> 8) & 0xFF; break;
|
||||||
|
default: v = argb & 0xFF;
|
||||||
|
}
|
||||||
|
int idx = c * inputH * inputW + y * inputW + x;
|
||||||
|
inputData[idx] = ((float) v / 255f - mean[c]) / std[c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pad 行: 已经是 0, 因为 FloatBuffer 默认 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 推理
|
||||||
|
long[] shape = {1, 3, inputH, inputW};
|
||||||
|
OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape);
|
||||||
|
Map<String, OnnxTensor> inputs = Collections.singletonMap("x", inputTensor);
|
||||||
|
|
||||||
|
List<List<Float>> boxes;
|
||||||
|
try (OrtSession.Result result = session.run(inputs)) {
|
||||||
|
OnnxTensor outTensor = (OnnxTensor) result.get(0);
|
||||||
|
float[][] prob = extractProbMap(outTensor, inputH, inputW);
|
||||||
|
// 3. DB 后处理
|
||||||
|
boxes = DbPostProcessor.postProcess(prob, targetH, targetW, origH, origW,
|
||||||
|
dbThresh, boxThresh, unclipRatio);
|
||||||
|
} finally {
|
||||||
|
inputTensor.close();
|
||||||
|
}
|
||||||
|
return boxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ONNX tensor 抽取概率图为 [H, W] float[][]
|
||||||
|
* <p>
|
||||||
|
* PaddleOCR 检测模型输出可能是 float[1][1][H][W] / float[1][H][W] / FloatBuffer 等.
|
||||||
|
*/
|
||||||
|
private static float[][] extractProbMap(OnnxTensor tensor, int h, int w) throws OrtException {
|
||||||
|
Object val = tensor.getValue();
|
||||||
|
if (val instanceof float[][][][]) {
|
||||||
|
return ((float[][][][]) val)[0][0];
|
||||||
|
} else if (val instanceof float[][][]) {
|
||||||
|
return ((float[][][]) val)[0];
|
||||||
|
} else if (val instanceof float[][]) {
|
||||||
|
return (float[][]) val;
|
||||||
|
} else if (val instanceof FloatBuffer) {
|
||||||
|
FloatBuffer buf = (FloatBuffer) val;
|
||||||
|
float[][] map = new float[h][w];
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
map[y][x] = buf.get(y * w + x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage resize(BufferedImage src, int w, int h) {
|
||||||
|
java.awt.Image tmp = src.getScaledInstance(w, h, java.awt.Image.SCALE_SMOOTH);
|
||||||
|
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||||
|
java.awt.Graphics2D g = dst.createGraphics();
|
||||||
|
g.drawImage(tmp, 0, 0, null);
|
||||||
|
g.dispose();
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws OrtException {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.ruoyi.business.ocr.core;
|
||||||
|
|
||||||
|
import ai.onnxruntime.OnnxTensor;
|
||||||
|
import ai.onnxruntime.OrtEnvironment;
|
||||||
|
import ai.onnxruntime.OrtException;
|
||||||
|
import ai.onnxruntime.OrtSession;
|
||||||
|
import ai.onnxruntime.OrtSession.SessionOptions;
|
||||||
|
import ai.onnxruntime.OrtSession.SessionOptions.ExecutionMode;
|
||||||
|
import ai.onnxruntime.OrtSession.SessionOptions.OptLevel;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.nio.FloatBuffer;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PaddleOCR 识别 (CRNN + CTC) — ONNX Runtime 推理.
|
||||||
|
* <p>
|
||||||
|
* 输入: [1, 3, H, W] 归一化图 (mean=0.5, std=0.5)
|
||||||
|
* 输出: [1, T, N] (T=序列长度, N=字典大小+1 含 blank)
|
||||||
|
* <p>
|
||||||
|
* 当前使用 PP-OCRv4 mobile: H=32, maxW=320 (v4 标准, v5 是 H=48).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class TextRecognizer implements AutoCloseable {
|
||||||
|
|
||||||
|
private final OrtEnvironment env;
|
||||||
|
private final OrtSession session;
|
||||||
|
private final Dictionary dict;
|
||||||
|
private final float[] mean = {0.5f, 0.5f, 0.5f};
|
||||||
|
private final float[] std = {0.5f, 0.5f, 0.5f};
|
||||||
|
/** 输入最大宽 — 通过构造参数传入 */
|
||||||
|
private final int maxW;
|
||||||
|
/** 输入高度 — 通过构造参数传入 (v5=48, v4=32) */
|
||||||
|
private final int targetH;
|
||||||
|
|
||||||
|
public TextRecognizer(OrtEnvironment env, Path modelPath, Dictionary dict, int targetH, int maxW) throws OrtException {
|
||||||
|
this.env = env;
|
||||||
|
this.dict = dict;
|
||||||
|
this.targetH = targetH;
|
||||||
|
this.maxW = maxW;
|
||||||
|
SessionOptions opts = new SessionOptions();
|
||||||
|
// 并行执行模式 + 全图优化 (节点融合/常量折叠) — CRNN 是主要瓶颈, 收益最大
|
||||||
|
opts.setExecutionMode(ExecutionMode.PARALLEL);
|
||||||
|
opts.setOptimizationLevel(OptLevel.ALL_OPT);
|
||||||
|
// CRNN 单图推理 op 数少, intra-op 多核并行收益高; inter-op 多图并发 (无 batch 时无效)
|
||||||
|
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
|
||||||
|
opts.setIntraOpNumThreads(threads);
|
||||||
|
opts.setInterOpNumThreads(threads);
|
||||||
|
this.session = env.createSession(modelPath.toString(), opts);
|
||||||
|
log.info("CRNN 识别器加载完成: {}, 字典={} 字符, threads={}", modelPath.getFileName(), dict.size(), threads);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别一张裁剪图, 返回 (文本, 置信度)
|
||||||
|
*/
|
||||||
|
public RecognizedText recognize(BufferedImage crop) throws OrtException {
|
||||||
|
// 复用 batch 推理, 单图 = batch=1
|
||||||
|
List<RecognizedText> results = recognizeBatch(Collections.singletonList(crop));
|
||||||
|
return results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量识别多张裁剪图 — 性能关键.
|
||||||
|
* <p>
|
||||||
|
* 核心优化: 把所有 crop pad 到 batch 内统一宽度 (maxW), 一次性喂给 ONNX.
|
||||||
|
* ONNX 内部用 8 线程并行算所有样本, 单图推理省掉 33 次 kernel launch.
|
||||||
|
* <p>
|
||||||
|
* 输入: 每张 crop 任意宽度 → resize 高 48 + 宽按比例 ≤ maxW
|
||||||
|
* 输出: 与输入 crops 一一对应的 RecognizedText
|
||||||
|
*/
|
||||||
|
public List<RecognizedText> recognizeBatch(List<BufferedImage> crops) throws OrtException {
|
||||||
|
int B = crops.size();
|
||||||
|
if (B == 0) return Collections.emptyList();
|
||||||
|
|
||||||
|
// targetH 来自构造参数 (v5=48, v4=32, 通过 OcrEngine 从配置读取)
|
||||||
|
|
||||||
|
// 1. 计算每张 crop 的目标宽度 + batch 内最大宽度
|
||||||
|
int[] targetWs = new int[B];
|
||||||
|
int batchMaxW = 0;
|
||||||
|
for (int i = 0; i < B; i++) {
|
||||||
|
BufferedImage c = crops.get(i);
|
||||||
|
float ratio = (float) targetH / c.getHeight();
|
||||||
|
int w = Math.min(maxW, Math.max(1, Math.round(c.getWidth() * ratio)));
|
||||||
|
// pad 到 8 倍数 (CRNN 下采样 8x)
|
||||||
|
w = ((w + 7) / 8) * 8;
|
||||||
|
targetWs[i] = w;
|
||||||
|
if (w > batchMaxW) batchMaxW = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 拼成 [B, 3, 48, batchMaxW] — 每张图只在 [0..targetW] 区间写, 其余为 0
|
||||||
|
float[] inputData = new float[B * 3 * targetH * batchMaxW];
|
||||||
|
for (int b = 0; b < B; b++) {
|
||||||
|
int w = targetWs[b];
|
||||||
|
if (w == 0) continue;
|
||||||
|
BufferedImage resized = resize(crops.get(b), w, targetH);
|
||||||
|
int[] pixels = new int[w * targetH];
|
||||||
|
resized.getRGB(0, 0, w, targetH, pixels, 0, w);
|
||||||
|
int bOffset = b * 3 * targetH * batchMaxW;
|
||||||
|
for (int c = 0; c < 3; c++) {
|
||||||
|
int cOffset = bOffset + c * targetH * batchMaxW;
|
||||||
|
int meanC = (int) (mean[c] * 255);
|
||||||
|
int stdC = (int) (std[c] * 255);
|
||||||
|
for (int y = 0; y < targetH; y++) {
|
||||||
|
int rowStart = cOffset + y * batchMaxW;
|
||||||
|
int pixRowStart = y * w;
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
int argb = pixels[pixRowStart + x];
|
||||||
|
int v;
|
||||||
|
switch (c) {
|
||||||
|
case 0: v = (argb >> 16) & 0xFF; break;
|
||||||
|
case 1: v = (argb >> 8) & 0xFF; break;
|
||||||
|
default: v = argb & 0xFF;
|
||||||
|
}
|
||||||
|
// (v/255 - mean) / std, 避免浮点除法 (mean/std 都是 0.5)
|
||||||
|
inputData[rowStart + x] = (v - meanC) / (stdC * 1.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 推理
|
||||||
|
long[] shape = {B, 3, targetH, batchMaxW};
|
||||||
|
OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape);
|
||||||
|
Map<String, OnnxTensor> inputs = Collections.singletonMap("x", inputTensor);
|
||||||
|
|
||||||
|
List<RecognizedText> results = new ArrayList<>(B);
|
||||||
|
try (OrtSession.Result result = session.run(inputs)) {
|
||||||
|
OnnxTensor outTensor = (OnnxTensor) result.get(0);
|
||||||
|
Object val = outTensor.getValue();
|
||||||
|
float[][][] logits3d;
|
||||||
|
if (val instanceof float[][][]) {
|
||||||
|
logits3d = (float[][][]) val;
|
||||||
|
} else if (val instanceof float[][][][]) {
|
||||||
|
logits3d = ((float[][][][]) val)[0];
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass()));
|
||||||
|
}
|
||||||
|
for (int b = 0; b < B; b++) {
|
||||||
|
results.add(CtcDecoder.decode(logits3d[b], dict));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inputTensor.close();
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ONNX tensor 抽取 logits 为 [T, N] float[][]
|
||||||
|
*/
|
||||||
|
private static float[][] extractLogits(OnnxTensor tensor) throws OrtException {
|
||||||
|
Object val = tensor.getValue();
|
||||||
|
if (val instanceof float[][][]) {
|
||||||
|
return ((float[][][]) val)[0];
|
||||||
|
} else if (val instanceof float[][]) {
|
||||||
|
return (float[][]) val;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage resize(BufferedImage src, int w, int h) {
|
||||||
|
java.awt.Image tmp = src.getScaledInstance(w, h, java.awt.Image.SCALE_SMOOTH);
|
||||||
|
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||||
|
java.awt.Graphics2D g = dst.createGraphics();
|
||||||
|
g.drawImage(tmp, 0, 0, null);
|
||||||
|
g.dispose();
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws OrtException {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识别结果 */
|
||||||
|
public record RecognizedText(String text, double confidence) {}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ruoyi.business.ocr.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCR 识别超时异常 — 对齐 Python app.core.ocr_engine.OCRTimeout
|
||||||
|
* <p>
|
||||||
|
* 单页 OCR 超时 / 整流程超时时抛出, 由 RecognizeService 转成 error_code="timeout".
|
||||||
|
*/
|
||||||
|
public class OcrTimeoutException extends RuntimeException {
|
||||||
|
|
||||||
|
public OcrTimeoutException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OcrTimeoutException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
+434
@@ -0,0 +1,434 @@
|
|||||||
|
package com.ruoyi.business.ocr.service;
|
||||||
|
|
||||||
|
import com.ruoyi.business.ocr.InvoiceFields;
|
||||||
|
import com.ruoyi.business.ocr.OcrLine;
|
||||||
|
import com.ruoyi.business.ocr.util.AmountUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 OCR 文本/行里抽取发票字段 — 对齐 Python app.services.invoice_extractor
|
||||||
|
* <p>
|
||||||
|
* 适配中国大陆 增值税发票(电子普票 / 专票 / 电子专票 / 数电票).
|
||||||
|
* <p>
|
||||||
|
* 关键策略:
|
||||||
|
* - 主体按 OCR box 坐标判断归属 (左右两栏)
|
||||||
|
* - 名称提取加 stop word, 避免单行文本混淆
|
||||||
|
* - 金额兜底: tax + pretax = total 组合搜索
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class InvoiceExtractor {
|
||||||
|
|
||||||
|
// ---------- 发票类型 (按长度降序, 优先匹配最长前缀, 避免 "电子发票(增值税专用发票)" 被 "增值税专用发票" 抢先命中) ----------
|
||||||
|
private static final List<String> INVOICE_TYPES = Arrays.asList(
|
||||||
|
"电子发票(增值税专用发票)", // 13
|
||||||
|
"电子发票(增值税普通发票)", // 13
|
||||||
|
"增值税电子专用发票", // 9
|
||||||
|
"增值税电子普通发票", // 9
|
||||||
|
"增值税专用发票", // 7
|
||||||
|
"增值税普通发票", // 7
|
||||||
|
"通用机打发票", // 6
|
||||||
|
"数电票(电子发票)", // 8
|
||||||
|
"数电票", // 3
|
||||||
|
"电子发票" // 4
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- 发票号码 ----------
|
||||||
|
private static final Pattern NO_PATTERN = Pattern.compile(
|
||||||
|
"(?:发\\s*票\\s*号\\s*码|号\\s*码|No\\.?|号)\\s*[::]?\\s*(\\d{8,20})",
|
||||||
|
Pattern.CASE_INSENSITIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- 发票代码 ----------
|
||||||
|
private static final Pattern CODE_PATTERN = Pattern.compile(
|
||||||
|
"(?:发\\s*票\\s*代\\s*码|代\\s*码)\\s*[::]?\\s*(\\d{10,12}|\\d{8,12})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- 开票日期 ----------
|
||||||
|
private static final Pattern DATE_PATTERN = Pattern.compile(
|
||||||
|
"(?:开\\s*票\\s*日\\s*期|日\\s*期)\\s*[::]?\\s*" +
|
||||||
|
"(\\d{4})\\s*[年/\\.]\\s*(\\d{1,2})\\s*[月/\\.]\\s*(\\d{1,2})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- 纳税人识别号 (至少 1 个字母, 排除纯数字发票号) ----------
|
||||||
|
private static final Pattern TAX_NO_PATTERN = Pattern.compile("((?=[0-9A-Z]*[A-Z])[0-9A-Z]{18})");
|
||||||
|
|
||||||
|
// ---------- 主体标签 ----------
|
||||||
|
private static final Pattern BUYER_LABEL = Pattern.compile("购\\s*买\\s*方\\s*(?:信\\s*息|名\\s*称|)");
|
||||||
|
private static final Pattern SELLER_LABEL = Pattern.compile("销\\s*售\\s*方\\s*(?:信\\s*息|名\\s*称|)");
|
||||||
|
|
||||||
|
// ---------- 名称 (带 stop word 截断) ----------
|
||||||
|
private static final String NAME_STOP = "(?:销售方|购买方|统一社会信用|纳税人|项目名称|规格型号|^单位$|^数量$|^单价$|^金额|^税率|^税额|备注|收款人|复核|开票人|价税合计|小写|大写)";
|
||||||
|
private static final Pattern NAME_PATTERN = Pattern.compile(
|
||||||
|
"名\\s*称\\s*[::]\\s*" +
|
||||||
|
"((?:(?!" + NAME_STOP + ")[^\\n\\r]){2,60}?(?:公司|商店|厂|店|部|中心|工作室))"
|
||||||
|
);
|
||||||
|
// 不依赖 "名称:" 前缀 — 用于 PDF 内嵌文本拆字版式 (label 和 value 分两段)
|
||||||
|
// 不强制 lookback 拒绝中文 (OCR 行可能整行连在一起如 "名称北京国钜...公司"), 靠 cleanName 截 stop word 过滤杂质
|
||||||
|
private static final Pattern COMPANY_PATTERN = Pattern.compile(
|
||||||
|
"([一-龥A-Za-z0-9()()·\\-]{2,30}(?:公司|商店|厂|店|部|中心|工作室))"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- 数字候选 ----------
|
||||||
|
private static final Pattern DECIMAL_PATTERN = Pattern.compile("(\\d+\\.\\d{2})");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入口: 从文本 + (可选) 行列表抽取
|
||||||
|
*
|
||||||
|
* @param text OCR 全文
|
||||||
|
* @param lines OCR 行列表 (含 box)
|
||||||
|
* @param qrTotalAmount QR 解出的金额 (权威). 不为 null 时, 强制作为 total 用于 fallback 组合搜索.
|
||||||
|
*/
|
||||||
|
public InvoiceFields extract(String text, List<OcrLine> lines, Double qrTotalAmount) {
|
||||||
|
String norm = norm(text);
|
||||||
|
InvoiceFields fields = new InvoiceFields();
|
||||||
|
|
||||||
|
fields.setInvoiceType(detectInvoiceType(norm));
|
||||||
|
fields.setInvoiceCode(extractInvoiceCode(norm));
|
||||||
|
fields.setInvoiceNo(extractInvoiceNo(norm));
|
||||||
|
fields.setInvoiceDate(extractDate(norm));
|
||||||
|
|
||||||
|
// 金额 — 优先用 QR 提供的 total (权威, 数电票/电子发票的价税合计在 QR 里),
|
||||||
|
// QR 缺失时回退 OCR 的 TOTAL_PATTERN / 第一个数字
|
||||||
|
Double total;
|
||||||
|
if (qrTotalAmount != null) {
|
||||||
|
total = qrTotalAmount;
|
||||||
|
} else {
|
||||||
|
total = AmountUtils.extractTotalAmount(norm);
|
||||||
|
}
|
||||||
|
Double tax = AmountUtils.extractTaxAmount(norm);
|
||||||
|
Double pretax = AmountUtils.extractPretaxAmount(norm);
|
||||||
|
|
||||||
|
// 兜底 1: tax + pretax = total 组合搜索
|
||||||
|
if ((tax == null || pretax == null) && total != null) {
|
||||||
|
List<Double> candidates = new ArrayList<>();
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
Matcher m = DECIMAL_PATTERN.matcher(norm);
|
||||||
|
while (m.find()) {
|
||||||
|
String s = m.group(1);
|
||||||
|
double v = Double.parseDouble(s);
|
||||||
|
if (v < total && seen.add(s)) {
|
||||||
|
candidates.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort(Comparator.reverseOrder());
|
||||||
|
for (int i = 0; i < candidates.size(); i++) {
|
||||||
|
double a = candidates.get(i);
|
||||||
|
for (int j = i + 1; j < candidates.size(); j++) {
|
||||||
|
double b = candidates.get(j);
|
||||||
|
if (Math.abs(a + b - total) < 0.011) {
|
||||||
|
if (pretax == null) pretax = round2(a);
|
||||||
|
if (tax == null) tax = round2(b);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tax != null && pretax != null) break;
|
||||||
|
}
|
||||||
|
// 兜底: 只剩一个候选
|
||||||
|
if ((tax == null || pretax == null) && candidates.size() == 1) {
|
||||||
|
double only = round2(candidates.get(0));
|
||||||
|
if (pretax == null && tax == null) {
|
||||||
|
pretax = only;
|
||||||
|
tax = round2(total - only);
|
||||||
|
} else if (tax == null) {
|
||||||
|
tax = only;
|
||||||
|
} else if (pretax == null) {
|
||||||
|
pretax = only;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底 2: total - 任一 = 另一
|
||||||
|
if (tax == null && total != null && pretax != null) {
|
||||||
|
tax = round2(total - pretax);
|
||||||
|
}
|
||||||
|
if (pretax == null && total != null && tax != null) {
|
||||||
|
pretax = round2(total - tax);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.setAmount(total);
|
||||||
|
fields.setTaxAmount(tax);
|
||||||
|
fields.setAmountPretax(pretax);
|
||||||
|
fields.setAmountCn(AmountUtils.extractCnAmount(norm));
|
||||||
|
fields.setAmountMatch(AmountUtils.amountConsistent(fields.getAmountCn(), fields.getAmount()));
|
||||||
|
|
||||||
|
// 主体
|
||||||
|
String sellerName, sellerTax, buyerName, buyerTax;
|
||||||
|
if (lines != null && !lines.isEmpty()) {
|
||||||
|
String[] parties = extractPartiesFromLines(lines);
|
||||||
|
sellerName = parties[0]; sellerTax = parties[1];
|
||||||
|
buyerName = parties[2]; buyerTax = parties[3];
|
||||||
|
if (!(sellerName != null && buyerName != null)) {
|
||||||
|
String[] textParties = extractPartiesFromText(norm);
|
||||||
|
sellerName = or(sellerName, textParties[0]);
|
||||||
|
sellerTax = or(sellerTax, textParties[1]);
|
||||||
|
buyerName = or(buyerName, textParties[2]);
|
||||||
|
buyerTax = or(buyerTax, textParties[3]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String[] textParties = extractPartiesFromText(norm);
|
||||||
|
sellerName = textParties[0]; sellerTax = textParties[1];
|
||||||
|
buyerName = textParties[2]; buyerTax = textParties[3];
|
||||||
|
}
|
||||||
|
fields.setSellerName(sellerName);
|
||||||
|
fields.setSellerTaxNo(sellerTax);
|
||||||
|
fields.setBuyerName(buyerName);
|
||||||
|
fields.setBuyerTaxNo(buyerTax);
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String or(String a, String b) {
|
||||||
|
return a != null ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double round2(double v) {
|
||||||
|
return Math.round(v * 100.0) / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String norm(String text) {
|
||||||
|
return text == null ? "" : text.replaceAll("\\s+", " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String detectInvoiceType(String text) {
|
||||||
|
for (String t : INVOICE_TYPES) {
|
||||||
|
if (text.contains(t)) return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractInvoiceNo(String text) {
|
||||||
|
Matcher m = NO_PATTERN.matcher(text);
|
||||||
|
return m.find() ? m.group(1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractInvoiceCode(String text) {
|
||||||
|
Matcher m = CODE_PATTERN.matcher(text);
|
||||||
|
return m.find() ? m.group(1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractDate(String text) {
|
||||||
|
Matcher m = DATE_PATTERN.matcher(text);
|
||||||
|
if (!m.find()) return null;
|
||||||
|
int y = Integer.parseInt(m.group(1));
|
||||||
|
int mo = Integer.parseInt(m.group(2));
|
||||||
|
int d = Integer.parseInt(m.group(3));
|
||||||
|
return String.format("%04d-%02d-%02d", y, mo, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cleanName(String name) {
|
||||||
|
if (name == null) return null;
|
||||||
|
String s = name;
|
||||||
|
// OCR 容易把 "名称北京国钜..." 整段匹出来 (名称 是字符类里的字), 剥掉前缀保留公司名
|
||||||
|
for (String prefix : new String[]{"名称", "购买方", "销售方", "买方", "卖方", "购方", "销方"}) {
|
||||||
|
if (s.startsWith(prefix)) s = s.substring(prefix.length());
|
||||||
|
}
|
||||||
|
// 截断 stop word (label 残留: OCR/PDF 都可能把 label 跟 value 拼在一起)
|
||||||
|
for (String stop : new String[]{"纳税人", "统一社会", "购买方", "销售方",
|
||||||
|
"项目名称", "规格型号", "单价", "数量", "金额", "税率", "税额",
|
||||||
|
"价税合计", "大写", "小写", "备注", "收款", "复核", "开票"}) {
|
||||||
|
int idx = s.indexOf(stop);
|
||||||
|
if (idx >= 0) s = s.substring(0, idx);
|
||||||
|
}
|
||||||
|
// 去掉前导符号
|
||||||
|
s = s.replaceAll("^[\\s::,,。、]+", "");
|
||||||
|
// 只保留中文/字母/数字/()/-/·
|
||||||
|
s = s.replaceAll("[^一-龥A-Za-z0-9()()·\\-]", "");
|
||||||
|
s = s.replaceAll("[::;,,。、 ]+$", "").trim();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* box: [[x1,y1], ...] → (cx, cy)
|
||||||
|
*/
|
||||||
|
private static double[] boxCenter(List<List<Float>> box) {
|
||||||
|
if (box == null || box.size() < 4) return new double[]{0, 0};
|
||||||
|
double minX = Double.MAX_VALUE, maxX = -Double.MAX_VALUE;
|
||||||
|
double minY = Double.MAX_VALUE, maxY = -Double.MAX_VALUE;
|
||||||
|
for (List<Float> p : box) {
|
||||||
|
if (p.size() < 2) continue;
|
||||||
|
double x = p.get(0), y = p.get(1);
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
return new double[]{(minX + maxX) / 2, (minY + maxY) / 2};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 [seller_name, seller_tax, buyer_name, buyer_tax]
|
||||||
|
* <p>
|
||||||
|
* 策略:
|
||||||
|
* - byX 分支 (左右栏): 用 buyer/seller 标签的 x 坐标分栏. **用 COMPANY_PATTERN** (不依赖"名称:"前缀).
|
||||||
|
* - 上下栏 / 单栏分支: 按 y 排序, **第一个 = 销售方** (中国数电票/电子专票版式 — 销方先印).
|
||||||
|
*/
|
||||||
|
private String[] extractPartiesFromLines(List<OcrLine> lines) {
|
||||||
|
List<List<Float>> buyerLabelBox = null, sellerLabelBox = null;
|
||||||
|
for (OcrLine line : lines) {
|
||||||
|
if (buyerLabelBox == null && BUYER_LABEL.matcher(line.getText() != null ? line.getText() : "").find()) {
|
||||||
|
buyerLabelBox = line.getBox();
|
||||||
|
}
|
||||||
|
if (sellerLabelBox == null && SELLER_LABEL.matcher(line.getText() != null ? line.getText() : "").find()) {
|
||||||
|
sellerLabelBox = line.getBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean byX = buyerLabelBox != null && sellerLabelBox != null
|
||||||
|
&& Math.abs(boxCenter(buyerLabelBox)[0] - boxCenter(sellerLabelBox)[0]) > 50;
|
||||||
|
|
||||||
|
String sellerName = null, sellerTax = null, buyerName = null, buyerTax = null;
|
||||||
|
|
||||||
|
if (byX) {
|
||||||
|
double[] bc = boxCenter(buyerLabelBox);
|
||||||
|
double[] sc = boxCenter(sellerLabelBox);
|
||||||
|
double mid = (bc[0] + sc[0]) / 2;
|
||||||
|
for (OcrLine line : lines) {
|
||||||
|
if (line.getText() == null) continue;
|
||||||
|
// 用 COMPANY_PATTERN 替代 NAME_PATTERN — 不依赖 "名称:" 前缀, 对拆字/拼接行更鲁棒
|
||||||
|
Matcher nm = COMPANY_PATTERN.matcher(line.getText());
|
||||||
|
if (nm.find()) {
|
||||||
|
double cx = boxCenter(line.getBox())[0];
|
||||||
|
String cleaned = cleanName(nm.group(1));
|
||||||
|
if (cleaned == null || containsStopWord(cleaned)) continue;
|
||||||
|
if (cx < mid && buyerName == null) buyerName = cleaned;
|
||||||
|
else if (cx >= mid && sellerName == null) sellerName = cleaned;
|
||||||
|
}
|
||||||
|
Matcher tm = TAX_NO_PATTERN.matcher(line.getText());
|
||||||
|
if (tm.find()) {
|
||||||
|
double cx = boxCenter(line.getBox())[0];
|
||||||
|
String tax = tm.group(1);
|
||||||
|
if (cx < mid && buyerTax == null) buyerTax = tax;
|
||||||
|
else if (cx >= mid && sellerTax == null) sellerTax = tax;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没找到 buyer/seller 标签 box → 自动检测栏位 (左右栏 vs 上下栏)
|
||||||
|
// 收集所有 (name, cx, cy) 和 (tax, cx, cy) 候选
|
||||||
|
List<double[]> nameCoords = new ArrayList<>(); // [cx, cy]
|
||||||
|
List<String> nameVals = new ArrayList<>();
|
||||||
|
List<double[]> taxCoords = new ArrayList<>();
|
||||||
|
List<String> taxVals = new ArrayList<>();
|
||||||
|
for (OcrLine line : lines) {
|
||||||
|
if (line.getText() == null) continue;
|
||||||
|
double[] c = boxCenter(line.getBox());
|
||||||
|
Matcher nm = COMPANY_PATTERN.matcher(line.getText());
|
||||||
|
if (nm.find()) {
|
||||||
|
String cleaned = cleanName(nm.group(1));
|
||||||
|
log.info("DEBUG company match: text={} match={} cleaned={} stopWord={}",
|
||||||
|
line.getText(), nm.group(1), cleaned, containsStopWord(cleaned));
|
||||||
|
if (cleaned != null && !containsStopWord(cleaned)) {
|
||||||
|
nameCoords.add(c);
|
||||||
|
nameVals.add(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Matcher tm = TAX_NO_PATTERN.matcher(line.getText());
|
||||||
|
if (tm.find()) {
|
||||||
|
taxCoords.add(c);
|
||||||
|
taxVals.add(tm.group(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动判断: 前两个 name 的 |dy| < 30 且 |dx| > 200 → 左右栏
|
||||||
|
boolean leftRight = false;
|
||||||
|
if (nameCoords.size() >= 2) {
|
||||||
|
double dx = Math.abs(nameCoords.get(0)[0] - nameCoords.get(1)[0]);
|
||||||
|
double dy = Math.abs(nameCoords.get(0)[1] - nameCoords.get(1)[1]);
|
||||||
|
leftRight = dy < 30 && dx > 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftRight) {
|
||||||
|
// 左右栏: 按 x 排序, **x 小 = 买方 (购买方在左), x 大 = 卖方 (销售方在右)**
|
||||||
|
sortByFirstCoord(nameCoords, nameVals, 0);
|
||||||
|
sortByFirstCoord(taxCoords, taxVals, 0);
|
||||||
|
} else {
|
||||||
|
// 上下栏: 按 y 排序, **第一个 = 买方** (国家税务总局标准: 购方信息在前)
|
||||||
|
sortByFirstCoord(nameCoords, nameVals, 1);
|
||||||
|
sortByFirstCoord(taxCoords, taxVals, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameVals.size() > 0) buyerName = nameVals.get(0);
|
||||||
|
if (taxVals.size() > 0) buyerTax = taxVals.get(0);
|
||||||
|
if (nameVals.size() > 1) sellerName = nameVals.get(1);
|
||||||
|
if (taxVals.size() > 1) sellerTax = taxVals.get(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new String[]{sellerName, sellerTax, buyerName, buyerTax};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按指定坐标 (0=x, 1=y) 同步排序坐标和值列表
|
||||||
|
*/
|
||||||
|
private static void sortByFirstCoord(List<double[]> coords, List<String> vals, int dim) {
|
||||||
|
List<Integer> idx = new ArrayList<>();
|
||||||
|
for (int i = 0; i < coords.size(); i++) idx.add(i);
|
||||||
|
idx.sort(Comparator.comparingDouble(i -> coords.get(i)[dim]));
|
||||||
|
List<double[]> sc = new ArrayList<>();
|
||||||
|
List<String> sv = new ArrayList<>();
|
||||||
|
for (int i : idx) {
|
||||||
|
sc.add(coords.get(i));
|
||||||
|
sv.add(vals.get(i));
|
||||||
|
}
|
||||||
|
coords.clear(); coords.addAll(sc);
|
||||||
|
vals.clear(); vals.addAll(sv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无 box 信息时的 fallback: 全局收集 name + tax, 按出现顺序配对.
|
||||||
|
* <p>
|
||||||
|
* 中国电子发票版式 (数电票 / 电子专票): 国家税务总局标准 — **购买方信息在前(左/上), 销售方信息在后(右/下)**.
|
||||||
|
* PDF 文字流的 value 顺序通常按视觉顺序 (左/上 先), 所以:
|
||||||
|
* **第一个匹配 = 购买方, 第二个匹配 = 销售方**.
|
||||||
|
* <p>
|
||||||
|
* 修复:
|
||||||
|
* 1. PDF 内嵌文本拆字版式 ("购/买/方/信/息") — 旧 "在标签后 200 字符找 name" 失效 (label/value 分段)
|
||||||
|
* 2. 用 COMPANY_PATTERN (不依赖 "名称:" 前缀) 替代 NAME_PATTERN
|
||||||
|
* 3. 第一个 = 买方 (国家税务总局标准: 购方信息在前)
|
||||||
|
*
|
||||||
|
* @return [seller_name, seller_tax, buyer_name, buyer_tax]
|
||||||
|
*/
|
||||||
|
private String[] extractPartiesFromText(String text) {
|
||||||
|
// 1. 全局收集所有公司名 (按出现顺序, 去重叠, 去 stop word)
|
||||||
|
List<String> cleanedNames = new ArrayList<>();
|
||||||
|
Matcher nm = COMPANY_PATTERN.matcher(text);
|
||||||
|
int lastEnd = -1;
|
||||||
|
while (nm.find()) {
|
||||||
|
if (nm.start() < lastEnd) continue; // 去重叠
|
||||||
|
String c = cleanName(nm.group(1));
|
||||||
|
if (c != null && !containsStopWord(c)) cleanedNames.add(c);
|
||||||
|
lastEnd = nm.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 全局收集所有税号 (按出现顺序)
|
||||||
|
List<String> taxes = new ArrayList<>();
|
||||||
|
Matcher tm = TAX_NO_PATTERN.matcher(text);
|
||||||
|
while (tm.find()) taxes.add(tm.group(1));
|
||||||
|
|
||||||
|
// 3. 配对 (按位置一一对应): name[0]<->tax[0] = 买方, name[1]<->tax[1] = 卖方
|
||||||
|
String buyerName = cleanedNames.size() > 0 ? cleanedNames.get(0) : null;
|
||||||
|
String buyerTax = taxes.size() > 0 ? taxes.get(0) : null;
|
||||||
|
String sellerName = cleanedNames.size() > 1 ? cleanedNames.get(1) : null;
|
||||||
|
String sellerTax = taxes.size() > 1 ? taxes.get(1) : null;
|
||||||
|
|
||||||
|
return new String[]{sellerName, sellerTax, buyerName, buyerTax};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公司名 stop word 过滤 (cleanName 已经过滤一些, 这里再覆盖):
|
||||||
|
* 表格项目名 ("项目名称"), 规格型号 ("规格"), 备注 ("备注"), 单位, 数量 等.
|
||||||
|
* <p>
|
||||||
|
* 注意: "名称" 不在这里过滤 — cleanName 会截断 "名称" 前缀; 如果 cleanName 截断后还是包含 "名称"
|
||||||
|
* 才在这里过滤 (防止截断失败导致整个公司名被拒).
|
||||||
|
*/
|
||||||
|
private static boolean containsStopWord(String s) {
|
||||||
|
if (s == null) return true;
|
||||||
|
// 只过滤明显不是公司名的杂质 (cleanName 已经处理过大部分 stop word)
|
||||||
|
return s.contains("价税合计") || s.contains("大写") || s.contains("小写")
|
||||||
|
|| s.contains("项目名称") || s.contains("规格型号")
|
||||||
|
|| s.equals("公司") || s.length() < 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.ruoyi.business.ocr.service;
|
||||||
|
|
||||||
|
import com.google.zxing.*;
|
||||||
|
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
|
||||||
|
import com.google.zxing.common.HybridBinarizer;
|
||||||
|
import com.ruoyi.business.ocr.QrDecodeResult;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电子发票二维码识别 — 对齐 Python app.services.qr_decoder
|
||||||
|
* <p>
|
||||||
|
* 国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔):
|
||||||
|
* 01,<type>,<invoice_code>,<invoice_no>,<amount>,<date>,<check_code>,<reserved>
|
||||||
|
* <p>
|
||||||
|
* 例: 01,31,,24922000000006110014,39500.00,20240202,,A371
|
||||||
|
* <p>
|
||||||
|
* 使用 ZXing (纯 Java, 无需 opencv).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class QrDecoder {
|
||||||
|
|
||||||
|
private static final MultiFormatReader ZXING_READER = new MultiFormatReader();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从图片文件解电子发票二维码
|
||||||
|
*/
|
||||||
|
public QrDecodeResult decodeQr(Path imagePath) {
|
||||||
|
String raw;
|
||||||
|
try {
|
||||||
|
raw = detectQr(imagePath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("QR 检测异常 {}: {}", imagePath.getFileName(), e.getMessage());
|
||||||
|
return new QrDecodeResult();
|
||||||
|
}
|
||||||
|
if (raw == null || raw.isEmpty()) {
|
||||||
|
return new QrDecodeResult();
|
||||||
|
}
|
||||||
|
QrDecodeResult parsed = parsePayload(raw);
|
||||||
|
if (parsed.hasAnyField()) {
|
||||||
|
log.info("QR 解码成功: no={}, amt={}, date={}",
|
||||||
|
parsed.getInvoiceNo(), parsed.getAmount(), parsed.getInvoiceDate());
|
||||||
|
} else {
|
||||||
|
log.debug("QR 解出但字段无效: raw={}", raw.substring(0, Math.min(80, raw.length())));
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全图 → 4 象限 → 2x 放大, 任一命中即返回.
|
||||||
|
*/
|
||||||
|
private String detectQr(Path imagePath) throws IOException {
|
||||||
|
BufferedImage img = ImageIO.read(imagePath.toFile());
|
||||||
|
if (img == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// 1) 全图
|
||||||
|
String txt = tryDecode(img);
|
||||||
|
if (txt != null && !txt.isEmpty()) {
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
// 2) 四象限
|
||||||
|
int h = img.getHeight();
|
||||||
|
int w = img.getWidth();
|
||||||
|
BufferedImage[][] crops = {
|
||||||
|
{img.getSubimage(0, 0, w / 2, h / 2), img.getSubimage(w / 2, 0, w - w / 2, h / 2)},
|
||||||
|
{img.getSubimage(0, h / 2, w / 2, h - h / 2), img.getSubimage(w / 2, h / 2, w - w / 2, h - h / 2)}
|
||||||
|
};
|
||||||
|
String[] names = {"left-top", "right-top", "left-bottom", "right-bottom"};
|
||||||
|
int idx = 0;
|
||||||
|
for (BufferedImage[] row : crops) {
|
||||||
|
for (BufferedImage crop : row) {
|
||||||
|
txt = tryDecode(crop);
|
||||||
|
if (txt != null && !txt.isEmpty()) {
|
||||||
|
log.debug("QR found in {}", names[idx]);
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) 2x 放大 (二维码像素过小的情况)
|
||||||
|
BufferedImage scaled = resize(img, 2.0);
|
||||||
|
return tryDecode(scaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tryDecode(BufferedImage img) {
|
||||||
|
try {
|
||||||
|
LuminanceSource source = new BufferedImageLuminanceSource(img);
|
||||||
|
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
||||||
|
Result result = ZXING_READER.decode(bitmap);
|
||||||
|
return result != null ? result.getText() : "";
|
||||||
|
} catch (NotFoundException e) {
|
||||||
|
return "";
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("ZXing decode error: {}", e.getMessage());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage resize(BufferedImage src, double scale) {
|
||||||
|
int w = (int) (src.getWidth() * scale);
|
||||||
|
int h = (int) (src.getHeight() * scale);
|
||||||
|
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g = dst.createGraphics();
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||||
|
g.drawImage(src, 0, 0, w, h, null);
|
||||||
|
g.dispose();
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析电子发票二维码内容 → QrDecodeResult
|
||||||
|
* <p>
|
||||||
|
* 字段全 null 表示无二维码或格式不正确.
|
||||||
|
*/
|
||||||
|
private QrDecodeResult parsePayload(String raw) {
|
||||||
|
if (raw == null || raw.isEmpty()) {
|
||||||
|
return new QrDecodeResult();
|
||||||
|
}
|
||||||
|
String[] parts = raw.split(",", -1);
|
||||||
|
if (parts.length != 8) {
|
||||||
|
log.debug("QR 字段数 {} != 8, 视为格式不正确", parts.length);
|
||||||
|
QrDecodeResult r = new QrDecodeResult();
|
||||||
|
r.setRaw(raw);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
QrDecodeResult result = new QrDecodeResult();
|
||||||
|
result.setRaw(raw);
|
||||||
|
|
||||||
|
// parts[3] = 发票号
|
||||||
|
String invoiceNo = parts[3].trim();
|
||||||
|
if (!invoiceNo.isEmpty() && invoiceNo.length() >= 10 && invoiceNo.length() <= 30) {
|
||||||
|
result.setInvoiceNo(invoiceNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parts[4] = 金额
|
||||||
|
String amtStr = parts[4].trim();
|
||||||
|
if (!amtStr.isEmpty()) {
|
||||||
|
try {
|
||||||
|
result.setAmount(Math.round(Double.parseDouble(amtStr) * 100.0) / 100.0);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parts[5] = 开票日期 (YYYYMMDD)
|
||||||
|
String dateStr = parts[5].trim();
|
||||||
|
if (dateStr.length() == 8 && dateStr.chars().allMatch(Character::isDigit)) {
|
||||||
|
result.setInvoiceDate(dateStr.substring(0, 4) + "-" + dateStr.substring(4, 6) + "-" + dateStr.substring(6, 8));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
package com.ruoyi.business.ocr.service;
|
||||||
|
|
||||||
|
import com.ruoyi.business.ocr.config.OcrProperties;
|
||||||
|
import com.ruoyi.business.ocr.core.ImageProcessor;
|
||||||
|
import com.ruoyi.business.ocr.core.OcrEngine;
|
||||||
|
import com.ruoyi.business.ocr.core.PdfProcessor;
|
||||||
|
import com.ruoyi.business.ocr.exception.OcrTimeoutException;
|
||||||
|
import com.ruoyi.business.ocr.InvoiceFields;
|
||||||
|
import com.ruoyi.business.ocr.InvoiceResult;
|
||||||
|
import com.ruoyi.business.ocr.OcrLine;
|
||||||
|
import com.ruoyi.business.ocr.QrDecodeResult;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 端到端识别流水: 文件 → QR → (可选)OCR → 字段抽取 → InvoiceResult
|
||||||
|
* <p>
|
||||||
|
* 完全对齐 Python app.services.recognize_service:
|
||||||
|
* <pre>
|
||||||
|
* 1. PDF / 图片 → BufferedImage 列表 (page_count)
|
||||||
|
* 2. qr = decode_qr(page[0])
|
||||||
|
* 3. qr_ok && !qr_full_ocr → [QR only] 快路径返回
|
||||||
|
* 4. !qr_ok → not_invoice, 不跑 OCR
|
||||||
|
* 5. for each page:
|
||||||
|
* if elapsed > total_deadline → timeout
|
||||||
|
* try _ocr_image(page) with page_timeout
|
||||||
|
* 6. extract_invoice(raw_text, lines)
|
||||||
|
* 7. overlay_qr_fields(qr) ← QR 3 字段覆盖 OCR 结果
|
||||||
|
* 8. return InvoiceResult
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RecognizeService {
|
||||||
|
|
||||||
|
private static final Set<String> IMG_EXTS = Set.of(".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff");
|
||||||
|
|
||||||
|
private final OcrProperties props;
|
||||||
|
private final PdfProcessor pdfProcessor;
|
||||||
|
private final OcrEngine ocrEngine;
|
||||||
|
private final QrDecoder qrDecoder;
|
||||||
|
private final InvoiceExtractor extractor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入口: 上传文件字节流
|
||||||
|
*/
|
||||||
|
public InvoiceResult recognizeFile(String filename, byte[] content) {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
String suffix = filename == null ? ".bin" : ext(filename);
|
||||||
|
Path tmp = saveUpload(content, suffix);
|
||||||
|
try {
|
||||||
|
return recognizePath(tmp, true);
|
||||||
|
} finally {
|
||||||
|
// 已在 recognizePath finally 里删, 这里兜底
|
||||||
|
try { Files.deleteIfExists(tmp); } catch (IOException ignored) {}
|
||||||
|
log.info("总耗时 {}ms", System.currentTimeMillis() - t0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入口: 服务器本地路径 (已在白名单校验)
|
||||||
|
*/
|
||||||
|
public InvoiceResult recognizePath(Path filePath, boolean deleteAfter) {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
String suffix = ext(filePath.getFileName().toString());
|
||||||
|
long totalDeadline = t0 + props.getOcr().getTotalTimeoutS() * 1000L;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. PDF / 图片 → 临时图片列表
|
||||||
|
List<Path> pageImgs;
|
||||||
|
try {
|
||||||
|
if (suffix.equals(".pdf")) {
|
||||||
|
pageImgs = pdfProcessor.pdfToImages(filePath);
|
||||||
|
} else if (IMG_EXTS.contains(suffix)) {
|
||||||
|
pageImgs = List.of(filePath);
|
||||||
|
} else {
|
||||||
|
return err("不支持的文件类型: " + suffix + "(仅支持 PDF / 图片)",
|
||||||
|
"unsupported", false, 1, t0);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("PDF/图片处理失败: {}", e.getMessage(), e);
|
||||||
|
return err("PDF/图片处理失败: " + e.getMessage(),
|
||||||
|
"process_failed", false, 1, t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. QR 优先识别
|
||||||
|
QrDecodeResult qr = qrDecoder.decodeQr(pageImgs.get(0));
|
||||||
|
boolean qrOk = qr.hasAnyField();
|
||||||
|
|
||||||
|
// 3. 快路径: QR 命中且 fast 模式
|
||||||
|
if (qrOk && !props.getOcr().isQrFullOcr()) {
|
||||||
|
log.info("QR 快路径: {} 耗时 {}ms", filePath.getFileName(), elapsedMs(t0));
|
||||||
|
return buildQrOnlyResult(qr, elapsedMs(t0), pageImgs.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 无有效 QR → 非发票, 不跑 OCR
|
||||||
|
if (!qrOk) {
|
||||||
|
String reason = qr.getRaw() == null || qr.getRaw().isEmpty()
|
||||||
|
? "未识别到发票二维码"
|
||||||
|
: "二维码格式不合法";
|
||||||
|
log.info("非发票 (无有效 QR): {} 耗时 {}ms", filePath.getFileName(), elapsedMs(t0));
|
||||||
|
InvoiceResult r = err(reason + "(可能不是发票图片)",
|
||||||
|
"not_invoice", false, pageImgs.size(), t0);
|
||||||
|
r.setFromQr(false);
|
||||||
|
r.setQrRaw(qr.getRaw() == null || qr.getRaw().isEmpty() ? null : qr.getRaw());
|
||||||
|
r.setQrError(qr.getRaw() == null || qr.getRaw().isEmpty() ? "no_qr" : "bad_format");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4.5 PDF 内嵌文本 fast path (跳过 ONNX, 直接 PDFTextStripper)
|
||||||
|
// - 适用电子发票 / 数电票 PDF (含真实文本层)
|
||||||
|
// - 扫描件 PDF 抽不到文本, 自动回退到 ONNX
|
||||||
|
if (suffix.equals(".pdf") && props.getOcr().isUsePdfTextFirst()) {
|
||||||
|
try {
|
||||||
|
String pdfText = pdfProcessor.extractText(filePath);
|
||||||
|
int minChars = props.getOcr().getPdfTextMinChars();
|
||||||
|
if (pdfText.length() >= minChars) {
|
||||||
|
int elapsed = elapsedMs(t0);
|
||||||
|
log.info("PDF 内嵌文本 fast path: {} chars={}, 跳过 ONNX, 耗时 {}ms",
|
||||||
|
filePath.getFileName(), pdfText.length(), elapsed);
|
||||||
|
InvoiceFields fields = extractor.extract(pdfText, java.util.Collections.emptyList(), qr.getAmount());
|
||||||
|
fields = overlayQrFields(fields, qr);
|
||||||
|
InvoiceResult r = new InvoiceResult();
|
||||||
|
r.setSuccess(true);
|
||||||
|
r.setIsInvoice(true);
|
||||||
|
r.setRawText(pdfText);
|
||||||
|
r.setFields(fields);
|
||||||
|
r.setPageCount(pageImgs.size());
|
||||||
|
r.setEngine("pdftxt");
|
||||||
|
r.setElapsedMs(elapsed);
|
||||||
|
r.setFromQr(true);
|
||||||
|
r.setQrRaw(qr.getRaw());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
log.info("PDF 内嵌文本太短 ({} 字符 < {}), 回退 ONNX", pdfText.length(), minChars);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("PDF 内嵌文本抽取失败, 回退 ONNX: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 每页 OCR
|
||||||
|
List<OcrLine> allLines = new ArrayList<>();
|
||||||
|
for (int idx = 0; idx < pageImgs.size(); idx++) {
|
||||||
|
long remaining = totalDeadline - System.currentTimeMillis();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
log.warn("达到总超时 ({}s), 中断 OCR", props.getOcr().getTotalTimeoutS());
|
||||||
|
InvoiceResult r = err(String.format("达到总超时 (%d秒), 已识别 %d/%d 页",
|
||||||
|
props.getOcr().getTotalTimeoutS(), idx, pageImgs.size()),
|
||||||
|
"timeout", false, pageImgs.size(), t0);
|
||||||
|
r.setRawText(joinLines(allLines));
|
||||||
|
r.setLines(allLines);
|
||||||
|
r.setFromQr(true);
|
||||||
|
r.setQrRaw(qr.getRaw());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
int pageTimeout = (int) Math.min(props.getOcr().getPageTimeoutS(), remaining / 1000.0);
|
||||||
|
try {
|
||||||
|
allLines.addAll(ocrImage(pageImgs.get(idx)));
|
||||||
|
} catch (OcrTimeoutException e) {
|
||||||
|
log.warn("第 {} 页 OCR 超时: {}", idx + 1, e.getMessage());
|
||||||
|
InvoiceResult r = err(String.format("第 %d 页识别超时 (%.1f秒)",
|
||||||
|
idx + 1, pageTimeout * 1.0),
|
||||||
|
"timeout", false, pageImgs.size(), t0);
|
||||||
|
r.setRawText(joinLines(allLines));
|
||||||
|
r.setLines(allLines);
|
||||||
|
r.setFromQr(true);
|
||||||
|
r.setQrRaw(qr.getRaw());
|
||||||
|
return r;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("第 {} 页 OCR 失败: {}", idx + 1, e.getMessage(), e);
|
||||||
|
InvoiceResult r = err(String.format("第 %d 页识别失败: %s", idx + 1, e.getMessage()),
|
||||||
|
"ocr_failed", false, pageImgs.size(), t0);
|
||||||
|
r.setRawText(joinLines(allLines));
|
||||||
|
r.setLines(allLines);
|
||||||
|
r.setFromQr(true);
|
||||||
|
r.setQrRaw(qr.getRaw());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 字段抽取 (QR total 优先) + QR 字段覆盖
|
||||||
|
String rawText = joinLines(allLines);
|
||||||
|
// 把 QR 的 amount 提前传给 extractor, 让 fallback 组合搜索能用对的总价.
|
||||||
|
InvoiceFields fields = extractor.extract(rawText, allLines, qr.getAmount());
|
||||||
|
fields = overlayQrFields(fields, qr);
|
||||||
|
|
||||||
|
int elapsed = elapsedMs(t0);
|
||||||
|
log.info("识别完成: {} 页={}, from_qr=true, 耗时={}ms", filePath.getFileName(), pageImgs.size(), elapsed);
|
||||||
|
InvoiceResult r = new InvoiceResult();
|
||||||
|
r.setSuccess(true);
|
||||||
|
r.setIsInvoice(true);
|
||||||
|
r.setRawText(rawText);
|
||||||
|
r.setLines(allLines);
|
||||||
|
r.setFields(fields);
|
||||||
|
r.setPageCount(pageImgs.size());
|
||||||
|
r.setEngine("paddleocr");
|
||||||
|
r.setElapsedMs(elapsed);
|
||||||
|
r.setFromQr(true);
|
||||||
|
r.setQrRaw(qr.getRaw());
|
||||||
|
return r;
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
if (deleteAfter) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(filePath);
|
||||||
|
if (suffix.equals(".pdf")) {
|
||||||
|
// 清理 .xxx_pages/ 临时目录
|
||||||
|
Path pagesDir = filePath.getParent().resolve("." + stem(filePath) + "_pages");
|
||||||
|
if (Files.exists(pagesDir)) {
|
||||||
|
try (var stream = Files.walk(pagesDir)) {
|
||||||
|
stream.sorted((a, b) -> b.compareTo(a)).forEach(p -> {
|
||||||
|
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 内部 ----------
|
||||||
|
|
||||||
|
private List<OcrLine> ocrImage(Path imgPath) {
|
||||||
|
Path rotated = ImageProcessor.autoRotate(imgPath);
|
||||||
|
Path enhanced = ImageProcessor.enhance(rotated);
|
||||||
|
return ocrEngine.recognize(enhanced);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String joinLines(List<OcrLine> lines) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < lines.size(); i++) {
|
||||||
|
if (i > 0) sb.append('\n');
|
||||||
|
sb.append(lines.get(i).getText());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ext(String filename) {
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot >= 0 ? filename.substring(dot).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stem(Path p) {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
return dot > 0 ? name.substring(0, dot) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path saveUpload(byte[] content, String suffix) {
|
||||||
|
try {
|
||||||
|
Path tmp = Files.createTempFile("ry_ocr_", suffix);
|
||||||
|
Files.write(tmp, content);
|
||||||
|
return tmp;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("保存临时文件失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int elapsedMs(long t0) {
|
||||||
|
return (int) (System.currentTimeMillis() - t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvoiceResult err(String error, String errorCode, boolean isInvoice, int pageCount, long t0) {
|
||||||
|
InvoiceResult r = new InvoiceResult();
|
||||||
|
r.setSuccess(false);
|
||||||
|
r.setIsInvoice(isInvoice);
|
||||||
|
r.setError(error);
|
||||||
|
r.setErrorCode(errorCode);
|
||||||
|
r.setPageCount(pageCount);
|
||||||
|
r.setEngine("paddleocr");
|
||||||
|
r.setElapsedMs(elapsedMs(t0));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvoiceResult buildQrOnlyResult(QrDecodeResult qr, int elapsedMs, int pageCount) {
|
||||||
|
InvoiceFields fields = new InvoiceFields();
|
||||||
|
fields.setInvoiceNo(qr.getInvoiceNo());
|
||||||
|
fields.setAmount(qr.getAmount());
|
||||||
|
fields.setInvoiceDate(qr.getInvoiceDate());
|
||||||
|
InvoiceResult r = new InvoiceResult();
|
||||||
|
r.setSuccess(true);
|
||||||
|
r.setIsInvoice(true);
|
||||||
|
r.setRawText("[QR only] " + qr.getRaw());
|
||||||
|
r.setFields(fields);
|
||||||
|
r.setPageCount(pageCount);
|
||||||
|
r.setEngine("qr");
|
||||||
|
r.setElapsedMs(elapsedMs);
|
||||||
|
r.setFromQr(true);
|
||||||
|
r.setQrRaw(qr.getRaw());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR 解出的 3 字段优先, 没解到的保持 OCR 结果
|
||||||
|
*/
|
||||||
|
private InvoiceFields overlayQrFields(InvoiceFields fields, QrDecodeResult qr) {
|
||||||
|
if (qr.getInvoiceNo() != null) fields.setInvoiceNo(qr.getInvoiceNo());
|
||||||
|
if (qr.getAmount() != null) fields.setAmount(qr.getAmount());
|
||||||
|
if (qr.getInvoiceDate() != null) fields.setInvoiceDate(qr.getInvoiceDate());
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package com.ruoyi.business.ocr.util;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 金额工具: 中文大写金额解析 + 小写金额正则 — 对齐 Python app.utils.amount_utils
|
||||||
|
* <p>
|
||||||
|
* 中文大写金额解析是 cn2an smart 模式的简化版:
|
||||||
|
* - 支持字符: 零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整
|
||||||
|
* - 处理 亿/万/元 三段累计, 段内 仟佰拾 累加
|
||||||
|
* - 处理 角分 (0.1 / 0.01)
|
||||||
|
*/
|
||||||
|
public final class AmountUtils {
|
||||||
|
|
||||||
|
private AmountUtils() {}
|
||||||
|
|
||||||
|
// ---------- 小写金额正则 ----------
|
||||||
|
|
||||||
|
/** 通用金额: ¥1,234.56 或 1234.56 */
|
||||||
|
private static final Pattern NUM_PATTERN = Pattern.compile("¥?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
|
||||||
|
|
||||||
|
/** 税额: "税额 ¥12.34" / "税额:12.34" */
|
||||||
|
private static final Pattern TAX_AMOUNT_PATTERN = Pattern.compile("税\\s*额\\s*[¥:]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
|
||||||
|
|
||||||
|
/** 不含税: "不含税价 ¥1234.56" / "不含税:1234.56" */
|
||||||
|
private static final Pattern PRETAX_PATTERN = Pattern.compile("(?:不合?税价|不含税)\\s*[¥:]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
|
||||||
|
|
||||||
|
/** 价税合计: "价税合计 ¥1234.56" */
|
||||||
|
private static final Pattern TOTAL_PATTERN = Pattern.compile("价税合计[^\\d]*[¥]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
|
||||||
|
|
||||||
|
// ---------- 中文大写金额正则 ----------
|
||||||
|
|
||||||
|
private static final Pattern CN_IN_PARENS_AFTER_TOTAL = Pattern.compile(
|
||||||
|
"价税合计[^\\((]*[\\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]+)[\\))]"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Pattern CN_IN_ANY_PARENS = Pattern.compile(
|
||||||
|
"[\\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30})[\\))]"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Pattern CN_LONG = Pattern.compile("[零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30}");
|
||||||
|
|
||||||
|
// ---------- 数字映射 ----------
|
||||||
|
|
||||||
|
private static final Map<Character, Integer> DIGIT_MAP = new HashMap<>();
|
||||||
|
private static final Map<Character, Double> UNIT_MAP = new HashMap<>();
|
||||||
|
private static final Map<Character, Double> BIG_UNIT_MAP = new HashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
DIGIT_MAP.put('零', 0);
|
||||||
|
DIGIT_MAP.put('壹', 1);
|
||||||
|
DIGIT_MAP.put('贰', 2);
|
||||||
|
DIGIT_MAP.put('叁', 3);
|
||||||
|
DIGIT_MAP.put('肆', 4);
|
||||||
|
DIGIT_MAP.put('伍', 5);
|
||||||
|
DIGIT_MAP.put('陆', 6);
|
||||||
|
DIGIT_MAP.put('柒', 7);
|
||||||
|
DIGIT_MAP.put('捌', 8);
|
||||||
|
DIGIT_MAP.put('玖', 9);
|
||||||
|
|
||||||
|
UNIT_MAP.put('拾', 10.0);
|
||||||
|
UNIT_MAP.put('佰', 100.0);
|
||||||
|
UNIT_MAP.put('仟', 1000.0);
|
||||||
|
|
||||||
|
BIG_UNIT_MAP.put('角', 0.1);
|
||||||
|
BIG_UNIT_MAP.put('分', 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 公开方法 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本里抽取中文大写金额
|
||||||
|
* <p>
|
||||||
|
* 优先级:
|
||||||
|
* 1. "价税合计" 后面括号内
|
||||||
|
* 2. 任意中括号里的中文金额
|
||||||
|
* 3. 含"元"或"圆"的最长中文字符串
|
||||||
|
*/
|
||||||
|
public static String extractCnAmount(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher m = CN_IN_PARENS_AFTER_TOTAL.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
return m.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
m = CN_IN_ANY_PARENS.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
return m.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher m2 = CN_LONG.matcher(text);
|
||||||
|
while (m2.find()) {
|
||||||
|
String cand = m2.group();
|
||||||
|
if (cand.contains("元") || cand.contains("圆")) {
|
||||||
|
return cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中文大写金额 → float, 例如 "贰佰元整" → 200.0
|
||||||
|
*/
|
||||||
|
public static Double parseCnAmount(String cnText) {
|
||||||
|
if (cnText == null || cnText.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String s = normalizeCnAmount(cnText);
|
||||||
|
s = s.replaceAll("整$", "");
|
||||||
|
if (!s.endsWith("元")) {
|
||||||
|
s = s + "元";
|
||||||
|
}
|
||||||
|
return smartParse(s);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大写 vs 小写金额比对
|
||||||
|
*/
|
||||||
|
public static Boolean amountConsistent(String cnText, Double numAmount) {
|
||||||
|
if (cnText == null || numAmount == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Double cnValue = parseCnAmount(cnText);
|
||||||
|
if (cnValue == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.abs(cnValue - numAmount) < 0.011;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取第一个形如 1234.56 或 ¥1,234.56 的金额
|
||||||
|
*/
|
||||||
|
public static Double extractNumAmount(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Matcher m = NUM_PATTERN.matcher(text);
|
||||||
|
if (!m.find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(m.group(1).replace(",", ""));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取价税合计 (优先), 兜底走 extractNumAmount
|
||||||
|
*/
|
||||||
|
public static Double extractTotalAmount(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Matcher m = TOTAL_PATTERN.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(m.group(1).replace(",", ""));
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
return extractNumAmount(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取税额
|
||||||
|
*/
|
||||||
|
public static Double extractTaxAmount(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Matcher m = TAX_AMOUNT_PATTERN.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(m.group(1).replace(",", ""));
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取不含税金额
|
||||||
|
*/
|
||||||
|
public static Double extractPretaxAmount(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Matcher m = PRETAX_PATTERN.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(m.group(1).replace(",", ""));
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 内部 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中文金额归一化: 圆→元, 〇→零, 去空格
|
||||||
|
*/
|
||||||
|
private static String normalizeCnAmount(String text) {
|
||||||
|
if (text == null) return "";
|
||||||
|
return text.replace("圆", "元").replace("〇", "零").replace(" ", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cn2an smart 模式简化实现:
|
||||||
|
* - 拾佰仟 在元段内累加
|
||||||
|
* - 万 / 亿 切换大段
|
||||||
|
* - 角分 处理小数
|
||||||
|
*/
|
||||||
|
private static double smartParse(String s) {
|
||||||
|
// 拆分: 整数部分 (元段) + 小数部分 (角分)
|
||||||
|
int yuanIdx = s.indexOf('元');
|
||||||
|
String intPart = yuanIdx >= 0 ? s.substring(0, yuanIdx) : s;
|
||||||
|
String decPart = yuanIdx >= 0 ? s.substring(yuanIdx + 1) : "";
|
||||||
|
|
||||||
|
double intValue = parseIntegerPart(intPart);
|
||||||
|
double decValue = parseDecimalPart(decPart);
|
||||||
|
return intValue + decValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析整数部分: 处理 拾佰仟 万 亿
|
||||||
|
*/
|
||||||
|
private static double parseIntegerPart(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return 0;
|
||||||
|
|
||||||
|
// 分段: 以 亿 / 万 分隔
|
||||||
|
// 例: 叁万玖仟伍佰 → [叁] (亿段=0) + [玖] (万段) + [伍佰] (元段)
|
||||||
|
// 例: 壹亿贰仟万叁仟 → [壹] (亿段) + [贰] (万段) + [叁] (元段)
|
||||||
|
double total = 0;
|
||||||
|
double currentSection = 0; // 当前段(元/万/亿) 的累加值
|
||||||
|
double currentNum = 0; // 当前数字 (0-9)
|
||||||
|
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (DIGIT_MAP.containsKey(c)) {
|
||||||
|
currentNum = DIGIT_MAP.get(c);
|
||||||
|
} else if (UNIT_MAP.containsKey(c)) {
|
||||||
|
// 拾佰仟: 处理 拾伍 = 15 (省略壹) 的情况
|
||||||
|
double v = currentNum == 0 ? 1 : currentNum;
|
||||||
|
currentSection += v * UNIT_MAP.get(c);
|
||||||
|
currentNum = 0;
|
||||||
|
} else if (c == '万') {
|
||||||
|
currentSection += currentNum;
|
||||||
|
total += currentSection * 10000;
|
||||||
|
currentSection = 0;
|
||||||
|
currentNum = 0;
|
||||||
|
} else if (c == '亿') {
|
||||||
|
currentSection += currentNum;
|
||||||
|
total += currentSection * 100000000;
|
||||||
|
currentSection = 0;
|
||||||
|
currentNum = 0;
|
||||||
|
}
|
||||||
|
// 零 跳过
|
||||||
|
}
|
||||||
|
// 收尾: 段尾若有数字未乘单位 (如 "叁万玖" 末尾的 "玖")
|
||||||
|
currentSection += currentNum;
|
||||||
|
total += currentSection;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析小数部分: 角分
|
||||||
|
*/
|
||||||
|
private static double parseDecimalPart(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return 0;
|
||||||
|
|
||||||
|
double value = 0;
|
||||||
|
double currentNum = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (DIGIT_MAP.containsKey(c)) {
|
||||||
|
currentNum = DIGIT_MAP.get(c);
|
||||||
|
} else if (BIG_UNIT_MAP.containsKey(c)) {
|
||||||
|
if (currentNum > 0 || i == 0) {
|
||||||
|
double v = currentNum == 0 ? 1 : currentNum;
|
||||||
|
value += v * BIG_UNIT_MAP.get(c);
|
||||||
|
}
|
||||||
|
currentNum = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -17,7 +17,7 @@ import com.ruoyi.business.domain.BizMeetingInvoice;
|
|||||||
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
|
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
|
||||||
import com.ruoyi.business.ocr.InvoiceFields;
|
import com.ruoyi.business.ocr.InvoiceFields;
|
||||||
import com.ruoyi.business.ocr.InvoiceResult;
|
import com.ruoyi.business.ocr.InvoiceResult;
|
||||||
import com.ruoyi.business.ocr.OcrClient;
|
import com.ruoyi.business.ocr.LocalInvoiceRecognizer;
|
||||||
import com.ruoyi.business.ocr.ZipExtractor;
|
import com.ruoyi.business.ocr.ZipExtractor;
|
||||||
import com.ruoyi.business.oss.OssUploader;
|
import com.ruoyi.business.oss.OssUploader;
|
||||||
import com.ruoyi.common.utils.SecurityUtils;
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
@@ -44,7 +44,7 @@ public class InvoiceOcrService
|
|||||||
private static final Logger log = LoggerFactory.getLogger(InvoiceOcrService.class);
|
private static final Logger log = LoggerFactory.getLogger(InvoiceOcrService.class);
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private OcrClient ocrClient;
|
private LocalInvoiceRecognizer recognizer;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private OssUploader ossUploader;
|
private OssUploader ossUploader;
|
||||||
@@ -159,7 +159,7 @@ public class InvoiceOcrService
|
|||||||
*/
|
*/
|
||||||
void recognizeSingle(Long materialId, String ossUrl)
|
void recognizeSingle(Long materialId, String ossUrl)
|
||||||
{
|
{
|
||||||
InvoiceResult ir = ocrClient.recognizeByUrl(ossUrl);
|
InvoiceResult ir = recognizer.recognizeByUrl(ossUrl);
|
||||||
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
|
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
|
||||||
{
|
{
|
||||||
BigDecimal amount = ir.getFields().getAmount() != null
|
BigDecimal amount = ir.getFields().getAmount() != null
|
||||||
@@ -210,7 +210,7 @@ public class InvoiceOcrService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
InvoiceResult ir = ocrClient.recognize(f);
|
InvoiceResult ir = recognizer.recognize(f);
|
||||||
if (!Boolean.TRUE.equals(ir.getSuccess()) || ir.getFields() == null
|
if (!Boolean.TRUE.equals(ir.getSuccess()) || ir.getFields() == null
|
||||||
|| !isRecognizedAsInvoice(ir.getFields()))
|
|| !isRecognizedAsInvoice(ir.getFields()))
|
||||||
{
|
{
|
||||||
@@ -275,7 +275,7 @@ public class InvoiceOcrService
|
|||||||
if (inv == null || inv.getOssUrl() == null) return;
|
if (inv == null || inv.getOssUrl() == null) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
InvoiceResult ir = ocrClient.recognizeByUrl(inv.getOssUrl());
|
InvoiceResult ir = recognizer.recognizeByUrl(inv.getOssUrl());
|
||||||
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
|
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
|
||||||
{
|
{
|
||||||
BigDecimal amount = ir.getFields().getAmount() != null
|
BigDecimal amount = ir.getFields().getAmount() != null
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class AliyunSmsSender {
|
|||||||
@Value("${ruoyi.sms.esignTemplate}")
|
@Value("${ruoyi.sms.esignTemplate}")
|
||||||
private String esignTemplate;
|
private String esignTemplate;
|
||||||
|
|
||||||
@Value("${ruoyi.sms.esignBaseUrl:https://ringdoctor.com/hg}")
|
@Value("${ruoyi.sms.esignBaseUrl:https://risingdoctor.com/hg}")
|
||||||
private String esignBaseUrl;
|
private String esignBaseUrl;
|
||||||
|
|
||||||
@Value("${ruoyi.sms.regionId:cn-hangzhou}")
|
@Value("${ruoyi.sms.regionId:cn-hangzhou}")
|
||||||
@@ -105,7 +105,7 @@ public class AliyunSmsSender {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId}
|
* 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId}
|
||||||
* 例: https://ringdoctor.com/hg/#/doctor/sign-fill?attendeeId=123
|
* 例: https://risingdoctor.com/hg/#/doctor/sign-fill?attendeeId=123
|
||||||
* (nginx 子路径 /hg 已配在 ruoyi.sms.esignBaseUrl 里, 前端 Vue Router 是 hash 模式,
|
* (nginx 子路径 /hg 已配在 ruoyi.sms.esignBaseUrl 里, 前端 Vue Router 是 hash 模式,
|
||||||
* 所以 Java 只拼 #/doctor/sign-fill 路由 + attendeeId)
|
* 所以 Java 只拼 #/doctor/sign-fill 路由 + attendeeId)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,7 +32,27 @@ export function useCamera(videoId: string, blur?: BlurConfig) {
|
|||||||
const errorMsg = ref<string>('')
|
const errorMsg = ref<string>('')
|
||||||
|
|
||||||
function getVideoEl(): HTMLVideoElement | null {
|
function getVideoEl(): HTMLVideoElement | null {
|
||||||
return document.getElementById(videoId) as HTMLVideoElement | null
|
const el = document.getElementById(videoId) as HTMLElement | null
|
||||||
|
if (!el) return null
|
||||||
|
// uni-app H5 把 <video> 编译成 <uni-video> Vue 组件时, getElementById
|
||||||
|
// 拿到的是 wrapper, 它的 srcObject setter 会透传给内部原生 <video>,
|
||||||
|
// 但 readyState / play() 不一定透传. 用 readyState 是否 number 判断是否原生.
|
||||||
|
if (el.tagName === 'VIDEO' && typeof (el as any).readyState === 'number') {
|
||||||
|
return el as HTMLVideoElement
|
||||||
|
}
|
||||||
|
// wrapper 场景: 内部包了一个原生 <video>
|
||||||
|
const inner = el.querySelector('video') as HTMLVideoElement | null
|
||||||
|
if (inner && typeof inner.readyState === 'number') {
|
||||||
|
console.log('[camera] 穿透 uni-app wrapper, 找到内部原生 <video>')
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
// 最后一搏: 找页面上第一个原生 <video>
|
||||||
|
const fallback = document.querySelector('video') as HTMLVideoElement | null
|
||||||
|
if (fallback) {
|
||||||
|
console.log('[camera] 全局兜底找到第一个原生 <video>')
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startCamera() {
|
async function startCamera() {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<video
|
<video
|
||||||
id="panorama-video"
|
id="panorama-video"
|
||||||
class="video"
|
class="video"
|
||||||
autoplay
|
|
||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
/>
|
/>
|
||||||
@@ -279,19 +278,38 @@ function goBack() {
|
|||||||
.preview-actions {
|
.preview-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-around;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 40rpx 60rpx;
|
padding: 40rpx 60rpx;
|
||||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-btn {
|
.preview-btn {
|
||||||
flex: 1;
|
/* flex item: 在 .preview-actions 里 flex:1 平分宽度 */
|
||||||
margin: 0 20rpx;
|
flex: 1 1 0%;
|
||||||
padding: 24rpx 0;
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
/* flex container: 让内部 <text> 真正居中 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
/* 重置浏览器 / uni-app H5 给 <button> 的默认外观 (边框/appearance 是按钮不对称的常见根因) */
|
||||||
|
margin: 0 16rpx;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
border-radius: 48rpx;
|
border-radius: 48rpx;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
min-height: 88rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-btn-cancel {
|
.preview-btn-cancel {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<video
|
<video
|
||||||
id="signin-video"
|
id="signin-video"
|
||||||
class="video"
|
class="video"
|
||||||
autoplay
|
|
||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
/>
|
/>
|
||||||
@@ -67,7 +66,7 @@
|
|||||||
import { useCamera } from '@/composables/useCamera'
|
import { useCamera } from '@/composables/useCamera'
|
||||||
|
|
||||||
const videoId = 'signin-video'
|
const videoId = 'signin-video'
|
||||||
// A4 纸 20%~50% 区间 (签到人手机号/身份证号所在的姓名栏) 做高斯模糊脱敏
|
// A4 纸: 从顶边到底边的一竖条, 水平位置在 20%~50% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
|
||||||
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
|
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
|
||||||
useCamera(videoId, { start: 0.2, end: 0.5, radius: 20 })
|
useCamera(videoId, { start: 0.2, end: 0.5, radius: 20 })
|
||||||
|
|
||||||
@@ -273,19 +272,38 @@ function goBack() {
|
|||||||
.preview-actions {
|
.preview-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-around;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 40rpx 60rpx;
|
padding: 40rpx 60rpx;
|
||||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-btn {
|
.preview-btn {
|
||||||
flex: 1;
|
/* flex item: 在 .preview-actions 里 flex:1 平分宽度 */
|
||||||
margin: 0 20rpx;
|
flex: 1 1 0%;
|
||||||
padding: 24rpx 0;
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
/* flex container: 让内部 <text> 真正居中 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
/* 重置浏览器 / uni-app H5 给 <button> 的默认外观 (边框/appearance 是按钮不对称的常见根因) */
|
||||||
|
margin: 0 16rpx;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
border-radius: 48rpx;
|
border-radius: 48rpx;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
min-height: 88rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-btn-cancel {
|
.preview-btn-cancel {
|
||||||
|
|||||||
+126
-47
@@ -8,6 +8,25 @@
|
|||||||
*/
|
*/
|
||||||
export type Facing = 'user' | 'environment'
|
export type Facing = 'user' | 'environment'
|
||||||
|
|
||||||
|
// uni-app H5 runtime (uni-h5.es.js) 看到 <video autoplay> 会抢先调一次 play(),
|
||||||
|
// 此时 srcObject 还没设,会抛 NotSupportedError 变成 unhandled rejection.
|
||||||
|
// 模板已不再写 autoplay, 这里再装一个兜底监听, 把漏网的 NotSupportedError /
|
||||||
|
// AbortError 静默掉, 不污染控制台.
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__cameraRejectionGuard?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && !window.__cameraRejectionGuard) {
|
||||||
|
window.__cameraRejectionGuard = true
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
const name = (e?.reason as DOMException)?.name
|
||||||
|
if (name === 'NotSupportedError' || name === 'AbortError') {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function openCamera(video: HTMLVideoElement, facing: Facing): Promise<void> {
|
export async function openCamera(video: HTMLVideoElement, facing: Facing): Promise<void> {
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
throw new Error('当前浏览器不支持摄像头访问,请升级浏览器或使用 Chrome / Safari')
|
throw new Error('当前浏览器不支持摄像头访问,请升级浏览器或使用 Chrome / Safari')
|
||||||
@@ -52,10 +71,56 @@ video.setAttribute('playsinline', 'true')
|
|||||||
// iOS 必须: 不静音黑屏 (否则 iOS 拒绝播放)
|
// iOS 必须: 不静音黑屏 (否则 iOS 拒绝播放)
|
||||||
video.muted = true
|
video.muted = true
|
||||||
|
|
||||||
// 显式 play(): <video autoplay> 只在元素首次加载时触发一次, 而 srcObject 是
|
// 源就绪后再 play(): 过早 play() 会因为 video 还没有源抛 NotSupportedError
|
||||||
// 异步挂上的, 此时视频还没源, 浏览器不会自动重放 → 黑屏 + 播放按钮.
|
// ("The element has no supported sources"), 变成未捕获的 Promise 拒绝.
|
||||||
// 静音 + playsinline 下, 现代浏览器默认放行静音自动播放, 直接 play() 起流.
|
// safePlay 现在做四件事:
|
||||||
video.play()
|
// 1. 防御 uni-app H5 包装层让 video.play 返回 undefined 的情况
|
||||||
|
// 2. 吞掉 NotSupportedError / AbortError (浏览器认为没源时不报)
|
||||||
|
// 3. readyState < 2 时按 readyState 升级主动重试, 不再只等 loadedmetadata
|
||||||
|
// 4. 其他错误 warn 后不再重试 (避免噪音)
|
||||||
|
const safePlay = () => {
|
||||||
|
// 防御: uni-app H5 包装层可能让 video.play 不存在或返回 undefined
|
||||||
|
if (typeof video.play !== 'function') {
|
||||||
|
console.warn('[camera] video.play 不是函数 (可能 uni-app 包装), 跳过')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let result: unknown
|
||||||
|
try {
|
||||||
|
result = video.play()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[camera] play() 同步抛错:', (e as Error)?.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result || typeof (result as Promise<void>).catch !== 'function') {
|
||||||
|
// play() 没返回 Promise (uni-app 包装层常见), 无错误可处理, 直接放过
|
||||||
|
return
|
||||||
|
}
|
||||||
|
;(result as Promise<void>).catch((e: DOMException) => {
|
||||||
|
const name = e?.name
|
||||||
|
if (name === 'NotSupportedError' || name === 'AbortError') {
|
||||||
|
// 源还没就绪 (readyState 0/1), 起一个轮询, 数据到位再 play
|
||||||
|
if (video.readyState < 2) {
|
||||||
|
console.log('[camera] 源未就绪, readyState=', video.readyState, ', 等待后重试')
|
||||||
|
const start = Date.now()
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
clearInterval(timer)
|
||||||
|
safePlay()
|
||||||
|
} else if (Date.now() - start > 3000) {
|
||||||
|
clearInterval(timer)
|
||||||
|
console.warn('[camera] 等待 readyState>=2 超时, 放弃自动播放')
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[camera] play() 失败:', name, e?.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 赋值 srcObject 立刻主动 play 一次 (不再只等 loadedmetadata, 避免 uni-app
|
||||||
|
// 包装或某些浏览器不触发该事件时视频永远不播)
|
||||||
|
safePlay()
|
||||||
|
|
||||||
const fallbackTimer = setTimeout(() => {
|
const fallbackTimer = setTimeout(() => {
|
||||||
if (video.readyState < 1) {
|
if (video.readyState < 1) {
|
||||||
@@ -67,6 +132,8 @@ const fallbackTimer = setTimeout(() => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
})
|
})
|
||||||
console.log('[camera] defineProperty 后 srcObject isStream:', video.srcObject === stream)
|
console.log('[camera] defineProperty 后 srcObject isStream:', video.srcObject === stream)
|
||||||
|
// 强制赋值后再主动试一次 play
|
||||||
|
safePlay()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[camera] defineProperty 也失败:', e)
|
console.error('[camera] defineProperty 也失败:', e)
|
||||||
}
|
}
|
||||||
@@ -75,6 +142,7 @@ const fallbackTimer = setTimeout(() => {
|
|||||||
console.warn('[camera] 仍 readyState 0,改用 src=blobURL')
|
console.warn('[camera] 仍 readyState 0,改用 src=blobURL')
|
||||||
try {
|
try {
|
||||||
video.src = URL.createObjectURL(stream)
|
video.src = URL.createObjectURL(stream)
|
||||||
|
safePlay()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[camera] blobURL 兜底失败:', e)
|
console.error('[camera] blobURL 兜底失败:', e)
|
||||||
}
|
}
|
||||||
@@ -87,11 +155,19 @@ video.addEventListener(
|
|||||||
() => {
|
() => {
|
||||||
clearTimeout(fallbackTimer)
|
clearTimeout(fallbackTimer)
|
||||||
console.log('[camera] loadedmetadata 触发, readyState:', video.readyState, 'isStream:', video.srcObject === stream)
|
console.log('[camera] loadedmetadata 触发, readyState:', video.readyState, 'isStream:', video.srcObject === stream)
|
||||||
// 数据真正就绪后再补一次 play(), 兜底个别浏览器首次 play() 因无数据被打断
|
// 数据真正就绪后再 play
|
||||||
video.play()
|
safePlay()
|
||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 兜底: 个别浏览器 loadedmetadata 后 readyState 仍不够, loadeddata 再补一次 play()
|
||||||
|
video.addEventListener('loadeddata', () => safePlay(), { once: true })
|
||||||
|
|
||||||
|
// 若赋值 srcObject 前已就绪 (理论上不会), 立即放
|
||||||
|
if (video.readyState >= 1) {
|
||||||
|
safePlay()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopCamera(video: HTMLVideoElement): void {
|
export function stopCamera(video: HTMLVideoElement): void {
|
||||||
@@ -134,30 +210,27 @@ function buildGaussianKernel(radius: number): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对 RGBA 数组的竖直条带 [y0, y1) 做可分离高斯模糊, 返回新数组 (不修改原数组).
|
* 对 RGBA 数组的竖直条带 [x0, x1) 做可分离高斯模糊, 返回新数组 (不修改原数组).
|
||||||
* 水平卷积范围向外扩 radius, 保证条带边界处垂直卷积有正确的邻域上下文.
|
* 条带贯穿全高 (顶边→底边), 只在水平 [x0, x1) 范围内模糊; 其余区域照抄.
|
||||||
*/
|
*/
|
||||||
function gaussianBlurBand(
|
function gaussianBlurBand(
|
||||||
data: Uint8ClampedArray,
|
data: Uint8ClampedArray,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
y0: number,
|
x0: number,
|
||||||
y1: number,
|
x1: number,
|
||||||
radius: number
|
radius: number
|
||||||
): Uint8ClampedArray {
|
): Uint8ClampedArray {
|
||||||
const kernel = buildGaussianKernel(radius)
|
const kernel = buildGaussianKernel(radius)
|
||||||
const r = radius
|
const r = radius
|
||||||
const kSize = kernel.length
|
const kSize = kernel.length
|
||||||
|
|
||||||
const hy0 = Math.max(0, y0 - r)
|
// 1) 水平卷积 (沿 x): 读 data, 写 h1 (仅 [x0, x1) 列, 全高, 其余照抄)
|
||||||
const hy1 = Math.min(height, y1 + r)
|
|
||||||
|
|
||||||
// 1) 水平卷积: 读 data, 写 h1 (仅 [hy0, hy1) 行, 其余照抄)
|
|
||||||
const h1 = new Uint8ClampedArray(data)
|
const h1 = new Uint8ClampedArray(data)
|
||||||
const row = new Array<number>(width * 4)
|
const col = new Array<number>(height * 4)
|
||||||
for (let y = hy0; y < hy1; y++) {
|
for (let x = x0; x < x1; x++) {
|
||||||
const base = y * width * 4
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
const base = (y * width + x) * 4
|
||||||
let rr = 0
|
let rr = 0
|
||||||
let gg = 0
|
let gg = 0
|
||||||
let bb = 0
|
let bb = 0
|
||||||
@@ -166,29 +239,34 @@ function gaussianBlurBand(
|
|||||||
let sx = x + k - r
|
let sx = x + k - r
|
||||||
if (sx < 0) sx = 0
|
if (sx < 0) sx = 0
|
||||||
if (sx >= width) sx = width - 1
|
if (sx >= width) sx = width - 1
|
||||||
const idx = base + sx * 4
|
const idx = (y * width + sx) * 4
|
||||||
const wt = kernel[k]
|
const wt = kernel[k]
|
||||||
rr += data[idx] * wt
|
rr += data[idx] * wt
|
||||||
gg += data[idx + 1] * wt
|
gg += data[idx + 1] * wt
|
||||||
bb += data[idx + 2] * wt
|
bb += data[idx + 2] * wt
|
||||||
aa += data[idx + 3] * wt
|
aa += data[idx + 3] * wt
|
||||||
}
|
}
|
||||||
const o = x * 4
|
const o = y * 4
|
||||||
row[o] = rr
|
col[o] = rr
|
||||||
row[o + 1] = gg
|
col[o + 1] = gg
|
||||||
row[o + 2] = bb
|
col[o + 2] = bb
|
||||||
row[o + 3] = aa
|
col[o + 3] = aa
|
||||||
|
}
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const idx = (y * width + x) * 4
|
||||||
|
const o = y * 4
|
||||||
|
h1[idx] = col[o]
|
||||||
|
h1[idx + 1] = col[o + 1]
|
||||||
|
h1[idx + 2] = col[o + 2]
|
||||||
|
h1[idx + 3] = col[o + 3]
|
||||||
}
|
}
|
||||||
for (let i = 0; i < width * 4; i++) h1[base + i] = row[i]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 垂直卷积: 读 h1, 写 h2 (仅 [y0, y1) 行, 其余照抄 h1)
|
// 2) 垂直卷积 (沿 y): 读 h1, 写 h2 (仅 [x0, x1) 列, 全高, 其余照抄 h1)
|
||||||
const h2 = new Uint8ClampedArray(h1)
|
const h2 = new Uint8ClampedArray(h1)
|
||||||
const band = y1 - y0
|
const col2 = new Array<number>(height * 4)
|
||||||
const col = new Array<number>(band * 4)
|
for (let x = x0; x < x1; x++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let yy = 0; yy < band; yy++) {
|
|
||||||
const y = y0 + yy
|
|
||||||
let rr = 0
|
let rr = 0
|
||||||
let gg = 0
|
let gg = 0
|
||||||
let bb = 0
|
let bb = 0
|
||||||
@@ -204,19 +282,19 @@ function gaussianBlurBand(
|
|||||||
bb += h1[idx + 2] * wt
|
bb += h1[idx + 2] * wt
|
||||||
aa += h1[idx + 3] * wt
|
aa += h1[idx + 3] * wt
|
||||||
}
|
}
|
||||||
const o = yy * 4
|
const o = y * 4
|
||||||
col[o] = rr
|
col2[o] = rr
|
||||||
col[o + 1] = gg
|
col2[o + 1] = gg
|
||||||
col[o + 2] = bb
|
col2[o + 2] = bb
|
||||||
col[o + 3] = aa
|
col2[o + 3] = aa
|
||||||
}
|
}
|
||||||
for (let yy = 0; yy < band; yy++) {
|
for (let y = 0; y < height; y++) {
|
||||||
const idx = ((y0 + yy) * width + x) * 4
|
const idx = (y * width + x) * 4
|
||||||
const o = yy * 4
|
const o = y * 4
|
||||||
h2[idx] = col[o]
|
h2[idx] = col2[o]
|
||||||
h2[idx + 1] = col[o + 1]
|
h2[idx + 1] = col2[o + 1]
|
||||||
h2[idx + 2] = col[o + 2]
|
h2[idx + 2] = col2[o + 2]
|
||||||
h2[idx + 3] = col[o + 3]
|
h2[idx + 3] = col2[o + 3]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +303,7 @@ function gaussianBlurBand(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 截图并同时生成两张图: 清晰版 + 竖直条带 [start, end] 高斯模糊版 (签到表脱敏用).
|
* 截图并同时生成两张图: 清晰版 + 竖直条带 [start, end] 高斯模糊版 (签到表脱敏用).
|
||||||
|
* start/end 是「宽度」比例 (从左到右), 条带贯穿全高 (顶边→底边).
|
||||||
* 同一帧出两张, 避免两次截图不一致.
|
* 同一帧出两张, 避免两次截图不一致.
|
||||||
*/
|
*/
|
||||||
export function captureFrameWithBlur(
|
export function captureFrameWithBlur(
|
||||||
@@ -246,12 +325,12 @@ export function captureFrameWithBlur(
|
|||||||
ctx.drawImage(video, 0, 0, w, h)
|
ctx.drawImage(video, 0, 0, w, h)
|
||||||
const sharp = canvas.toDataURL('image/jpeg', 0.85)
|
const sharp = canvas.toDataURL('image/jpeg', 0.85)
|
||||||
|
|
||||||
const y0 = Math.max(0, Math.floor(h * start))
|
const x0 = Math.max(0, Math.floor(w * start))
|
||||||
const y1 = Math.min(h, Math.floor(h * end))
|
const x1 = Math.min(w, Math.floor(w * end))
|
||||||
let blurred = sharp
|
let blurred = sharp
|
||||||
if (y1 - y0 > 1) {
|
if (x1 - x0 > 1) {
|
||||||
const imgData = ctx.getImageData(0, 0, w, h)
|
const imgData = ctx.getImageData(0, 0, w, h)
|
||||||
const dst = gaussianBlurBand(imgData.data, w, h, y0, y1, radius)
|
const dst = gaussianBlurBand(imgData.data, w, h, x0, x1, radius)
|
||||||
imgData.data.set(dst)
|
imgData.data.set(dst)
|
||||||
ctx.putImageData(imgData, 0, 0)
|
ctx.putImageData(imgData, 0, 0)
|
||||||
blurred = canvas.toDataURL('image/jpeg', 0.85)
|
blurred = canvas.toDataURL('image/jpeg', 0.85)
|
||||||
|
|||||||
@@ -195,24 +195,25 @@ const MENU = {
|
|||||||
],
|
],
|
||||||
doctor: [
|
doctor: [
|
||||||
{ path: '/doctor/home', title: '首页', icon: House },
|
{ path: '/doctor/home', title: '首页', icon: House },
|
||||||
// 以下 3 项仅审核通过 (audit_status='2') 才显示, 由 menu.filter 用 requireAuditApproved 过滤
|
{ path: '/doctor/submissions', title: '我的项目策划方案', icon: EditPen },
|
||||||
|
// 以下 2 项仅审核通过 (audit_status='2') 才显示, 由 menu.filter 用 requireAuditApproved 过滤
|
||||||
{ path: '/doctor/meetings', title: '我参与的会议', icon: Calendar, requireAuditApproved: true },
|
{ path: '/doctor/meetings', title: '我参与的会议', icon: Calendar, requireAuditApproved: true },
|
||||||
{ path: '/doctor/projects', title: '我报名的项目', icon: Document, requireAuditApproved: true },
|
{ path: '/doctor/projects', title: '我报名的项目', icon: Document, requireAuditApproved: true },
|
||||||
{ path: '/doctor/messages', title: '消息通知', icon: Bell },
|
{ path: '/doctor/messages', title: '消息通知', icon: Bell },
|
||||||
{ path: '/doctor/submissions', title: '我的项目设计投稿', icon: EditPen, requireAuditApproved: true },
|
|
||||||
{ path: '/doctor/account', title: '账号信息', icon: User }
|
{ path: '/doctor/account', title: '账号信息', icon: User }
|
||||||
],
|
],
|
||||||
executor: [
|
executor: [
|
||||||
{ path: '/executor/overview', title: '首页', icon: House },
|
{ path: '/executor/overview', title: '首页', icon: House },
|
||||||
|
{ path: '/executor/submissions', title: '我的项目策划方案', icon: EditPen },
|
||||||
{ path: '/executor/meetings', title: '会议执行', icon: Calendar },
|
{ path: '/executor/meetings', title: '会议执行', icon: Calendar },
|
||||||
{ path: '/executor/projects', title: '项目列表', icon: Document },
|
{ path: '/executor/projects', title: '项目列表', icon: Document },
|
||||||
{ path: '/executor/people', title: '人员管理', icon: User, requireMain: true },
|
{ path: '/executor/people', title: '人员管理', icon: User, requireMain: true },
|
||||||
{ path: '/executor/labor', title: '劳务凭证', icon: EditPen },
|
|
||||||
{ path: '/executor/messages', title: '消息通知', icon: Bell },
|
{ path: '/executor/messages', title: '消息通知', icon: Bell },
|
||||||
{ path: '/executor/account', title: '账号信息', icon: Setting }
|
{ path: '/executor/account', title: '账号信息', icon: Setting }
|
||||||
],
|
],
|
||||||
sponsor: [
|
sponsor: [
|
||||||
{ path: '/sponsor/home', title: '首页', icon: House },
|
{ path: '/sponsor/home', title: '首页', icon: House },
|
||||||
|
{ path: '/sponsor/submissions', title: '我的项目策划方案', icon: EditPen },
|
||||||
{ path: '/sponsor/my-projects', title: '我的项目', icon: Document },
|
{ path: '/sponsor/my-projects', title: '我的项目', icon: Document },
|
||||||
{ path: '/sponsor/meetings', title: '会议列表', icon: Calendar },
|
{ path: '/sponsor/meetings', title: '会议列表', icon: Calendar },
|
||||||
{ path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true },
|
{ path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true },
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ const routes = [
|
|||||||
{ path: 'meetings', name: 'doctor-meetings', component: () => import('@/views/doctor/Meetings.vue'), meta: { title: '我参与的会议' } },
|
{ path: 'meetings', name: 'doctor-meetings', component: () => import('@/views/doctor/Meetings.vue'), meta: { title: '我参与的会议' } },
|
||||||
{ path: 'projects', name: 'doctor-projects', component: () => import('@/views/doctor/Projects.vue'), meta: { title: '我报名的项目' } },
|
{ path: 'projects', name: 'doctor-projects', component: () => import('@/views/doctor/Projects.vue'), meta: { title: '我报名的项目' } },
|
||||||
{ path: 'messages', name: 'doctor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
|
{ path: 'messages', name: 'doctor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
|
||||||
{ path: 'submissions', name: 'doctor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目设计投稿' } },
|
{ path: 'submissions', name: 'doctor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
|
||||||
{ path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目设计投稿' } },
|
{ path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
|
||||||
{ path: 'submission/detail/:planId', name: 'doctor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '投稿详情' } },
|
{ path: 'submission/detail/:planId', name: 'doctor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '策划方案详情' } },
|
||||||
{ path: 'submission/edit/:planId', name: 'doctor-submission-edit', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '修改项目设计投稿' } },
|
{ path: 'submission/edit/:planId', name: 'doctor-submission-edit', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '修改项目策划方案' } },
|
||||||
{ path: 'account', name: 'doctor-account', component: () => import('@/views/doctor/Account.vue'), meta: { title: '账号信息' } },
|
{ path: 'account', name: 'doctor-account', component: () => import('@/views/doctor/Account.vue'), meta: { title: '账号信息' } },
|
||||||
{ path: 'sign-fill', name: 'doctor-sign-fill', component: () => import('@/views/doctor/SignFill.vue'), meta: { title: '填写劳务信息', role: 'doctor', hideMenu: true } },
|
{ path: 'sign-fill', name: 'doctor-sign-fill', component: () => import('@/views/doctor/SignFill.vue'), meta: { title: '填写劳务信息', role: 'doctor', hideMenu: true } },
|
||||||
{ path: 'sign-contract', name: 'doctor-sign-contract', component: () => import('@/views/doctor/SignContract.vue'), meta: { title: '签署劳务协议', role: 'doctor', hideMenu: true } },
|
{ path: 'sign-contract', name: 'doctor-sign-contract', component: () => import('@/views/doctor/SignContract.vue'), meta: { title: '签署劳务协议', role: 'doctor', hideMenu: true } },
|
||||||
@@ -108,6 +108,10 @@ const routes = [
|
|||||||
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
|
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
|
||||||
{ path: '', redirect: { name: 'executor-overview' } },
|
{ path: '', redirect: { name: 'executor-overview' } },
|
||||||
{ path: 'overview', name: 'executor-overview', component: () => import('@/views/executor/Overview.vue'), meta: { title: '首页' } },
|
{ path: 'overview', name: 'executor-overview', component: () => import('@/views/executor/Overview.vue'), meta: { title: '首页' } },
|
||||||
|
{ path: 'submissions', name: 'executor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
|
||||||
|
{ path: 'submission/new', name: 'executor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
|
||||||
|
{ path: 'submission/detail/:planId', name: 'executor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '策划方案详情' } },
|
||||||
|
{ path: 'submission/edit/:planId', name: 'executor-submission-edit', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '修改项目策划方案' } },
|
||||||
{ path: 'meetings', name: 'executor-meetings', component: () => import('@/views/executor/Meetings.vue'), meta: { title: '会议执行' } },
|
{ path: 'meetings', name: 'executor-meetings', component: () => import('@/views/executor/Meetings.vue'), meta: { title: '会议执行' } },
|
||||||
{ path: 'meetings/new', name: 'executor-meetings-new', component: () => import('@/views/meetings/MeetingNew.vue'), meta: { title: '新建会议' } },
|
{ path: 'meetings/new', name: 'executor-meetings-new', component: () => import('@/views/meetings/MeetingNew.vue'), meta: { title: '新建会议' } },
|
||||||
{ path: 'meetings/detail/:meetingId', name: 'executor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
|
{ path: 'meetings/detail/:meetingId', name: 'executor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
|
||||||
@@ -125,6 +129,10 @@ const routes = [
|
|||||||
{ path: '/sponsor', component: AdminLayout, meta: { role: 'sponsor' }, children: [
|
{ path: '/sponsor', component: AdminLayout, meta: { role: 'sponsor' }, children: [
|
||||||
{ path: '', redirect: { name: 'sponsor-home' } },
|
{ path: '', redirect: { name: 'sponsor-home' } },
|
||||||
{ path: 'home', name: 'sponsor-home', component: () => import('@/views/sponsor/Home.vue'), meta: { title: '首页' } },
|
{ path: 'home', name: 'sponsor-home', component: () => import('@/views/sponsor/Home.vue'), meta: { title: '首页' } },
|
||||||
|
{ path: 'submissions', name: 'sponsor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
|
||||||
|
{ path: 'submission/new', name: 'sponsor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
|
||||||
|
{ path: 'submission/detail/:planId', name: 'sponsor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '策划方案详情' } },
|
||||||
|
{ path: 'submission/edit/:planId', name: 'sponsor-submission-edit', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '修改项目策划方案' } },
|
||||||
{ path: 'projects', name: 'sponsor-projects', component: () => import('@/views/sponsor/Projects.vue'), meta: { title: '项目管理' } },
|
{ path: 'projects', name: 'sponsor-projects', component: () => import('@/views/sponsor/Projects.vue'), meta: { title: '项目管理' } },
|
||||||
{ path: 'my-projects', name: 'sponsor-my-projects', component: () => import('@/views/sponsor/SponsorProjects.vue'), meta: { title: '我的项目' } },
|
{ path: 'my-projects', name: 'sponsor-my-projects', component: () => import('@/views/sponsor/SponsorProjects.vue'), meta: { title: '我的项目' } },
|
||||||
{ path: 'projects/detail/:projectId', name: 'sponsor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
|
{ path: 'projects/detail/:projectId', name: 'sponsor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-loading="loading" class="page-card doctor-submission-detail">
|
<div v-loading="loading" class="page-card doctor-submission-detail">
|
||||||
<!-- 面包屑 (标准: 首页 / 分类 / 当前页) -->
|
<!-- 面包屑 (标准: 首页 / 分类 / 当前页) -->
|
||||||
<div class="breadcrumb">首页 / 我的项目设计投稿 / 投稿详情</div>
|
<div class="breadcrumb">首页 / 我的项目策划方案 / 策划方案详情</div>
|
||||||
|
|
||||||
<!-- 表单 (只读, 用 el-input readonly 展示) -->
|
<!-- 表单 (只读, 用 el-input readonly 展示) -->
|
||||||
<el-form label-width="120px" class="readonly-form">
|
<el-form label-width="120px" class="readonly-form">
|
||||||
@@ -68,6 +68,8 @@ import request from '@/utils/request'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
// 投稿模块路径前缀: 按当前角色 (doctor/executor/sponsor) 拼, 避免串到别的角色侧栏
|
||||||
|
const base = computed(() => `/${route.meta.role || 'doctor'}`)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detail = ref({})
|
const detail = ref({})
|
||||||
@@ -87,7 +89,7 @@ const display = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ path: '/doctor/submissions', query: { _t: Date.now() } })
|
router.push({ path: `${base.value}/submissions`, query: { _t: Date.now() } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-loading="loadingDetail" class="page-card doctor-submission-new">
|
<div v-loading="loadingDetail" class="page-card doctor-submission-new">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<span>首页 / 我的项目设计投稿 / {{ isEdit ? '修改项目设计投稿' : '新建项目设计投稿' }}</span>
|
<span>首页 / 我的项目策划方案 / {{ isEdit ? '修改项目策划方案' : '新建项目策划方案' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px" class="submission-form">
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px" class="submission-form">
|
||||||
@@ -41,20 +41,14 @@
|
|||||||
<el-row :gutter="12">
|
<el-row :gutter="12">
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-form-item label="设计文件" prop="designFileUrl">
|
<el-form-item label="设计文件" prop="designFileUrl">
|
||||||
<el-upload
|
<OssFileUploader
|
||||||
drag
|
v-model="form.designFileUrl"
|
||||||
action="#"
|
dir="ry8080/submission/design/"
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
placeholder="点击上传设计文件"
|
||||||
:show-file-list="true"
|
hint="支持 PDF / 图片, 单文件 ≤ 20MB"
|
||||||
:file-list="designFileList"
|
:max-size="20"
|
||||||
:before-upload="beforeDesignUpload"
|
block
|
||||||
:http-request="uploadDesign"
|
/>
|
||||||
:on-remove="onDesignRemove"
|
|
||||||
>
|
|
||||||
<i class="el-icon-upload"></i>
|
|
||||||
<div class="el-upload__text">将设计文件拖到此处,或<em>点击上传</em></div>
|
|
||||||
<div class="el-upload__tip" slot="tip">支持 PDF / 图片, 单文件 ≤ 20MB</div>
|
|
||||||
</el-upload>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -76,24 +70,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
import { reactive, ref, onMounted, computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import { bizAdd, bizUpdate } from '@/api/public'
|
import { bizAdd, bizUpdate } from '@/api/public'
|
||||||
import { uploadToOss } from '@/utils/oss'
|
|
||||||
import DictSelect from '@/components/DictSelect.vue'
|
import DictSelect from '@/components/DictSelect.vue'
|
||||||
|
import OssFileUploader from '@/components/OssFileUploader.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
// 投稿模块路径前缀: 按当前角色 (doctor/executor/sponsor) 拼, 避免串到别的角色侧栏
|
||||||
|
const base = computed(() => `/${route.meta.role || 'doctor'}`)
|
||||||
|
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const loadingDetail = ref(false)
|
const loadingDetail = ref(false)
|
||||||
const designFileList = ref([])
|
|
||||||
const designUploading = ref(false)
|
|
||||||
const isEdit = !!route.params.planId
|
const isEdit = !!route.params.planId
|
||||||
|
|
||||||
// 项目方向 options (复用 manager 侧接口)
|
// 项目方向 options (复用 manager 侧接口)
|
||||||
@@ -124,36 +118,6 @@ const rules = {
|
|||||||
designFileUrl: [{ required: true, message: '请上传设计文件', trigger: 'change' }]
|
designFileUrl: [{ required: true, message: '请上传设计文件', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
function beforeDesignUpload(file) {
|
|
||||||
const max = 20 * 1024 * 1024
|
|
||||||
if (file.size > max) { ElMessage.error('设计文件不能超过 20MB'); return false }
|
|
||||||
const accept = ['.pdf', '.png', '.jpg', '.jpeg']
|
|
||||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
|
||||||
if (!accept.includes(ext)) { ElMessage.error('仅支持 PDF / 图片格式'); return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadDesign(opts) {
|
|
||||||
const file = opts.file
|
|
||||||
designUploading.value = true
|
|
||||||
try {
|
|
||||||
const url = await uploadToOss(file, 'ry8080/submission/design/')
|
|
||||||
form.designFileUrl = url
|
|
||||||
designFileList.value = [{ name: file.name, url }]
|
|
||||||
ElMessage.success('设计文件上传成功')
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error('上传失败: ' + (e?.message || e))
|
|
||||||
designFileList.value = []
|
|
||||||
} finally {
|
|
||||||
designUploading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDesignRemove() {
|
|
||||||
form.designFileUrl = ''
|
|
||||||
designFileList.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
const planId = route.params.planId
|
const planId = route.params.planId
|
||||||
if (!planId) return
|
if (!planId) return
|
||||||
@@ -170,10 +134,6 @@ async function loadDetail() {
|
|||||||
form.designFileUrl = d.designFileUrl || ''
|
form.designFileUrl = d.designFileUrl || ''
|
||||||
form.status = d.status && String(d.status).trim() ? String(d.status) : '0'
|
form.status = d.status && String(d.status).trim() ? String(d.status) : '0'
|
||||||
form.remark = d.remark || ''
|
form.remark = d.remark || ''
|
||||||
if (d.designFileUrl) {
|
|
||||||
const name = d.designFileUrl.split('/').pop() || `${d.planName || '设计文件'}.pdf`
|
|
||||||
designFileList.value = [{ name, url: d.designFileUrl }]
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.msg || '加载失败')
|
ElMessage.error(res?.msg || '加载失败')
|
||||||
goBack()
|
goBack()
|
||||||
@@ -188,7 +148,7 @@ async function loadDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ path: '/doctor/submissions', query: { _t: Date.now() } })
|
router.push({ path: `${base.value}/submissions`, query: { _t: Date.now() } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmCancel() {
|
function confirmCancel() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-card">
|
<div class="page-card">
|
||||||
<div class="breadcrumb">首页 / 我的项目设计投稿</div>
|
<div class="breadcrumb">首页 / 我的项目策划方案</div>
|
||||||
|
|
||||||
<el-form inline :model="q" class="filter-form">
|
<el-form inline :model="q" class="filter-form">
|
||||||
<el-form-item label="策划方案名称"><el-input v-model="q.planName" placeholder="输入策划方案名称" clearable style="width: 200px" /></el-form-item>
|
<el-form-item label="策划方案名称"><el-input v-model="q.planName" placeholder="输入策划方案名称" clearable style="width: 200px" /></el-form-item>
|
||||||
@@ -81,15 +81,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
import { reactive, ref, onMounted, computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import { bizList, bizUpdate } from '@/api/public'
|
import { bizList, bizUpdate } from '@/api/public'
|
||||||
import DictSelect from '@/components/DictSelect.vue'
|
import DictSelect from '@/components/DictSelect.vue'
|
||||||
import AuditStatusTag from '@/components/AuditStatusTag.vue'
|
import AuditStatusTag from '@/components/AuditStatusTag.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
// 投稿模块路径前缀: 按当前角色 (doctor/executor/sponsor) 拼, 避免串到别的角色侧栏
|
||||||
|
const base = computed(() => `/${route.meta.role || 'doctor'}`)
|
||||||
|
|
||||||
const q = reactive({
|
const q = reactive({
|
||||||
planName: '',
|
planName: '',
|
||||||
@@ -146,13 +149,13 @@ function canSubmit(row) {
|
|||||||
function onSelectionChange(arr) { selected.value = arr }
|
function onSelectionChange(arr) { selected.value = arr }
|
||||||
|
|
||||||
function onCreate() {
|
function onCreate() {
|
||||||
router.push({ path: '/doctor/submission/new' })
|
router.push({ path: `${base.value}/submission/new` })
|
||||||
}
|
}
|
||||||
function onView(row) {
|
function onView(row) {
|
||||||
router.push({ path: `/doctor/submission/detail/${row.planId}` })
|
router.push({ path: `${base.value}/submission/detail/${row.planId}` })
|
||||||
}
|
}
|
||||||
function onEdit(row) {
|
function onEdit(row) {
|
||||||
router.push({ path: `/doctor/submission/edit/${row.planId}` })
|
router.push({ path: `${base.value}/submission/edit/${row.planId}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(row) {
|
async function onSubmit(row) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-cta">
|
<div class="hero-cta">
|
||||||
<span class="cta-btn" @click="onSubmit">项目提案</span>
|
<span v-if="canPropose" class="cta-btn" @click="onSubmit">项目提案</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -169,6 +169,11 @@ function openPlan(id) {
|
|||||||
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
|
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
|
||||||
const loggedIn = computed(() => !!userStore.token)
|
const loggedIn = computed(() => !!userStore.token)
|
||||||
const userName = computed(() => userStore.user?.userName || '用户')
|
const userName = computed(() => userStore.user?.userName || '用户')
|
||||||
|
// 投稿角色: admin/manager 不开放投稿 → 隐藏「项目提案」按钮; 未登录 user 为 null → 显示(点击引导登录)
|
||||||
|
const canPropose = computed(() => {
|
||||||
|
const r = userStore.user?.role || ''
|
||||||
|
return r !== 'admin' && r !== 'manager'
|
||||||
|
})
|
||||||
|
|
||||||
const planSvg = `<svg viewBox="0 150 1200 370" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="2025-2030年项目规划">
|
const planSvg = `<svg viewBox="0 150 1200 370" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="2025-2030年项目规划">
|
||||||
<defs>
|
<defs>
|
||||||
@@ -257,7 +262,9 @@ function onSubmit() {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.push('/publicity')
|
const role = userStore.user?.role
|
||||||
|
const map = { doctor: '/doctor/submissions', executor: '/executor/submissions', sponsor: '/sponsor/submissions' }
|
||||||
|
router.push(map[role] || '/doctor/submissions')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user