From cc33dba4dfab223980c53a8e644507641ce79b6a Mon Sep 17 00:00:00 2001 From: leiyun Date: Sat, 21 Mar 2026 15:53:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E7=94=A8=E6=88=B7=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E7=9A=84=E5=9B=BE=E7=89=87=E5=81=9AOCR=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/sys_config.sql | 15 +++++++++ src/controller/common.js | 14 +++++++++ src/controller/mp.js | 26 +++++++++++++-- src/model/sys_config.js | 30 ++++++++++++++++++ src/service/ocr.js | 59 +++++++++++++++++++++++++++++++++++ view/admin/patient_index.html | 14 +++++---- 6 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 sql/sys_config.sql create mode 100644 src/model/sys_config.js create mode 100644 src/service/ocr.js diff --git a/sql/sys_config.sql b/sql/sys_config.sql new file mode 100644 index 0000000..7180b93 --- /dev/null +++ b/sql/sys_config.sql @@ -0,0 +1,15 @@ +-- 系统配置表 +CREATE TABLE IF NOT EXISTS `sys_config` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `config_key` varchar(50) NOT NULL COMMENT '配置标识', + `config_value` text COMMENT '配置值(JSON)', + `remark` varchar(200) DEFAULT '' COMMENT '备注', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_config_key` (`config_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表'; + +-- 初始化瘤种选项 +INSERT INTO `sys_config` (`config_key`, `config_value`, `remark`) VALUES +('tag_options', '["直肠癌","结肠癌","十二指肠癌","空肠癌","回肠癌","胃肠道间质瘤(GIST)","肠道神经内分泌肿瘤","腺瘤性息肉","炎性息肉","增生性息肉","平滑肌瘤","脂肪瘤"]', '瘤种选项列表'); diff --git a/src/controller/common.js b/src/controller/common.js index 2bd23b9..123ad1c 100644 --- a/src/controller/common.js +++ b/src/controller/common.js @@ -135,6 +135,20 @@ module.exports = class extends think.Controller { }; } + /** + * 获取瘤种选项列表 + * GET /common/tagOptions + */ + async tagOptionsAction() { + try { + const tags = await this.model('sys_config').getByKey('tag_options'); + return this.json({ code: 0, data: tags || [] }); + } catch (error) { + think.logger.error('获取瘤种选项失败:', error); + return this.json({ code: 0, data: [] }); + } + } + /** * 获取省市区树形数据(一次返回) * GET /common/regions diff --git a/src/controller/mp.js b/src/controller/mp.js index 1e352ee..c7fe33a 100644 --- a/src/controller/mp.js +++ b/src/controller/mp.js @@ -281,7 +281,7 @@ module.exports = class extends Base { async saveMyInfoAction() { const mpUser = this.mpUser; if (!mpUser) return this.json({ code: 1009, msg: '请先登录' }); - const { gender, province_code, city_code, district_code, address, emergency_contact, emergency_phone, tag, documents, sign_income, sign_privacy, sign_promise, income_amount, mp_env_version } = this.post(); + const { gender, province_code, city_code, district_code, address, emergency_contact, emergency_phone, documents, sign_income, sign_privacy, sign_promise, income_amount, mp_env_version } = this.post(); const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find(); if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 1, msg: '请先完成实名认证' }); if (!province_code || !city_code || !district_code) return this.json({ code: 1, msg: '请选择省市区' }); @@ -297,7 +297,7 @@ module.exports = class extends Base { gender: gender || '', province_code: province_code || '', city_code: city_code || '', district_code: district_code || '', address: address || '', emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', - tag: tag || '', documents: JSON.stringify(documents || []), + documents: JSON.stringify(documents || []), sign_income: sign_income || '', sign_privacy: sign_privacy || '', sign_promise: sign_promise || '', @@ -312,6 +312,28 @@ module.exports = class extends Base { operator_id: 0, operator_name: '用户自助提交' }); + + // 异步 OCR 识别瘤种(不阻塞用户提交) + const patientId = user.patient_id; + const docUrls = documents || []; + if (docUrls.length) { + setImmediate(async () => { + try { + const tagOptions = await think.model('sys_config').getByKey('tag_options'); + if (tagOptions && tagOptions.length) { + const ocrService = think.service('ocr'); + const matchedTag = await ocrService.matchTag(docUrls, tagOptions); + if (matchedTag) { + await think.model('patient').where({ id: patientId }).update({ tag: matchedTag }); + think.logger.info(`[OCR] 患者(ID:${patientId}) 识别瘤种: ${matchedTag}`); + } + } + } catch (err) { + think.logger.error(`[OCR] 患者(ID:${patientId}) 识别失败:`, err.message); + } + }); + } + return this.json({ code: 0, data: {}, msg: '提交成功' }); } catch (error) { think.logger.error('saveMyInfo error:', error); diff --git a/src/model/sys_config.js b/src/model/sys_config.js new file mode 100644 index 0000000..95ecdb8 --- /dev/null +++ b/src/model/sys_config.js @@ -0,0 +1,30 @@ +module.exports = class extends think.Model { + get tableName() { + return 'sys_config'; + } + + /** + * 根据 key 获取配置值(自动 JSON.parse) + */ + async getByKey(key) { + const row = await this.where({ config_key: key }).find(); + if (think.isEmpty(row)) return null; + try { + return JSON.parse(row.config_value); + } catch (e) { + return row.config_value; + } + } + + /** + * 设置配置值(自动 JSON.stringify) + */ + async setByKey(key, value, remark) { + const val = typeof value === 'string' ? value : JSON.stringify(value); + const exists = await this.where({ config_key: key }).find(); + if (think.isEmpty(exists)) { + return this.add({ config_key: key, config_value: val, remark: remark || '' }); + } + return this.where({ config_key: key }).update({ config_value: val }); + } +}; diff --git a/src/service/ocr.js b/src/service/ocr.js new file mode 100644 index 0000000..4b0db89 --- /dev/null +++ b/src/service/ocr.js @@ -0,0 +1,59 @@ +const cosConfig = require('../config/cos.js'); + +module.exports = class extends think.Service { + /** + * 对图片进行 OCR 识别,匹配瘤种关键词 + * @param {string[]} imageUrls - 图片 URL 数组 + * @param {string[]} tagOptions - 瘤种关键词列表 + * @returns {string} 匹配到的第一个瘤种,未匹配返回空字符串 + */ + async matchTag(imageUrls, tagOptions) { + if (!imageUrls || !imageUrls.length || !tagOptions || !tagOptions.length) return ''; + + for (const url of imageUrls) { + try { + const text = await this.recognizeText(url); + if (!text) continue; + + // 按瘤种列表顺序匹配,优先匹配靠前的 + for (const tag of tagOptions) { + // 去掉括号内容做模糊匹配,如 "胃肠道间质瘤(GIST)" 同时匹配 "胃肠道间质瘤" 和 "GIST" + const cleanTag = tag.replace(/[((][^))]*[))]/g, '').trim(); + const bracketMatch = tag.match(/[((]([^))]*)[))]/); + if (text.includes(cleanTag)) return tag; + if (bracketMatch && bracketMatch[1] && text.toUpperCase().includes(bracketMatch[1].toUpperCase())) return tag; + if (text.includes(tag)) return tag; + } + } catch (err) { + think.logger.warn(`[OCR] 识别失败 ${url}:`, err.message); + } + } + return ''; + } + + /** + * 调用腾讯云通用印刷体 OCR + * @param {string} imageUrl - 图片 URL + * @returns {string} 识别出的全部文字 + */ + async recognizeText(imageUrl) { + const tencentcloud = require('tencentcloud-sdk-nodejs'); + const OcrClient = tencentcloud.ocr.v20181119.Client; + + const client = new OcrClient({ + credential: { + secretId: cosConfig.secretId, + secretKey: cosConfig.secretKey + }, + region: 'ap-shanghai', + profile: { + httpProfile: { endpoint: 'ocr.tencentcloudapi.com' } + } + }); + + const result = await client.GeneralBasicOCR({ ImageUrl: imageUrl }); + if (!result.TextDetections || !result.TextDetections.length) return ''; + + return result.TextDetections.map(item => item.DetectedText).join(''); + } +}; diff --git a/view/admin/patient_index.html b/view/admin/patient_index.html index 8c360f2..7b9e6cf 100644 --- a/view/admin/patient_index.html +++ b/view/admin/patient_index.html @@ -258,18 +258,19 @@ const app = createApp({ // 省市区树形数据 const regionTree = ref([]); - // 瘤种选项 - const tagOptions = [ - '直肠癌', '结肠癌', '十二指肠癌', '空肠癌', '回肠癌', - '胃肠道间质瘤(GIST)', '肠道神经内分泌肿瘤', - '腺瘤性息肉', '炎性息肉', '增生性息肉', '平滑肌瘤', '脂肪瘤' - ]; + // 瘤种选项(从接口加载) + const tagOptions = ref([]); const regionFilter = ref([]); const exporting = ref(false); const tabStatusMap = { all: '', draft: '-1', pending: '0', rejected: '2', approved: '1' }; + async function loadTagOptions() { + var res = await fetch('/common/tagOptions').then(function(r) { return r.json(); }); + if (res.code === 0) tagOptions.value = res.data || []; + } + async function loadRegionTree() { var res = await fetch('/common/regions').then(function(r) { return r.json(); }); if (res.code === 0) regionTree.value = res.data || []; @@ -472,6 +473,7 @@ const app = createApp({ onMounted(function() { loadList(); loadRegionTree(); + loadTagOptions(); }); return {