From 97bc48c17cdac3a956fc4ffb0214ee19870bc766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Mon, 17 Aug 2026 09:51:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=20API=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E8=A7=84=E5=88=99=E6=96=B0=E5=BB=BA=20api/workbench.js?= =?UTF-8?q?=EF=BC=88xk-api=E3=80=81unwrapXkApi=20=E5=8F=96=E5=80=BC?= =?UTF-8?q?=EF=BC=89=E3=80=82=20=20=20=20=20=20=20=E6=96=B0=E5=A2=9E=20com?= =?UTF-8?q?ponents/ai-brief-card/ai-brief-card.vue=EF=BC=9A=E5=8D=A1?= =?UTF-8?q?=E7=89=87=20+=20=E5=85=A8=E6=96=87=E5=BA=95=E9=83=A8=E5=BC=B9?= =?UTF-8?q?=E5=B1=82=EF=BC=88page-container=20+=20v-if=20=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E8=BF=94=E5=9B=9E=E9=94=AE=EF=BC=89=EF=BC=8C=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E9=A6=96=E5=BC=B9=E7=94=A8=20storage=20=E6=8C=89?= =?UTF-8?q?=E5=A4=A9=E8=AE=B0=E5=BD=95=EF=BC=9B=E6=A8=A1=E6=9D=BF=E9=81=B5?= =?UTF-8?q?=E5=AE=88=E5=B0=8F=E7=A8=8B=E5=BA=8F=E9=99=90=E5=88=B6=EF=BC=88?= =?UTF-8?q?=E6=97=A0=20=3F./=3F=3F=E3=80=81=E6=97=A0=20:class=20=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E8=B0=83=E7=94=A8=EF=BC=89=E3=80=82=20=20=20=20=20=20?= =?UTF-8?q?=20=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=A1=B5=E9=9D=A2=E6=8C=82?= =?UTF-8?q?=E8=BD=BD=E5=8D=A1=E7=89=87=E5=B9=B6=E6=8E=A5=E5=85=A5=20refres?= =?UTF-8?q?hWorkbenchData=EF=BC=8C=E7=BB=84=E4=BB=B6=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=8C=89=E3=80=8C=E9=97=A8=E5=BA=97+=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E3=80=8D=E5=8E=BB=E9=87=8D=E2=80=94=E2=80=94=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20onShow=20=E5=8F=8D=E5=A4=8D=E8=A7=A6=E5=8F=91=E4=B9=9F?= =?UTF-8?q?=E5=8F=AA=E6=8B=89=E4=B8=80=E6=AC=A1=EF=BC=8C=E5=88=87=E5=BA=97?= =?UTF-8?q?/=E8=B7=A8=E5=A4=A9=E8=87=AA=E5=8A=A8=E9=87=8D=E6=8B=89?= =?UTF-8?q?=EF=BC=88=E8=B7=A8=E5=A4=A9=E9=A6=96=E6=AC=A1=E4=BC=9A=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E7=94=9F=E6=88=90=E5=B9=B6=E8=A7=A6=E5=8F=91=E6=96=B0?= =?UTF-8?q?=E4=B8=80=E5=A4=A9=E7=9A=84=E9=A6=96=E5=BC=B9=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/consult.js | 11 +- api/reception.js | 27 + api/workbench.js | 16 + common/common.js | 3 - components/ai-brief-card/ai-brief-card.vue | 527 ++++++++++++++++++ .../appointment-upcoming-drawer.vue | 461 ++++++++++++++- pages/workbench/index.vue | 17 +- store/index.js | 24 +- .../components/AiPrescriptionDrawer.vue | 252 +++++++++ .../sub_workbench/workbench_consult.vue | 64 +-- 10 files changed, 1321 insertions(+), 81 deletions(-) create mode 100644 api/workbench.js create mode 100644 components/ai-brief-card/ai-brief-card.vue diff --git a/api/consult.js b/api/consult.js index 96afc8f..2558c78 100644 --- a/api/consult.js +++ b/api/consult.js @@ -89,16 +89,7 @@ export function imageOrderInfo(data) { }) } -/*咨询列表 挂号列表 - order_id int 是 订单id -*/ -export function registerList(data) { - return req.request({ - url: 'register/list', - method: 'post', - data - }) -} +// 老 Yii register/list(挂号列表)已迁移到 xk-api doctor-reception-wx/patient-list(见 api/reception.js getPatientList) // 咨询列表角标 /** * @param {Object} data diff --git a/api/reception.js b/api/reception.js index dd9619c..3a5b8fd 100644 --- a/api/reception.js +++ b/api/reception.js @@ -52,6 +52,24 @@ export function getAppointmentUpcomingListApi(data) { }) } +/** 医生代患者改期 xk-api POST /doctor-reception-wx/reschedule-appointment(register_id/visit_date/slot_id/remark,写改期日志) */ +export function rescheduleAppointmentApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/reschedule-appointment', + method: 'POST', + data + }) +} + +/** 医生本人号源日历 xk-api GET /doctor-reception-wx/my-schedule-calendar(改期抽屉选新日期/时段) */ +export function getMyScheduleCalendarApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/my-schedule-calendar', + method: 'GET', + data: data || {} + }) +} + // 获取患者详情(挂号维度的就诊信息,不含处方列表) export function getPatientItem(id) { return req.request({ @@ -611,6 +629,15 @@ export function aiGenerationDetailApi(data) { }) } +/** 标记 AI 生成记录为已读(熄灭未读微光) */ +export function aiMarkGenerationReadApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/ai-mark-generation-read', + method: 'POST', + data + }) +} + /** AI 处方药名模糊对照 */ export function aiMatchPrescriptionDrugsApi(data) { return req.request({ diff --git a/api/workbench.js b/api/workbench.js new file mode 100644 index 0000000..96fd6f2 --- /dev/null +++ b/api/workbench.js @@ -0,0 +1,16 @@ +/** + * 医生工作台扩展接口(xk-api,取值用 code / result / message,配合 unwrapXkApi) + * 看板统计/预约明细等历史接口在 api/reception.js,本文件放工作台新增能力(AI 简报等) + */ +import { + req +} from '@/common/js/index.js'; + +/** AI 今日门诊简报 xk-api GET /doctor-reception-wx/ai-daily-brief(store_id 必传;refresh=1 强制重新生成,后端每天首次生成后按天缓存) */ +export function getAiDailyBriefApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/ai-daily-brief', + method: 'GET', + data + }) +} diff --git a/common/common.js b/common/common.js index 2742c82..aa816b8 100644 --- a/common/common.js +++ b/common/common.js @@ -1,7 +1,4 @@ import $store from '@/store/index.js'; -import { - registerList, -} from '@/api/consult.js' class common { constructor(arg) { this.timer = null diff --git a/components/ai-brief-card/ai-brief-card.vue b/components/ai-brief-card/ai-brief-card.vue new file mode 100644 index 0000000..db1b39e --- /dev/null +++ b/components/ai-brief-card/ai-brief-card.vue @@ -0,0 +1,527 @@ + + + + + diff --git a/components/appointment-upcoming-drawer/appointment-upcoming-drawer.vue b/components/appointment-upcoming-drawer/appointment-upcoming-drawer.vue index 2dbe62a..33476b6 100644 --- a/components/appointment-upcoming-drawer/appointment-upcoming-drawer.vue +++ b/components/appointment-upcoming-drawer/appointment-upcoming-drawer.vue @@ -1,9 +1,10 @@ @@ -90,7 +183,7 @@ * 且三端接口鉴权头不同(医生 JWT / _clinicAdmin / _platformAdmin),收敛在组件内按 role 分发最直观。 * 数据分页由后端 appointment-upcoming-list 提供(page/page_size/visit_date),滚动到底自动翻页。 */ -import { getAppointmentUpcomingListApi } from '@/api/reception.js' +import { getAppointmentUpcomingListApi, rescheduleAppointmentApi, getMyScheduleCalendarApi } from '@/api/reception.js' import { getClinicAdminAppointmentUpcomingList } from '@/api/clinicAdmin.js' import { getPlatformAdminAppointmentUpcomingList } from '@/api/platformAdmin.js' @@ -116,6 +209,18 @@ export default { loading: false, // 请求序号:快速切日期/连点加载更多时,据此丢弃晚到的过期响应,防止旧数据覆盖新列表 reqSeq: 0, + // page-container 重挂载开关(见模板注释) + pageGuard: true, + // ---- 医生代改期子抽屉状态(仅 role=doctor 使用) ---- + rescheduleShow: false, + rescheduleTarget: null, + rescheduleRemark: '', + rescheduleSubmitting: false, + calendarLoading: false, + // 医生本人号源日历(my-schedule-calendar),展示文案在 applyCalendar 预计算 + calendarDays: [], + selectedDayIndex: 0, + selectedSlotId: -1, } }, computed: { @@ -135,6 +240,38 @@ export default { showStore() { return this.role !== 'clinic' }, + /** 仅医生端可代患者改期(管理员端明细只读;医生不受「就诊当天不可改」限制) */ + canReschedule() { + return this.role === 'doctor' + }, + /** 当前选中的日期对象 */ + selectedDay() { + return this.calendarDays[this.selectedDayIndex] || null + }, + /** 当前选中的时段对象 */ + selectedSlot() { + if (!this.selectedDay || this.selectedSlotId < 0) return null + const slots = this.selectedDay.slots || [] + for (let i = 0; i < slots.length; i++) { + if (slots[i].slot_id === this.selectedSlotId) return slots[i] + } + return null + }, + /** 改期目标文案(底部确认条) */ + rescheduleSlotLabel() { + if (!this.selectedDay || !this.selectedSlot) return '' + let label = this.selectedDay.date + ' ' + (this.selectedSlot.slot_name || '') + const s = this.selectedSlot + if (s.start_time && s.end_time && !(s.start_time === '00:00' && s.end_time === '23:59')) { + label += ' ' + s.start_time + '-' + s.end_time + } + return label + }, + /** 是否可提交:选中了出诊日的可约时段 */ + canConfirmReschedule() { + return !!(this.selectedDay && this.selectedDay.is_work && this.selectedSlot + && !this.selectedSlot.is_full && !this.selectedSlot.is_expired) + }, }, watch: { /** 打开时按入口预筛选并拉第一页(每次打开都取最新数据) */ @@ -153,6 +290,16 @@ export default { this.show = v }, onPageLeave() { + // 返回手势优先关改期子抽屉,主抽屉保留(微信每页只允许一个 page-container,收口在此分发) + if (this.rescheduleShow) { + this.rescheduleShow = false + // 原生容器一次 leave 后即失效,重挂载后才能继续拦截「关主抽屉」的下一次返回 + this.pageGuard = false + this.$nextTick(() => { + this.pageGuard = true + }) + return + } this.close() }, /** 切换日期筛选:回到第一页重查 */ @@ -222,7 +369,7 @@ export default { }) }, /** - * 行数据装饰:把状态样式类、短日期先算进数据(模板禁止 :class 调函数) + * 行数据装饰:把状态样式类、短日期、可改期标记先算进数据(模板禁止 :class 调函数) */ decorateItem(item) { let statusClass = 'is-normal' @@ -238,8 +385,125 @@ export default { status_class: statusClass, // 列表里日期缩短为 MM-DD,完整年份对当周预约无增量信息 visit_date_short: (item.visit_date || '').slice(5), + // 仅「已支付 + 待接诊」可改期(待支付单让患者先支付或取消,已接诊单改期无意义) + can_reschedule: item.is_pay === 1 && item.status === 1, } }, + /** 拨打就诊人电话(call_mobile 拦截器解码不脱敏,专供拨号) */ + callPatient(item) { + const phone = item.call_mobile || '' + if (!phone) { + uni.showToast({ title: '暂无联系电话', icon: 'none' }) + return + } + uni.makePhoneCall({ phoneNumber: phone }) + }, + /** 打开改期子抽屉并拉取医生本人号源日历(每次打开取最新余号) */ + openReschedule(item) { + this.rescheduleTarget = item + this.rescheduleRemark = '' + this.rescheduleShow = true + this.calendarLoading = true + this.calendarDays = [] + this.selectedSlotId = -1 + getMyScheduleCalendarApi().then((res) => { + this.calendarLoading = false + if (res && res.code === 0 && res.result) { + this.applyCalendar(res.result) + } + }).catch(() => { + this.calendarLoading = false + }) + }, + closeReschedule() { + this.rescheduleShow = false + }, + onReschedulePopupInput(v) { + this.rescheduleShow = v + }, + /** + * 预处理日历:MM-DD 短日期、余号/约满/休诊文案、标记当前预约时段 + * 与患者端挂号日历同一套预处理逻辑(模板禁止插值调方法,全部提前算好) + */ + applyCalendar(calendar) { + const target = this.rescheduleTarget || {} + const days = (calendar && calendar.days) || [] + const processed = days.map((day) => { + const dateParts = (day.date || '').split('-') + const dateText = dateParts.length === 3 ? dateParts[1] + '-' + dateParts[2] : day.date + let stateText = '休诊' + if (day.is_work) { + stateText = day.is_full ? '约满' : '余' + day.left + } + const slots = (day.slots || []).map((slot) => { + let slotState = '余' + slot.left + if (slot.is_expired) slotState = '已过时' + else if (slot.is_full) slotState = '约满' + if (day.date === target.visit_date && slot.slot_id === target.slot_id) slotState = '当前' + return { ...slot, state_text: slotState } + }) + return { ...day, date_text: dateText, state_text: stateText, slots } + }) + this.calendarDays = processed + // 默认选第一个可约日期(出诊且未约满),都不可约时停在今天 + let idx = 0 + for (let i = 0; i < processed.length; i++) { + if (processed[i].is_work && !processed[i].is_full) { + idx = i + break + } + } + this.selectedDayIndex = idx + this.autoSelectSlot() + }, + /** 切换改期日期:默认选中该日第一个可约时段 */ + selectDay(idx) { + this.selectedDayIndex = idx + this.autoSelectSlot() + }, + /** 选择时段:约满/已过时不可选 */ + selectSlot(slot) { + if (slot.is_full || slot.is_expired) return + this.selectedSlotId = slot.slot_id + }, + /** 自动选中当日第一个可约时段(全天单时段直接选中) */ + autoSelectSlot() { + this.selectedSlotId = -1 + const day = this.calendarDays[this.selectedDayIndex] + if (!day || !day.is_work) return + const slots = day.slots || [] + for (let i = 0; i < slots.length; i++) { + if (!slots[i].is_full && !slots[i].is_expired) { + this.selectedSlotId = slots[i].slot_id + return + } + } + }, + /** 提交改期:成功后刷新明细列表并通知父组件刷新工作台统计 */ + confirmReschedule() { + if (!this.canConfirmReschedule || this.rescheduleSubmitting || !this.rescheduleTarget) return + this.rescheduleSubmitting = true + rescheduleAppointmentApi({ + register_id: this.rescheduleTarget.register_id, + visit_date: this.selectedDay.date, + slot_id: this.selectedSlotId, + remark: this.rescheduleRemark, + }).then((res) => { + this.rescheduleSubmitting = false + if (res && res.code === 0) { + this.rescheduleShow = false + uni.showToast({ title: '改期成功', icon: 'success' }) + this.resetAndLoad() + // 父组件(工作台)可据此刷新预约统计卡片 + this.$emit('rescheduled', res.result || null) + } else { + uni.showToast({ title: (res && res.message) || '改期失败', icon: 'none' }) + } + }).catch(() => { + this.rescheduleSubmitting = false + uni.showToast({ title: '改期失败,请稍后重试', icon: 'none' }) + }) + }, }, } @@ -422,4 +686,195 @@ export default { color: #94a3b8; } } + +// 行内操作按钮(电话/改期):1px 同色系边框 + 微光,不用左侧色条 +.appt-row__actions { + display: flex; + margin-top: 12rpx; +} + +.appt-action { + display: inline-flex; + align-items: center; + padding: 6rpx 20rpx; + margin-right: 16rpx; + border: 1px solid rgba(106, 205, 187, 0.3); + box-shadow: 0 0 6px rgba(106, 205, 187, 0.18); + border-radius: 999rpx; + + &__text { + margin-left: 6rpx; + font-size: 24rpx; + color: #6acdbb; + } +} + +// 医生代改期子抽屉 +.resch { + display: flex; + flex-direction: column; + height: 100%; + background: #fff; +} + +.resch__current { + flex-shrink: 0; + padding: 16rpx 32rpx 0; + font-size: 26rpx; + color: #6c7380; +} + +.resch__body { + flex: 1; + min-height: 0; + padding: 16rpx 24rpx 0; + box-sizing: border-box; +} + +.resch__days { + white-space: nowrap; +} + +.resch__days-inner { + display: inline-flex; +} + +.resch-day { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 124rpx; + padding: 16rpx 0; + margin-right: 16rpx; + border-radius: 12rpx; + border: 1px solid #e5e7eb; + background: #fff; + + &__week { + font-size: 26rpx; + font-weight: 600; + color: #1d2129; + } + + &__date { + font-size: 22rpx; + color: #6c7380; + margin: 6rpx 0; + } + + &__state { + font-size: 22rpx; + color: #6acdbb; + + &--rest { + color: #94a3b8; + } + } + + &--active { + border-color: rgba(106, 205, 187, 0.6); + background: rgba(106, 205, 187, 0.08); + box-shadow: 0 0 6px rgba(106, 205, 187, 0.18); + } + + &--disabled { + opacity: 0.55; + } +} + +.resch__slots { + display: flex; + flex-wrap: wrap; + margin-top: 20rpx; +} + +.resch-slot { + display: flex; + flex-direction: column; + align-items: center; + width: 210rpx; + padding: 14rpx 0; + margin: 0 16rpx 16rpx 0; + border-radius: 12rpx; + border: 1px solid #e5e7eb; + background: #fff; + + &__name { + font-size: 26rpx; + font-weight: 600; + color: #1d2129; + } + + &__time { + font-size: 22rpx; + color: #6c7380; + margin: 4rpx 0; + } + + &__state { + font-size: 22rpx; + color: #6acdbb; + } + + &--active { + border-color: rgba(106, 205, 187, 0.6); + background: rgba(106, 205, 187, 0.08); + box-shadow: 0 0 6px rgba(106, 205, 187, 0.18); + } + + &--disabled { + opacity: 0.55; + } +} + +.resch__tip { + padding: 32rpx 0; + text-align: center; + font-size: 26rpx; + color: #94a3b8; +} + +.resch__remark { + margin: 8rpx 0 32rpx; + + &-input { + width: 100%; + height: 76rpx; + padding: 0 24rpx; + box-sizing: border-box; + font-size: 26rpx; + color: #1d2129; + background: #f5f6f8; + border-radius: 12rpx; + } +} + +.resch__footer { + flex-shrink: 0; + padding: 16rpx 32rpx; + padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); + border-top: 1rpx solid #f0f0f0; +} + +.resch__selected { + font-size: 26rpx; + color: #6acdbb; + margin-bottom: 12rpx; +} + +.resch__btn { + height: 86rpx; + line-height: 86rpx; + text-align: center; + font-size: 32rpx; + font-weight: 500; + color: #fff; + background: #6acdbb; + border-radius: 64rpx; + + &--disabled { + opacity: 0.5; + } +} diff --git a/pages/workbench/index.vue b/pages/workbench/index.vue index b210263..efa60c8 100644 --- a/pages/workbench/index.vue +++ b/pages/workbench/index.vue @@ -95,6 +95,9 @@ + + + @@ -414,12 +417,13 @@ - + @@ -437,9 +441,6 @@ import { homeSession, getNotice } from '@/api/all.js' -import { - registerList -} from '../../api/consult'; import { getAcceptingPatientList as fetchAcceptingPatientListApi, getOnlineConsultationPatientListApi, @@ -640,6 +641,10 @@ export default { }, refreshWorkbenchData() { const silentStats = this.statsInitialized || this.hasStatsCache(this.statsRange); + // AI 简报随工作台一起刷新:组件内部按「门店+日期」去重,同店同天已拉过会直接跳过 + if (this.$refs.aiBrief) { + this.$refs.aiBrief.fetchBrief(false); + } return Promise.all([ this.getNotice(), this.getInfo(), @@ -855,6 +860,10 @@ export default { this.apptDefaultDate = date || ''; this.apptDrawerShow = true; }, + /** 抽屉内改期成功:预约分布/待接诊数已变化,静默刷新看板(不打断用户继续操作抽屉) */ + onAppointmentRescheduled() { + this.fetchWorkbenchStats({ silent: true }); + }, fetchWorkbenchStats(options = {}) { const silent = options.silent === true; const range = options.range || this.statsRange; diff --git a/store/index.js b/store/index.js index 065a2bf..1bfcc72 100644 --- a/store/index.js +++ b/store/index.js @@ -2,21 +2,13 @@ import Vue from 'vue' import Vuex from 'vuex' import { disconnectWebSocket } from '@/utils/ws/initWebSocket.js'; -import { - registerList -} from '@/api/consult.js' Vue.use(Vuex) const store = new Vuex.Store({ state: { utils: null, - consult: [], //咨询会话列表 docSession: [], //医生首页消息会话列表 - status: 1 }, mutations: { - SET_CONSULT(state, res) { - state.consult = res; - }, SET_DOCSESSION(state, res) { state.docSession = res; }, @@ -54,20 +46,8 @@ const store = new Vuex.Store({ closeWebSocket() { disconnectWebSocket(); }, - // 获取挂号信息 - getConsult({ - state, - commit - }) { - registerList({ - status: state.status, - page: 1 - }).then(res => { - if (res.errcode == 0) { - commit('SET_CONSULT', res.data); - } - }) - }, + // 老 getConsult(Yii register/list 轮询挂号列表)已随接口迁移 xk-api 一并移除: + // 挂号列表页自身分页拉取(patient-list),无页面消费该全局状态 } }) export default store diff --git a/subPackages/sub_workbench/prescription_v2/components/AiPrescriptionDrawer.vue b/subPackages/sub_workbench/prescription_v2/components/AiPrescriptionDrawer.vue index f523a94..46eda6e 100644 --- a/subPackages/sub_workbench/prescription_v2/components/AiPrescriptionDrawer.vue +++ b/subPackages/sub_workbench/prescription_v2/components/AiPrescriptionDrawer.vue @@ -48,11 +48,13 @@ :class="{ 'ai-hist-item--active': activeId == row.id, 'ai-hist-item--pending': row.pending, + 'ai-hist-item--unread': row.unread, }" :data-id="row.id" @click="onSelectHistoryTap" > {{ row.nameText }} + 未读 {{ row.typeText }} @@ -65,6 +67,33 @@ /> 加载历史处方… + + + + + {{ tab.label }} + 新 + + + + + {{ multiFailMsg }} + 请选择历史或生成处方 本次生成未产出对照结果(耗时 {{ durationText }}) @@ -315,6 +344,7 @@ import { aiFeatureGateApi, aiGeneratePrescriptionApi, aiListGenerationsApi, + aiMarkGenerationReadApi, aiMatchPrescriptionDrugsApi, aiSavePrescriptionDrugSelectionApi, getProcessRuleList, @@ -408,6 +438,11 @@ export default { consentText: '', consentVisible: false, consentSubmitting: false, + /** 多方案对比:后端 prescriptions[] */ + multiPrescriptions: [], + multiActiveIndex: 0, + multiTabView: [], + multiFailMsg: '', importBtnStyle: { backgroundColor: '#6ACDBB', color: '#fff', @@ -539,6 +574,10 @@ export default { this.rxChildProcessRuleName = '' this.rxProcessRuleNote = '' this.activeId = 0 + this.multiPrescriptions = [] + this.multiActiveIndex = 0 + this.multiTabView = [] + this.multiFailMsg = '' this.rebuildMatchView() }, onClose() { @@ -603,9 +642,44 @@ export default { typeText: typeLabel + ' · ' + cnt + '味 · ' + this.formatTime(row.created_at) + (row.duration_ms ? (' · ' + this.formatDurationMs(row.duration_ms)) : ''), prescription_type: pt, pending: false, + unread: Number(row.is_read) !== 1, } }) }, + /** + * 本地同步已读状态(历史 + 多方案) + */ + patchLocalRead(generationId) { + const gid = Number(generationId || 0) + if (gid <= 0) return + const hist = Array.isArray(this.historyList) ? this.historyList : [] + for (let i = 0; i < hist.length; i++) { + if (Number(hist[i].id) === gid) { + this.$set(this.historyList[i], 'is_read', 1) + } + } + const multi = Array.isArray(this.multiPrescriptions) ? this.multiPrescriptions : [] + for (let j = 0; j < multi.length; j++) { + if (Number((multi[j] && multi[j].generation_id) || 0) === gid) { + this.$set(this.multiPrescriptions[j], 'is_read', 1) + } + } + this.rebuildHistoryView() + this.rebuildMultiTabView() + }, + /** + * 查看方案后标记已读(失败不阻断) + */ + async markReadIfNeeded(generationId, alreadyRead) { + const gid = Number(generationId || 0) + if (gid <= 0 || Number(alreadyRead) === 1) return + try { + await aiMarkGenerationReadApi({ generation_id: gid }) + this.patchLocalRead(gid) + } catch (e) { + // ignore + } + }, /** * 开始生成:取消旧选中、清空对照预览,历史顶部插入「正在生成中」占位 * 避免仍高亮旧方案,造成「还在看上一次结果」的错觉 @@ -908,6 +982,66 @@ export default { onGenFieldBlur() { this.editingKey = '' }, + /** + * 重建多方案 Tab 展示数据(模板禁止方法调用,先算进 data) + */ + rebuildMultiTabView() { + const list = Array.isArray(this.multiPrescriptions) ? this.multiPrescriptions : [] + const active = Number(this.multiActiveIndex || 0) + const tabs = [] + for (let i = 0; i < list.length; i++) { + const p = list[i] || {} + const failed = p.ok === false + const unread = !failed && Number(p.is_read) !== 1 + tabs.push({ + index: i, + label: failed ? ('方案' + (i + 1) + '失败') : ('方案' + (i + 1)), + failed: failed, + active: i === active, + unread: unread, + }) + } + this.multiTabView = tabs + const cur = list[active] || null + this.multiFailMsg = + cur && cur.ok === false ? String(cur.error_msg || '该方案生成失败') : '' + }, + /** + * 应用某一份多方案到右侧对照区 + */ + applyMultiSlot(slot, index) { + this.multiActiveIndex = Number(index || 0) + this.rebuildMultiTabView() + if (!slot || slot.ok === false) { + this.matchResult = null + this.matchedList = [] + this.unmatchedList = [] + this.conflictView = [] + this.applyRxMeta({ + dosage: 0, + day_dosage: 0, + reason: '', + basis: '', + prescription_name: '', + duration_ms: slot ? slot.duration_ms : 0, + }) + this.activeId = Number((slot && slot.generation_id) || 0) + this.rebuildMatchView() + return + } + this.activeId = Number(slot.generation_id || 0) + this.applyMatch(slot.match || null) + this.applyConflict(slot.conflict || null) + this.applyRxMeta(slot) + this.markReadIfNeeded(this.activeId, slot.is_read) + }, + onMultiTabTap(e) { + const ds = (e && e.currentTarget && e.currentTarget.dataset) || {} + const idx = Number(ds.index || 0) + const slot = this.multiPrescriptions[idx] + if (!slot) return + this.applyMultiSlot(slot, idx) + }, async onGenerate() { if (this.generating) return const chief = String(this.aiChief || '').trim() @@ -928,6 +1062,9 @@ export default { this.closeGenModal() this.beginGeneratingPlaceholder() this.generating = true + this.multiPrescriptions = [] + this.multiActiveIndex = 0 + this.rebuildMultiTabView() try { const req = { register_id: Number(this.registerId), @@ -946,10 +1083,15 @@ export default { const res = await aiGeneratePrescriptionApi(req) const data = this.unwrapBiz(res) || {} const durationText = this.formatDurationMs(data.duration_ms) + const list = Array.isArray(data.prescriptions) ? data.prescriptions : [] // 业务软失败:HTTP 成功但 ok=false,toast 警告而非失败 if (data.ok === false) { this.endGeneratingPlaceholder() this.resetPreview() + if (list.length > 1) { + this.multiPrescriptions = list + this.rebuildMultiTabView() + } if (data.generation_id) { await this.loadHistory() this.activeId = Number(data.generation_id) @@ -958,10 +1100,36 @@ export default { return } await this.loadHistory() + if (list.length > 1) { + this.multiPrescriptions = list + let firstOk = 0 + for (let i = 0; i < list.length; i++) { + if (list[i] && list[i].ok !== false) { + firstOk = i + break + } + } + this.applyMultiSlot(list[firstOk], firstOk) + let okN = 0 + for (let i = 0; i < list.length; i++) { + if (list[i] && list[i].ok !== false) okN++ + } + const failN = list.length - okN + const base = + failN > 0 + ? '已生成' + okN + '套可用(' + failN + '套失败)' + : '已生成' + okN + '套方案' + uni.showToast({ + title: durationText ? base + '(' + durationText + ')' : base, + icon: 'none', + }) + return + } this.activeId = Number(data.generation_id || 0) this.applyMatch(data.match || null) this.applyConflict(data.conflict || null) this.applyRxMeta(data) + this.markReadIfNeeded(this.activeId, 0) uni.showToast({ title: durationText ? ('已生成(耗时 ' + durationText + ')') : '已生成', icon: 'success', @@ -986,6 +1154,11 @@ export default { }, async selectHistoryById(id) { this.activeId = id + // 点历史时退出本次多方案对比 + this.multiPrescriptions = [] + this.multiActiveIndex = 0 + this.multiTabView = [] + this.multiFailMsg = '' this.matchLoading = true try { let pt = Number(this.prescriptionType || 0) @@ -1008,6 +1181,8 @@ export default { prescription_type: pt, }) const data = this.unwrapBiz(res) || {} + // match 后端已标已读,本地熄灭未读微光 + this.patchLocalRead(id) this.applyMatch(data) this.applyConflict(data.conflict || null) this.applyRxMeta({ @@ -1099,6 +1274,13 @@ export default { this.visible = false }, onConfirmImport() { + if (this.multiPrescriptions && this.multiPrescriptions.length > 1) { + const cur = this.multiPrescriptions[this.multiActiveIndex] + if (!cur || cur.ok === false) { + uni.showToast({ title: '请切换到成功方案后再导入', icon: 'none' }) + return + } + } const drugs = [] for (let i = 0; i < this.matchedList.length; i++) { const m = this.matchedList[i] @@ -1175,6 +1357,44 @@ export default { font-size: 24rpx; color: #6acdbb; } +.ai-multi-tabs { + width: 100%; + white-space: nowrap; + margin-bottom: 12rpx; +} +.ai-multi-tabs-inner { + padding: 0 8rpx 8rpx; +} +.ai-multi-tab { + display: inline-flex; + align-items: center; + padding: 10rpx 20rpx; + margin-right: 12rpx; + border-radius: 12rpx; + border: 1px solid rgba(106, 205, 187, 0.35); + background: #fff; + color: #666; + box-shadow: 0 0 6px rgba(106, 205, 187, 0.12); +} +.ai-multi-tab--active { + border-color: rgba(106, 205, 187, 0.8); + color: #2a9d8f; + background: rgba(106, 205, 187, 0.12); + box-shadow: 0 0 8px rgba(106, 205, 187, 0.22); +} +.ai-multi-tab--fail { + border-color: rgba(239, 68, 68, 0.35); + color: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.12); +} +.ai-multi-fail { + margin: 0 8rpx 12rpx; + padding: 12rpx 16rpx; + border-radius: 12rpx; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.06); + color: #ef4444; +} .ai-rx-meta { margin: 0 0 12rpx; background: #fff; @@ -1280,6 +1500,38 @@ export default { border: 1px dashed #6acdbb; background: #f0faf8; } +/* 未读:同色系边框 + 微光脉冲(禁止左侧色条) */ +.ai-hist-item--unread { + margin: 8rpx; + border-radius: 12rpx; + border: 1px solid rgba(106, 205, 187, 0.55); + box-shadow: + 0 0 0 1px rgba(106, 205, 187, 0.12), + 0 0 12rpx rgba(106, 205, 187, 0.35); + animation: ai-unread-glow 2.2s ease-in-out infinite; +} +.ai-unread-badge { + color: #2a9d8f; + margin-top: 4rpx; +} +.ai-multi-tab--unread { + border-color: rgba(106, 205, 187, 0.7); + box-shadow: 0 0 10rpx rgba(106, 205, 187, 0.35); + animation: ai-unread-glow 2.2s ease-in-out infinite; +} +@keyframes ai-unread-glow { + 0%, + 100% { + box-shadow: + 0 0 0 1px rgba(106, 205, 187, 0.1), + 0 0 8rpx rgba(106, 205, 187, 0.25); + } + 50% { + box-shadow: + 0 0 0 1px rgba(106, 205, 187, 0.22), + 0 0 16rpx rgba(106, 205, 187, 0.45); + } +} .ai-hist-name { overflow: hidden; text-overflow: ellipsis; diff --git a/subPackages/sub_workbench/workbench_consult.vue b/subPackages/sub_workbench/workbench_consult.vue index f19dbfd..44d0044 100644 --- a/subPackages/sub_workbench/workbench_consult.vue +++ b/subPackages/sub_workbench/workbench_consult.vue @@ -14,7 +14,7 @@ :title="item.patient" :meta="(item.sex % 2 != 0 ? '男' : '女') + ' · ' + item.age + '岁'" :time="item.created_at" - :rows="['推广员:' + (item.salesperson_name || '无')]" + :rows="item.info_rows" :status="{ text: tabs[current].name, color: tabs[current].color }" :actions="[{ key: 'info', text: current === 0 ? '去接诊' : '查看详情', type: current === 0 ? 'primary' : 'ghost' }]" @action="toInfo(item.user_patient_id, item.id)" @@ -36,10 +36,11 @@