| @@ -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)","肠道神经内分泌肿瘤","腺瘤性息肉","炎性息肉","增生性息肉","平滑肌瘤","脂肪瘤"]', '瘤种选项列表'); | |||||
| @@ -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 | * GET /common/regions | ||||
| @@ -281,7 +281,7 @@ module.exports = class extends Base { | |||||
| async saveMyInfoAction() { | async saveMyInfoAction() { | ||||
| const mpUser = this.mpUser; | const mpUser = this.mpUser; | ||||
| if (!mpUser) return this.json({ code: 1009, msg: '请先登录' }); | 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(); | 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 (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: '请选择省市区' }); | 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 || '', | gender: gender || '', province_code: province_code || '', city_code: city_code || '', | ||||
| district_code: district_code || '', address: address || '', | district_code: district_code || '', address: address || '', | ||||
| emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', | emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', | ||||
| tag: tag || '', documents: JSON.stringify(documents || []), | |||||
| documents: JSON.stringify(documents || []), | |||||
| sign_income: sign_income || '', | sign_income: sign_income || '', | ||||
| sign_privacy: sign_privacy || '', | sign_privacy: sign_privacy || '', | ||||
| sign_promise: sign_promise || '', | sign_promise: sign_promise || '', | ||||
| @@ -312,6 +312,28 @@ module.exports = class extends Base { | |||||
| operator_id: 0, | operator_id: 0, | ||||
| operator_name: '用户自助提交' | 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: '提交成功' }); | return this.json({ code: 0, data: {}, msg: '提交成功' }); | ||||
| } catch (error) { | } catch (error) { | ||||
| think.logger.error('saveMyInfo error:', error); | think.logger.error('saveMyInfo error:', error); | ||||
| @@ -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 }); | |||||
| } | |||||
| }; | |||||
| @@ -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(''); | |||||
| } | |||||
| }; | |||||
| @@ -258,18 +258,19 @@ const app = createApp({ | |||||
| // 省市区树形数据 | // 省市区树形数据 | ||||
| const regionTree = ref([]); | const regionTree = ref([]); | ||||
| // 瘤种选项 | |||||
| const tagOptions = [ | |||||
| '直肠癌', '结肠癌', '十二指肠癌', '空肠癌', '回肠癌', | |||||
| '胃肠道间质瘤(GIST)', '肠道神经内分泌肿瘤', | |||||
| '腺瘤性息肉', '炎性息肉', '增生性息肉', '平滑肌瘤', '脂肪瘤' | |||||
| ]; | |||||
| // 瘤种选项(从接口加载) | |||||
| const tagOptions = ref([]); | |||||
| const regionFilter = ref([]); | const regionFilter = ref([]); | ||||
| const exporting = ref(false); | const exporting = ref(false); | ||||
| const tabStatusMap = { all: '', draft: '-1', pending: '0', rejected: '2', approved: '1' }; | 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() { | async function loadRegionTree() { | ||||
| var res = await fetch('/common/regions').then(function(r) { return r.json(); }); | var res = await fetch('/common/regions').then(function(r) { return r.json(); }); | ||||
| if (res.code === 0) regionTree.value = res.data || []; | if (res.code === 0) regionTree.value = res.data || []; | ||||
| @@ -472,6 +473,7 @@ const app = createApp({ | |||||
| onMounted(function() { | onMounted(function() { | ||||
| loadList(); | loadList(); | ||||
| loadRegionTree(); | loadRegionTree(); | ||||
| loadTagOptions(); | |||||
| }); | }); | ||||
| return { | return { | ||||