feat: 按 API 拆分规则新建 api/workbench.js(xk-api、unwrapXkApi 取值)。

新增 components/ai-brief-card/ai-brief-card.vue:卡片 + 全文底部弹层(page-container + v-if 拦截返回键),每日首弹用 storage 按天记录;模板遵守小程序限制(无 ?./??、无 :class 函数调用)。
      工作台页面挂载卡片并接入 refreshWorkbenchData,组件内部按「门店+日期」去重——页面 onShow 反复触发也只拉一次,切店/跨天自动重拉(跨天首次会重新生成并触发新一天的首弹)。
This commit is contained in:
李琦
2026-08-17 09:51:49 +08:00
parent 1fbc14638a
commit 97bc48c17c
10 changed files with 1321 additions and 81 deletions

View File

@@ -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

View File

@@ -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({

16
api/workbench.js Normal file
View File

@@ -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
})
}

View File

@@ -1,7 +1,4 @@
import $store from '@/store/index.js';
import {
registerList,
} from '@/api/consult.js'
class common {
constructor(arg) {
this.timer = null

View File

@@ -0,0 +1,527 @@
<template>
<view>
<!-- AI 今日简报卡片:每天首次进工作台后端生成并缓存,之后常驻卡片可随时查看 -->
<view class="ai-brief-card">
<view class="ai-brief-card__head">
<view class="ai-brief-card__title-wrap">
<view class="ai-brief-card__badge">AI</view>
<text class="ai-brief-card__title">今日简报</text>
<text v-if="generatedAtText" class="ai-brief-card__time">{{ generatedAtText }}</text>
</view>
<view class="ai-brief-card__refresh" :class="{ 'is-disabled': loading }" @click="onRefreshTap">
<u-icon name="reload" size="22" color="#6acdbb"></u-icon>
<text class="ai-brief-card__refresh-text">重新生成</text>
</view>
</view>
<!-- 加载态:AI 生成可能耗时十几秒,给出明确等待文案 -->
<view v-if="loading" class="ai-brief-card__loading">
<u-loading mode="circle" size="28"></u-loading>
<text class="ai-brief-card__loading-text">AI 正在分析今日门诊数据…</text>
</view>
<!-- 接口失败态(网络层) -->
<view v-else-if="fetchError" class="ai-brief-card__error">
<text class="ai-brief-card__error-text">简报加载失败</text>
<view class="ai-brief-card__retry" @click="onRetryTap">重试</view>
</view>
<!-- AI 生成失败态:快照芯片仍可看,正文位置给重试引导 -->
<block v-else-if="brief && brief.status !== 1">
<view v-if="chips.length" class="ai-brief-card__chips">
<view v-for="chip in chips" :key="chip.label" class="ai-chip" :class="{ 'ai-chip--hl': chip.highlight }">
<text class="ai-chip__value">{{ chip.value }}</text>
<text class="ai-chip__label">{{ chip.label }}</text>
</view>
</view>
<view class="ai-brief-card__error">
<text class="ai-brief-card__error-text">AI 生成失败,请重试</text>
<view class="ai-brief-card__retry" @click="onRefreshTap">重新生成</view>
</view>
</block>
<!-- 成功态:快照芯片 + 正文预览(收纳高度,点击弹层看全文) -->
<block v-else-if="brief">
<view v-if="chips.length" class="ai-brief-card__chips">
<view v-for="chip in chips" :key="chip.label" class="ai-chip" :class="{ 'ai-chip--hl': chip.highlight }">
<text class="ai-chip__value">{{ chip.value }}</text>
<text class="ai-chip__label">{{ chip.label }}</text>
</view>
</view>
<view class="ai-brief-card__body" @click="openPopup">
<view v-for="(section, idx) in sections" :key="idx" class="ai-brief-card__section">
<text v-if="section.title" class="ai-brief-card__section-tag">{{ section.title }}</text>
<text class="ai-brief-card__section-text">{{ section.text }}</text>
</view>
</view>
<view class="ai-brief-card__more" @click="openPopup">
<text class="ai-brief-card__more-text">查看全文</text>
<u-icon name="arrow-right" size="22" color="#6acdbb"></u-icon>
</view>
</block>
</view>
<!-- 每日首弹/查看全文弹层:page-container 拦截返回键防误退(规则:抽屉必须 page-container + v-if) -->
<!-- #ifdef MP-WEIXIN -->
<page-container v-if="popupShow" :show="popupShow" :overlay="false" @leave="onPageLeave" />
<!-- #endif -->
<u-popup :value="popupShow" mode="bottom" border-radius="24" height="70%" :safe-area-inset-bottom="true" :mask-close-able="true" @input="onPopupInput" @close="closePopup">
<view class="ai-popup">
<view class="ai-popup__header">
<view class="ai-popup__title-wrap">
<view class="ai-brief-card__badge">AI</view>
<text class="ai-popup__title">{{ popupDateText }}</text>
</view>
<text class="ai-popup__close" @click="closePopup">关闭</text>
</view>
<scroll-view scroll-y="true" class="ai-popup__scroll">
<view v-if="chips.length" class="ai-brief-card__chips ai-popup__chips">
<view v-for="chip in chips" :key="chip.label" class="ai-chip" :class="{ 'ai-chip--hl': chip.highlight }">
<text class="ai-chip__value">{{ chip.value }}</text>
<text class="ai-chip__label">{{ chip.label }}</text>
</view>
</view>
<view class="ai-popup__body">
<view v-for="(section, idx) in sections" :key="idx" class="ai-popup__section">
<text v-if="section.title" class="ai-brief-card__section-tag">{{ section.title }}</text>
<text class="ai-popup__section-text">{{ section.text }}</text>
</view>
</view>
<view class="ai-popup__meta">由 AI 生成,仅供参考{{ generatedAtText ? ' · ' + generatedAtText : '' }}</view>
</scroll-view>
<view class="ai-popup__footer">
<view class="ai-popup__btn" @click="closePopup">知道了</view>
</view>
</view>
</u-popup>
</view>
</template>
<script>
/**
* AI 今日门诊简报卡片 + 每日首弹弹层(医生工作台)
* 数据流:挂载/切换门店时拉 /doctor-reception-wx/ai-daily-brief(后端每天首次生成并按天缓存);
* 弹层策略:生成成功且本机今天没弹过时自动弹一次全文(storage 按天记录),之后可点「查看全文」再看;
* 「重新生成」传 refresh=1 强制后端重生成(原行 UPDATE,不新增缓存行)
*/
import { getAiDailyBriefApi } from '@/api/workbench.js'
import { unwrapXkApi } from '@/utils/api-response.js'
/** 每日首弹的 storage key(按天记录,避免当天反复弹打扰) */
const POPUP_STORAGE_KEY = 'ai_brief_popup_date'
export default {
name: 'ai-brief-card',
props: {
/** 当前门店ID:简报按 doctor+store 缓存(排班/挂号统计都是按店口径),切店需重拉 */
storeId: {
type: [Number, String],
default: ''
}
},
data() {
return {
brief: null,
loading: false,
// 接口本身失败(网络/鉴权),区别于 brief.status=0 的「AI 生成失败」
fetchError: false,
popupShow: false,
// 去重键(storeId_日期):页面 onShow 会反复触发刷新,同店同天已拉过则跳过,
// 跨天/切店后 key 变化自动重拉(跨天首次会重新生成并触发每日首弹)
lastFetchKey: ''
}
},
computed: {
/** 生成时间展示(缓存行的更新时间) */
generatedAtText() {
return this.brief && this.brief.generated_at ? this.brief.generated_at : ''
},
/** 弹层标题:简报日期 */
popupDateText() {
const date = this.brief && this.brief.brief_date ? this.brief.brief_date : ''
return date ? date + ' 今日简报' : '今日简报'
},
/**
* 正文分段:后端约定 AI 输出「【今日概览】...【重点提醒】...【时间安排建议】...」,
* 按【标题】切段渲染更易读;AI 偶发不按格式时兜底整段展示
*/
sections() {
const content = this.brief && this.brief.content ? this.brief.content : ''
if (!content) return []
const matches = []
const reg = /【([^】]+)】([^【]*)/g
let m = reg.exec(content)
while (m) {
matches.push({ title: m[1] || '', text: (m[2] || '').trim() })
m = reg.exec(content)
}
if (matches.length === 0) return [{ title: '', text: content }]
return matches
},
/**
* 快照统计芯片:读生成时落库的 data_snapshot,保证数字与 AI 文本口径一致
* (值班余号/待接诊/今日到期预约/未来7天预约)
*/
chips() {
const data = this.brief && this.brief.data ? this.brief.data : null
if (!data) return []
const today = data.today || {}
const schedule = data.schedule || {}
const upcoming = data.upcoming || {}
const waitAccept = Number(today.wait_accept) || 0
const dueWaiting = Number(today.appointment_due_waiting) || 0
return [
{
label: '今日值班',
value: schedule.is_work ? (Number(schedule.left) || 0) + '/' + (Number(schedule.total) || 0) + '余号' : '休诊',
highlight: false
},
{ label: '待接诊', value: waitAccept, highlight: waitAccept > 0 },
{ label: '今日到期预约', value: dueWaiting, highlight: dueWaiting > 0 },
{ label: '未来7天预约', value: Number(upcoming.total) || 0, highlight: false }
]
}
},
mounted() {
this.fetchBrief(false)
},
methods: {
/** 今天日期字符串(去重键用,与后端 brief_date 同格式) */
todayStr() {
const d = new Date()
const pad = (n) => (n < 10 ? '0' + n : '' + n)
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
},
/**
* 拉取简报;refresh=true 强制后端重新生成(用户点「重新生成」按钮)
* 页面 onShow / 切店都会调用本方法,靠 lastFetchKey 去重避免重复请求;
* 首次生成可能耗时十几秒(走 AI),期间维持加载态
*/
async fetchBrief(refresh) {
if (!this.storeId || this.loading) return
const fetchKey = String(this.storeId) + '_' + this.todayStr()
if (!refresh && this.brief && this.lastFetchKey === fetchKey) return
this.loading = true
this.fetchError = false
try {
const params = { store_id: this.storeId }
if (refresh) params.refresh = 1
const res = await getAiDailyBriefApi(params)
const { ok, payload } = unwrapXkApi(res)
if (ok && payload) {
this.brief = payload
this.lastFetchKey = fetchKey
this.maybeAutoPopup()
} else {
this.fetchError = true
}
} catch (e) {
this.fetchError = true
} finally {
this.loading = false
}
},
/**
* 每日首弹:生成成功且本机今天没弹过才自动弹全文
* 失败态不自动弹(打扰用户),卡片内保留重试入口
*/
maybeAutoPopup() {
if (!this.brief || this.brief.status !== 1) return
const today = this.brief.brief_date
if (!today) return
if (uni.getStorageSync(POPUP_STORAGE_KEY) === today) return
uni.setStorageSync(POPUP_STORAGE_KEY, today)
this.popupShow = true
},
/** 重新生成(防抖:加载中不响应) */
onRefreshTap() {
if (this.loading) return
this.fetchBrief(true)
},
/** 网络失败重试(读缓存即可,不强制重生成) */
onRetryTap() {
if (this.loading) return
this.fetchBrief(false)
},
/** 点「查看全文」手动打开弹层 */
openPopup() {
this.popupShow = true
},
closePopup() {
this.popupShow = false
},
/** u-popup 双向同步(蒙层点击关闭等场景) */
onPopupInput(val) {
this.popupShow = val
},
/** page-container 返回键触发:关闭弹层而非退出页面 */
onPageLeave() {
this.popupShow = false
}
}
}
</script>
<style lang="scss" scoped>
/* 卡片:白底 + AI 主题绿 1px 边框 + 微光(规则:不用左侧竖色条,用同色系边框+外发光强调) */
.ai-brief-card {
background: #fff;
border: 1px solid rgba(106, 205, 187, 0.3);
border-radius: 16rpx;
box-shadow: 0 0 6px rgba(106, 205, 187, 0.18);
padding: 24rpx;
margin-bottom: 20rpx;
&__head {
display: flex;
justify-content: space-between;
align-items: center;
}
&__title-wrap {
display: flex;
align-items: center;
}
&__badge {
padding: 2rpx 12rpx;
background: linear-gradient(135deg, #6acdbb, #4db6a3);
border-radius: 8rpx;
font-size: 20rpx;
font-weight: 600;
color: #fff;
margin-right: 12rpx;
}
&__title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
&__time {
font-size: 22rpx;
color: #b0b3b8;
margin-left: 16rpx;
}
&__refresh {
display: flex;
align-items: center;
padding: 6rpx 16rpx;
border: 1px solid rgba(106, 205, 187, 0.4);
border-radius: 999rpx;
&.is-disabled {
opacity: 0.5;
}
}
&__refresh-text {
font-size: 22rpx;
color: #6acdbb;
margin-left: 6rpx;
}
&__loading {
display: flex;
align-items: center;
padding: 28rpx 8rpx;
}
&__loading-text {
font-size: 24rpx;
color: #909399;
margin-left: 16rpx;
}
&__error {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 8rpx;
}
&__error-text {
font-size: 24rpx;
color: #909399;
}
&__retry {
padding: 6rpx 24rpx;
background: rgba(106, 205, 187, 0.08);
border: 1px solid rgba(106, 205, 187, 0.4);
border-radius: 999rpx;
font-size: 22rpx;
color: #6acdbb;
}
&__chips {
display: flex;
flex-wrap: wrap;
margin-top: 20rpx;
}
/* 正文预览:限高收纳,点开弹层看全文 */
&__body {
max-height: 300rpx;
overflow: hidden;
background: #f7f9f8;
border-radius: 12rpx;
padding: 20rpx 24rpx;
margin-top: 20rpx;
}
&__section {
display: flex;
flex-direction: column;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
&__section-tag {
align-self: flex-start;
padding: 2rpx 16rpx;
background: rgba(106, 205, 187, 0.12);
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 600;
color: #4db6a3;
margin-bottom: 8rpx;
}
&__section-text {
font-size: 24rpx;
line-height: 1.7;
color: #555;
}
&__more {
display: flex;
align-items: center;
justify-content: center;
padding-top: 16rpx;
}
&__more-text {
font-size: 22rpx;
color: #6acdbb;
margin-right: 4rpx;
}
}
/* 快照统计芯片 */
.ai-chip {
display: flex;
align-items: center;
padding: 8rpx 18rpx;
border: 1px solid #ebeef5;
border-radius: 999rpx;
margin-right: 12rpx;
margin-bottom: 12rpx;
&--hl {
border-color: rgba(251, 140, 0, 0.45);
background: rgba(251, 140, 0, 0.08);
}
&__value {
font-size: 24rpx;
font-weight: 650;
color: #333;
}
&__label {
font-size: 22rpx;
color: #909399;
margin-left: 8rpx;
}
}
/* 全文弹层 */
.ai-popup {
display: flex;
flex-direction: column;
height: 100%;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 32rpx 20rpx;
border-bottom: 1px solid #f2f3f5;
}
&__title-wrap {
display: flex;
align-items: center;
}
&__title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
&__close {
font-size: 26rpx;
color: #909399;
padding: 8rpx;
}
&__scroll {
flex: 1;
min-height: 0;
padding: 24rpx 32rpx;
box-sizing: border-box;
}
&__chips {
margin-top: 0;
margin-bottom: 8rpx;
}
&__body {
display: flex;
flex-direction: column;
}
&__section {
display: flex;
flex-direction: column;
margin-bottom: 24rpx;
}
&__section-text {
font-size: 26rpx;
line-height: 1.8;
color: #444;
}
&__meta {
padding: 16rpx 0 32rpx;
border-top: 1px solid #f2f3f5;
font-size: 22rpx;
color: #b0b3b8;
}
&__footer {
padding: 16rpx 32rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
}
&__btn {
height: 80rpx;
line-height: 80rpx;
text-align: center;
background: linear-gradient(135deg, #6acdbb, #4db6a3);
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
}
</style>

View File

@@ -1,9 +1,10 @@
<template>
<view>
<!-- 页面级返回拦截:防止用户按返回键直接退出页面(规则要求抽屉必须配 page-container + v-if) -->
<!-- pageGuard 用于两级抽屉场景:原生容器一次 leave 后失效,关掉子抽屉后需重挂载才能拦截下一次返回 -->
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="show"
v-if="pageGuard && show"
:show="show"
:overlay="false"
@leave="onPageLeave"
@@ -62,6 +63,19 @@
<view class="appt-row__line2">
<text v-if="showDoctor && item.doctor_name" class="appt-row__meta">医生:{{ item.doctor_name }}</text>
<text v-if="showStore && item.store_name" class="appt-row__meta">{{ item.store_name }}</text>
<!-- 脱敏手机号展示(拦截器已解码+脱敏),拨号用未脱敏的 call_mobile -->
<text v-if="item.patient_mobile" class="appt-row__meta">{{ item.patient_mobile }}</text>
</view>
<!-- 行操作:电话沟通 + 医生代改期(仅医生端、已支付待接诊单可改) -->
<view class="appt-row__actions" v-if="item.call_mobile || (canReschedule && item.can_reschedule)">
<view v-if="item.call_mobile" class="appt-action" @click="callPatient(item)">
<u-icon name="phone" size="24" color="#6acdbb"></u-icon>
<text class="appt-action__text">电话</text>
</view>
<view v-if="canReschedule && item.can_reschedule" class="appt-action" @click="openReschedule(item)">
<u-icon name="calendar" size="24" color="#6acdbb"></u-icon>
<text class="appt-action__text">改期</text>
</view>
</view>
</view>
<text class="appt-row__status" :class="item.status_class">{{ item.status_text }}</text>
@@ -79,6 +93,85 @@
</scroll-view>
</view>
</u-popup>
<!-- 医生代改期子抽屉:日历日期 + 时段选择(数据来自 my-schedule-calendar),z-index 需高于明细抽屉 -->
<u-popup
v-if="role === 'doctor'"
:value="rescheduleShow"
mode="bottom"
border-radius="24"
height="72%"
:safe-area-inset-bottom="true"
:mask-close-able="true"
:z-index="10090"
@input="onReschedulePopupInput"
@close="closeReschedule"
>
<view class="resch">
<view class="appt-drawer__header">
<text class="appt-drawer__title">修改预约时间</text>
<text class="appt-drawer__close" @click="closeReschedule">关闭</text>
</view>
<view class="resch__current" v-if="rescheduleTarget">
{{ rescheduleTarget.patient_name || '就诊人' }} · 当前:{{ rescheduleTarget.visit_date }} {{ rescheduleTarget.slot_label }}
</view>
<scroll-view scroll-y="true" class="resch__body">
<view v-if="calendarLoading" class="appt-drawer__loading">
<u-loading mode="circle" size="36"></u-loading>
</view>
<block v-else>
<view v-if="calendarDays.length > 0">
<scroll-view scroll-x="true" class="resch__days" :show-scrollbar="false">
<view class="resch__days-inner">
<view
v-for="(day, idx) in calendarDays"
:key="day.date"
class="resch-day"
:class="{ 'resch-day--active': idx === selectedDayIndex, 'resch-day--disabled': !day.is_work || day.is_full }"
@click="selectDay(idx)"
>
<view class="resch-day__week">{{ day.week }}</view>
<view class="resch-day__date">{{ day.date_text }}</view>
<view class="resch-day__state" :class="{ 'resch-day__state--rest': !day.is_work }">{{ day.state_text }}</view>
</view>
</view>
</scroll-view>
<view class="resch__slots" v-if="selectedDay && selectedDay.is_work && selectedDay.slots.length > 1">
<view
v-for="slot in selectedDay.slots"
:key="slot.slot_id"
class="resch-slot"
:class="{ 'resch-slot--active': slot.slot_id === selectedSlotId, 'resch-slot--disabled': slot.is_full || slot.is_expired }"
@click="selectSlot(slot)"
>
<view class="resch-slot__name">{{ slot.slot_name }}</view>
<view class="resch-slot__time">{{ slot.start_time }}-{{ slot.end_time }}</view>
<view class="resch-slot__state">{{ slot.state_text }}</view>
</view>
</view>
<view class="resch__tip" v-if="selectedDay && !selectedDay.is_work">该日休诊,请选择其他日期</view>
</view>
<view class="resch__tip" v-else>暂无可约号源</view>
<view class="resch__remark">
<input
class="resch__remark-input"
v-model="rescheduleRemark"
placeholder="备注(选填,如:电话已与患者确认)"
maxlength="100"
/>
</view>
</block>
</scroll-view>
<view class="resch__footer">
<view class="resch__selected" v-if="rescheduleSlotLabel">改期至:{{ rescheduleSlotLabel }}</view>
<view
class="resch__btn"
:class="{ 'resch__btn--disabled': !canConfirmReschedule || rescheduleSubmitting }"
@click="confirmReschedule"
>{{ rescheduleSubmitting ? '提交中...' : '确认修改' }}</view>
</view>
</view>
</u-popup>
</view>
</template>
@@ -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' })
})
},
},
}
</script>
@@ -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;
}
}
</style>

View File

@@ -95,6 +95,9 @@
</view>
</view>
<!-- AI 今日简报:每天首次进入自动弹全文(storage 按天记录),之后常驻卡片可随时查看 -->
<ai-brief-card ref="aiBrief" :store-id="store_id"></ai-brief-card>
<!-- 未来7天预约卡片:点击总数或某天打开明细抽屉 -->
<view class="appt-panel">
<view class="appt-panel__head" @click="openApptDrawer('')">
@@ -414,12 +417,13 @@
</view>
</u-popup>
<!-- 未来7天预约明细抽屉(page-container + v-if 由组件内部处理) -->
<!-- 未来7天预约明细抽屉(page-container + v-if 由组件内部处理);改期成功后静默刷新看板统计 -->
<appointment-upcoming-drawer
v-model="apptDrawerShow"
role="doctor"
:days="appointmentUpcoming.days"
:default-date="apptDefaultDate"
@rescheduled="onAppointmentRescheduled"
></appointment-upcoming-drawer>
<d-top-message ref="topMessage"></d-top-message>
@@ -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;

View File

@@ -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

View File

@@ -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"
>
<text class="fs-26 font-bold ai-hist-name">{{ row.nameText }}</text>
<text v-if="row.unread" class="ai-unread-badge fs-20">未读</text>
<text class="fs-22 color-sub">{{ row.typeText }}</text>
</view>
</scroll-view>
@@ -65,6 +67,33 @@
/>
<view v-else-if="matchLoading" class="p-32 color-sub fs-24">加载历史处方…</view>
<block v-else>
<!-- 多方案对比 Tab(模板禁止方法调用,用 multiTabView) -->
<scroll-view
v-if="multiTabView.length > 1"
scroll-x
class="ai-multi-tabs"
>
<view class="ai-multi-tabs-inner flex-row">
<view
v-for="tab in multiTabView"
:key="tab.index"
class="ai-multi-tab"
:class="{
'ai-multi-tab--active': tab.active,
'ai-multi-tab--fail': tab.failed,
'ai-multi-tab--unread': tab.unread,
}"
:data-index="tab.index"
@click="onMultiTabTap"
>
<text class="fs-22">{{ tab.label }}</text>
<text v-if="tab.unread" class="ai-unread-badge fs-18">新</text>
</view>
</view>
</scroll-view>
<view v-if="multiFailMsg" class="ai-multi-fail">
<text class="fs-22">{{ multiFailMsg }}</text>
</view>
<view v-if="!matchResult && !lastDurationMs" class="p-32 color-sub fs-24">请选择历史或生成处方</view>
<view v-else-if="!matchResult && lastDurationMs" class="p-32 color-sub fs-24">
本次生成未产出对照结果(耗时 {{ 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;

View File

@@ -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 @@
</template>
<script>
// 到店接诊列表:已从老 Yii register/list 迁移到 xk-api patient-list(status 单状态筛选 + 分页)
// 取值按 xk-api 约定 code/result(见 Api-Response.mdc)
import {
registerList,
listMain
} from "@/api/consult.js";
getPatientList
} from "@/api/reception.js";
export default {
data() {
return {
@@ -85,31 +86,6 @@
},
onHide() {
this.isHide = true;
},
watch: {
consult: {
deep: true,
immediate: false,
handler(e) {
this.list[this.current] = e.list;
this.page = 1;
this.totalPage = e.pagination.totalPage;
this.$nextTick(() => {
uni.pageScrollTo({
scrollTop: 0,
duration: 300,
});
});
if (this.page >= this.totalPage) {
this.status = "nomore";
} else {
this.status = "loadmore";
}
},
},
},
computed: {
},
onLoad(op) {
this.type = op["type"] ? Number(op["type"]) : 1;
@@ -142,19 +118,32 @@
this.loading = true;
this.getList(true);
},
/**
* 拉取当前 tab 的挂号列表(xk-api patient-list 分页版)
* 第一页替换、后续页追加(老代码把新页拼在前面会导致越翻越乱,迁移时一并修正)
*/
getList(is_stop = false) {
registerList({
type: this.type,
getPatientList({
store_id: uni.getStorageSync('store_id') || '11001',
status: this.statued,
page: this.page,
page_size: 10,
}).then((res) => {
if (res.errcode == 0) {
this.list[this.current] = [
...res.data.list,
if (res.code === 0 && res.result) {
// 行数据预处理:卡片次要信息行提前拼好(模板里不要写复杂表达式/方法调用)
const rows = (res.result.list || []).map((item) => {
const infoRows = ['推广员:' + (item.salesperson_name || '无')];
// 预约单补显预约时段,医生一眼区分「当天现场号」和「预约到期号」
if (item.appointment && item.appointment.visit_date) {
infoRows.push('预约:' + item.appointment.visit_date + ' ' + (item.appointment.slot_name || '全天'));
}
return { ...item, info_rows: infoRows };
});
this.list[this.current] = this.page === 1 ? rows : [
...this.list[this.current],
...rows,
];
this.totalPage = res.data.pagination.totalPage;
this.totalPage = res.result.page_count || 1;
if (this.page >= this.totalPage) {
this.status = "nomore";
} else {
@@ -165,7 +154,7 @@
is_stop && uni.stopPullDownRefresh();
});
} else {
this.$toast(res.msg);
this.$toast(res.message || '加载失败');
is_stop && uni.stopPullDownRefresh();
this.loading = false;
if (this.page >= this.totalPage) {
@@ -178,9 +167,6 @@
},
change(index) {
this.current = index;
this.$store.state.status = index + 1;
// this.init();
if (index == 0) {
this.statued = 1
}