@@ -5,6 +5,14 @@ export function getConfig() {
|
||||
return request.get({ url: '/config/getConfig' })
|
||||
}
|
||||
|
||||
// 运行时读取医生网页面诊切换时间,与服务端拦截规则共用配置。
|
||||
export function getDoctorWebConsultationPolicy() {
|
||||
return request.get(
|
||||
{ url: '/config/doctorWebConsultation', params: { _t: Date.now() } },
|
||||
{ withToken: false, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
// 工作台主页
|
||||
export function getWorkbench() {
|
||||
return request.get({ url: '/workbench/index' })
|
||||
|
||||
@@ -24,6 +24,24 @@ export type DesktopWorkstationConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
export type DesktopWorkstationDownload = DesktopPackage & {
|
||||
platform: 'windows_x64' | 'macos_arm64' | 'macos_x64'
|
||||
label: string
|
||||
}
|
||||
|
||||
export type DesktopWorkstationDownloads = {
|
||||
version: string
|
||||
packages: DesktopWorkstationDownload[]
|
||||
}
|
||||
|
||||
// 仅返回已发布的安装包,不要求医生拥有系统设置权限。
|
||||
export function getDesktopWorkstationDownloads() {
|
||||
return request.get(
|
||||
{ url: '/setting.desktop_workstation/downloads' },
|
||||
{ withToken: false }
|
||||
) as Promise<DesktopWorkstationDownloads>
|
||||
}
|
||||
|
||||
export function getDesktopWorkstationConfig() {
|
||||
return request.get({
|
||||
url: '/setting.desktop_workstation/getConfig'
|
||||
|
||||
@@ -227,6 +227,29 @@ export function getCallRecords(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/getCallRecords', params })
|
||||
}
|
||||
|
||||
export interface ConsultationCallRecord {
|
||||
id: number
|
||||
diagnosis_id: number
|
||||
call_type?: number
|
||||
status?: number
|
||||
start_time?: number
|
||||
start_time_text?: string
|
||||
end_time_text?: string
|
||||
duration_text?: string
|
||||
transcript_text?: string | null
|
||||
transcription_status?: string
|
||||
transcription_status_text?: string
|
||||
transcription_segment_count?: number
|
||||
}
|
||||
|
||||
/** 按诊单读取每次面诊已保存的文字;不触发语音识别或转写写入。 */
|
||||
export function getConsultationTranscripts(params: { diagnosis_id: number }, signal?: AbortSignal) {
|
||||
return request.get<ConsultationCallRecord[]>(
|
||||
{ url: '/tcm.diagnosis/getCallRecords', params, signal },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
// 将 TRTC 房间号写入通话记录(与云端录制回调 room 关联)
|
||||
export function bindCallRoom(params: { diagnosis_id: number; room_id: string }) {
|
||||
return request.post({ url: '/tcm.diagnosis/bindCallRoom', params })
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<DoctorWebConsultationNotice v-if="!isMinimized" />
|
||||
<div v-show="!isMinimized" class="chat-container">
|
||||
<div v-if="!isReady" class="loading-container">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
@@ -131,6 +132,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
|
||||
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
|
||||
import { Loading, Close, Rank, Camera, Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
|
||||
import { addDoctorNote } from '@/api/patient'
|
||||
@@ -195,8 +198,9 @@ const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointm
|
||||
const serverAllowsVideo = ref(false)
|
||||
const serverAllowsAudio = ref(false)
|
||||
const callDisabledReason = ref('正在确认挂号类型')
|
||||
const canVideoCall = computed(() => serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
|
||||
const canAudioCall = computed(() => serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
|
||||
const { webConsultationBlocked, webConsultationMessage, ensureWebConsultationAllowed } = useDoctorWebConsultation()
|
||||
const canVideoCall = computed(() => !webConsultationBlocked.value && serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
|
||||
const canAudioCall = computed(() => !webConsultationBlocked.value && serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
|
||||
let releaseAppointmentCallGuard: (() => void) | undefined
|
||||
let chatContextVersion = 0
|
||||
let outgoingCallType = 2
|
||||
@@ -253,6 +257,7 @@ function syncAppointmentCallPolicy(res: {
|
||||
}
|
||||
|
||||
async function prepareAppointmentCall(callType: number) {
|
||||
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
|
||||
const contextVersion = chatContextVersion
|
||||
const allowed = () => callType === 1 ? canAudioCall.value : canVideoCall.value
|
||||
if (!visible.value || !allowed()) {
|
||||
@@ -266,6 +271,7 @@ async function prepareAppointmentCall(callType: number) {
|
||||
})
|
||||
if (!visible.value || chatContextVersion !== contextVersion) throw new Error('当前问诊已切换,请重新发起通话')
|
||||
syncAppointmentCallPolicy(res)
|
||||
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
|
||||
if (!allowed()) {
|
||||
feedback.msgWarning(callDisabledReason.value)
|
||||
throw new Error(callDisabledReason.value)
|
||||
@@ -713,6 +719,14 @@ function installTUICallKitRoomHooks() {
|
||||
wrap('calls')
|
||||
wrap('call')
|
||||
wrap('groupCall')
|
||||
// 已登录 SDK 的旧聊天窗口也不能在截止后接起新面诊。
|
||||
const originalAccept = server.accept
|
||||
if (typeof originalAccept === 'function') {
|
||||
server.accept = async function patchedAccept(...args: unknown[]) {
|
||||
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
|
||||
return originalAccept.apply(server, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function doBindCallRoom(rid: string) {
|
||||
@@ -1099,6 +1113,7 @@ watch(() => activeConversation.value, async (newConversation) => {
|
||||
})
|
||||
|
||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentId?: number; appointmentType?: string | null }) => {
|
||||
if (!ensureWebConsultationAllowed()) return
|
||||
stopChatArchiveWatch(true)
|
||||
const contextVersion = ++chatContextVersion
|
||||
releaseAppointmentCallGuard?.()
|
||||
@@ -1260,6 +1275,7 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
|
||||
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
|
||||
const startGroupVideoCall = async () => {
|
||||
if (!ensureWebConsultationAllowed()) return
|
||||
if (!canVideoCall.value) {
|
||||
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<el-alert
|
||||
class="doctor-web-consultation-notice"
|
||||
:type="consultationReminder.type"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="consultationReminder.title"
|
||||
>
|
||||
<p>{{ consultationReminder.description }}</p>
|
||||
<p v-if="policyLoadFailed">
|
||||
面诊切换时间更新失败,请重新获取最新设置。
|
||||
<el-button link type="primary" @click="refreshConsultationPolicy()">刷新切换时间</el-button>
|
||||
</p>
|
||||
<p v-if="consultationReminder.countdown" class="notice-countdown">
|
||||
距网页端面诊停用还有 {{ consultationReminder.countdown }}(北京时间)
|
||||
</p>
|
||||
<div class="notice-downloads">
|
||||
<a
|
||||
v-for="item in downloads.packages"
|
||||
:key="item.platform"
|
||||
class="installer-link"
|
||||
:href="item.url"
|
||||
:download="item.filename || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
下载 {{ item.label }}安装包
|
||||
</a>
|
||||
<span v-if="downloadsLoading && !downloads.packages.length">正在获取安装包…</span>
|
||||
<template v-else-if="!downloads.packages.length">
|
||||
<span>{{ downloadsFailed ? '安装包加载失败,请重试。' : '安装包暂未发布,请联系管理员获取。' }}</span>
|
||||
<el-button link type="primary" @click="loadDownloads(true)">重新获取</el-button>
|
||||
</template>
|
||||
<el-button v-else-if="downloadsFailed" link type="primary" @click="loadDownloads(true)">刷新安装包</el-button>
|
||||
</div>
|
||||
<p v-if="downloads.packages.length" class="install-instructions">
|
||||
<span v-if="downloads.version">当前版本 v{{ downloads.version }} · </span>
|
||||
安装完成后,使用当前医生账号登录桌面应用。
|
||||
</p>
|
||||
<p v-if="hasWindowsInstaller" class="install-instructions">
|
||||
Windows 安装包适用于 Windows 10 1809 及以上版本、Windows 11(64位)。
|
||||
</p>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
|
||||
import { useDesktopWorkstationDownloads } from '@/hooks/useDesktopWorkstationDownloads'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const { consultationReminder, policyLoadFailed, refreshConsultationPolicy } = useDoctorWebConsultation()
|
||||
const { downloads, downloadsLoading, downloadsFailed, loadDownloads } = useDesktopWorkstationDownloads()
|
||||
const hasWindowsInstaller = computed(() => downloads.value.packages.some(item => item.platform === 'windows_x64'))
|
||||
watch(() => consultationReminder.value.phase, () => { void loadDownloads(true) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doctor-web-consultation-notice {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.doctor-web-consultation-notice p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.notice-countdown {
|
||||
font-weight: 600;
|
||||
}
|
||||
.notice-downloads {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.installer-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
text-decoration: none;
|
||||
}
|
||||
.installer-link:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.installer-link:focus-visible {
|
||||
outline: 2px solid #1d4ed8;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.doctor-web-consultation-notice .install-instructions {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { getConsultationTranscripts, type ConsultationCallRecord } from '@/api/tcm'
|
||||
import { normalizeConsultationRecords } from '@/utils/consultation-transcripts'
|
||||
|
||||
export function useConsultationTranscripts(diagnosisId: () => number) {
|
||||
const records = ref<ConsultationCallRecord[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
let sequence = 0
|
||||
let controller: AbortController | undefined
|
||||
|
||||
const refresh = async () => {
|
||||
const id = Number(diagnosisId())
|
||||
const request = ++sequence
|
||||
controller?.abort()
|
||||
error.value = ''
|
||||
if (!Number.isSafeInteger(id) || id <= 0) {
|
||||
records.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
controller = new AbortController()
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getConsultationTranscripts({ diagnosis_id: id }, controller.signal)
|
||||
if (request !== sequence || Number(diagnosisId()) !== id) return
|
||||
records.value = normalizeConsultationRecords(result, id)
|
||||
} catch (reason: any) {
|
||||
if (request !== sequence || Number(diagnosisId()) !== id) return
|
||||
error.value = String(reason?.msg || reason?.message || '面诊文字对话加载失败,请重试')
|
||||
} finally {
|
||||
if (request === sequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(diagnosisId, () => {
|
||||
records.value = []
|
||||
void refresh()
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sequence++
|
||||
controller?.abort()
|
||||
records.value = []
|
||||
})
|
||||
return { records, loading, error, refresh }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import {
|
||||
getDesktopWorkstationDownloads,
|
||||
type DesktopWorkstationDownload,
|
||||
type DesktopWorkstationDownloads
|
||||
} from '@/api/setting/desktop_workstation'
|
||||
|
||||
// 接诊台、列表和聊天窗口共用一个请求与短缓存。
|
||||
const downloads = shallowRef<DesktopWorkstationDownloads>({ version: '', packages: [] })
|
||||
const downloadsLoading = ref(false)
|
||||
const downloadsFailed = ref(false)
|
||||
let pending: Promise<void> | undefined
|
||||
let loadedAt = 0
|
||||
const CACHE_MS = 5 * 60 * 1000
|
||||
|
||||
export function normalizeDesktopDownloads(value: DesktopWorkstationDownloads): DesktopWorkstationDownloads {
|
||||
const platforms = ['windows_x64', 'macos_arm64', 'macos_x64']
|
||||
const packages: DesktopWorkstationDownload[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of Array.isArray(value?.packages) ? value.packages : []) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
if (!platforms.includes(item.platform) || seen.has(item.platform)) continue
|
||||
try {
|
||||
const url = new URL(item.url)
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) continue
|
||||
if (!/^[a-f\d]{64}$/i.test(item.sha256)) continue
|
||||
packages.push({ ...item, url: url.href })
|
||||
seen.add(item.platform)
|
||||
} catch { /* 配置异常时不生成无效下载链接。 */ }
|
||||
}
|
||||
return { version: String(value?.version || ''), packages }
|
||||
}
|
||||
|
||||
export function useDesktopWorkstationDownloads() {
|
||||
const loadDownloads = (force = false): Promise<void> => {
|
||||
if (pending) return pending
|
||||
if (!force && loadedAt > 0 && Date.now() - loadedAt < CACHE_MS) return Promise.resolve()
|
||||
downloadsLoading.value = true
|
||||
downloadsFailed.value = false
|
||||
pending = getDesktopWorkstationDownloads().then(data => {
|
||||
downloads.value = normalizeDesktopDownloads(data)
|
||||
loadedAt = Date.now()
|
||||
}).catch(() => {
|
||||
downloadsFailed.value = true
|
||||
}).finally(() => {
|
||||
downloadsLoading.value = false
|
||||
pending = undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
const refreshOnFocus = () => { void loadDownloads() }
|
||||
onMounted(() => {
|
||||
void loadDownloads()
|
||||
window.addEventListener('focus', refreshOnFocus)
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('focus', refreshOnFocus))
|
||||
|
||||
return { downloads, downloadsLoading, downloadsFailed, loadDownloads }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { getDoctorWebConsultationPolicy } from '@/api/app'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
doctorWebConsultationMessage,
|
||||
doctorWebConsultationReminder,
|
||||
hasDoctorWebConsultationExpired,
|
||||
isConsultationDoctor,
|
||||
isDoctorWebConsultationBlocked,
|
||||
normalizeDoctorWebConsultationPolicy
|
||||
} from '@/utils/doctor-web-consultation'
|
||||
|
||||
// 多个列表、公告及聊天窗口共用请求;配置更新无需重新构建前端。
|
||||
let pending: Promise<void> | undefined
|
||||
let lastAttemptAt = 0
|
||||
const REFRESH_MS = 60 * 1000
|
||||
const policyLoadFailed = ref(false)
|
||||
|
||||
export function useDoctorWebConsultation() {
|
||||
const userStore = useUserStore()
|
||||
const now = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const updateClock = () => { now.value = Date.now() + (userStore.serverTimeOffsetMs || 0) }
|
||||
const policy = computed(() => normalizeDoctorWebConsultationPolicy(userStore.userInfo.web_consultation_policy))
|
||||
|
||||
const loadPolicy = (force = false): Promise<void> => {
|
||||
if (pending) return pending
|
||||
if (!force && lastAttemptAt > 0 && Date.now() - lastAttemptAt < REFRESH_MS) return Promise.resolve()
|
||||
lastAttemptAt = Date.now()
|
||||
pending = getDoctorWebConsultationPolicy().then(data => {
|
||||
const next = normalizeDoctorWebConsultationPolicy(data)
|
||||
if (!next) throw new Error('网页面诊时间配置无效')
|
||||
userStore.userInfo.web_consultation_policy = next
|
||||
userStore.serverTimeOffsetMs = next.server_time * 1000 - Date.now()
|
||||
policyLoadFailed.value = false
|
||||
updateClock()
|
||||
}).catch(() => {
|
||||
// 保留最近一次有效配置,后端仍逐次校验实际截止时间。
|
||||
policyLoadFailed.value = true
|
||||
}).finally(() => { pending = undefined })
|
||||
return pending
|
||||
}
|
||||
const refreshConsultationPolicy = () => loadPolicy(true)
|
||||
const refreshOnFocus = () => {
|
||||
updateClock()
|
||||
void refreshConsultationPolicy()
|
||||
}
|
||||
const refreshOnVisible = () => {
|
||||
if (document.visibilityState !== 'hidden') refreshOnFocus()
|
||||
}
|
||||
|
||||
// 登录响应和配置刷新响应均提供服务器时刻,避免本机时区/时钟影响规则。
|
||||
watch(() => userStore.serverTimeOffsetMs, updateClock, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
void refreshConsultationPolicy()
|
||||
timer = setInterval(() => {
|
||||
updateClock()
|
||||
void loadPolicy()
|
||||
}, 1000)
|
||||
window.addEventListener('focus', refreshOnFocus)
|
||||
document.addEventListener('visibilitychange', refreshOnVisible)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('focus', refreshOnFocus)
|
||||
document.removeEventListener('visibilitychange', refreshOnVisible)
|
||||
})
|
||||
|
||||
const isDoctor = computed(() => isConsultationDoctor(userStore.userInfo))
|
||||
const webConsultationExpired = computed(() => hasDoctorWebConsultationExpired(policy.value, now.value))
|
||||
const consultationReminder = computed(() => doctorWebConsultationReminder(policy.value, now.value))
|
||||
const webConsultationMessage = computed(() => doctorWebConsultationMessage(policy.value))
|
||||
const webConsultationBlocked = computed(() => isDoctorWebConsultationBlocked(userStore.userInfo, policy.value, now.value))
|
||||
const ensureWebConsultationAllowed = () => {
|
||||
updateClock()
|
||||
if (!webConsultationBlocked.value) return true
|
||||
feedback.msgWarning(webConsultationMessage.value)
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
isDoctor, consultationReminder, webConsultationExpired, webConsultationBlocked,
|
||||
webConsultationMessage, ensureWebConsultationAllowed, policyLoadFailed, refreshConsultationPolicy
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface UserState {
|
||||
routes: RouteRecordRaw[]
|
||||
perms: string[]
|
||||
isPaw: number // 0=需要修改密码,1=正常
|
||||
serverTimeOffsetMs: number
|
||||
}
|
||||
|
||||
const useUserStore = defineStore({
|
||||
@@ -27,7 +28,8 @@ const useUserStore = defineStore({
|
||||
// 权限
|
||||
perms: [],
|
||||
// 是否需要修改密码
|
||||
isPaw: 1
|
||||
isPaw: 1,
|
||||
serverTimeOffsetMs: 0
|
||||
}),
|
||||
getters: {},
|
||||
actions: {
|
||||
@@ -36,6 +38,7 @@ const useUserStore = defineStore({
|
||||
this.userInfo = {}
|
||||
this.perms = []
|
||||
this.isPaw = 1
|
||||
this.serverTimeOffsetMs = 0
|
||||
},
|
||||
login(playload: any) {
|
||||
const { account, password } = playload
|
||||
@@ -95,6 +98,8 @@ const useUserStore = defineStore({
|
||||
getUserInfo()
|
||||
.then((data) => {
|
||||
this.userInfo = data.user
|
||||
const serverTime = Number(data.user?.web_consultation_policy?.server_time)
|
||||
this.serverTimeOffsetMs = serverTime > 0 ? serverTime * 1000 - Date.now() : 0
|
||||
this.perms = data.permissions
|
||||
this.routes = filterAsyncRoutes(data.menu)
|
||||
// 更新 isPaw 状态
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ConsultationCallRecord } from '@/api/tcm'
|
||||
|
||||
export function normalizeConsultationRecords(value: unknown, diagnosisId: number): ConsultationCallRecord[] {
|
||||
if (!Array.isArray(value)) throw new Error('面诊记录返回格式异常,请重试')
|
||||
const seen = new Set<number>()
|
||||
return value.filter(row => {
|
||||
if (!row || !Number.isSafeInteger(Number(row.id)) || Number(row.id) <= 0 || seen.has(Number(row.id))) return false
|
||||
if (row.diagnosis_id != null && Number(row.diagnosis_id) !== diagnosisId) return false
|
||||
seen.add(Number(row.id))
|
||||
return true
|
||||
}).map(row => ({
|
||||
...row,
|
||||
id: Number(row.id),
|
||||
diagnosis_id: diagnosisId,
|
||||
transcript_text: typeof row.transcript_text === 'string' ? row.transcript_text.replace(/\r\n/g, '\n').trim() : ''
|
||||
})).sort((a, b) => b.id - a.id)
|
||||
}
|
||||
|
||||
export function consultationTranscriptText(row?: ConsultationCallRecord | null): string {
|
||||
return typeof row?.transcript_text === 'string' ? row.transcript_text.trim() : ''
|
||||
}
|
||||
|
||||
export function consultationTranscriptStatus(row: ConsultationCallRecord) {
|
||||
switch (row.transcription_status) {
|
||||
case 'running': return { label: '录音转写中', type: 'info' as const }
|
||||
case 'completed': return { label: '文字已生成', type: 'success' as const }
|
||||
case 'partial': return { label: '文字部分保存', type: 'warning' as const }
|
||||
case 'failed': return { label: '文字生成失败', type: 'danger' as const }
|
||||
default: return consultationTranscriptText(row)
|
||||
? { label: '文字已保存', type: 'success' as const }
|
||||
: { label: '未生成文字', type: 'info' as const }
|
||||
}
|
||||
}
|
||||
|
||||
export function consultationTranscriptEmptyText(row: ConsultationCallRecord): string {
|
||||
if (row.transcription_status === 'running') return '正在转写,可点击刷新查看最新保存的文字。'
|
||||
if (row.transcription_status === 'failed') return '本次面诊文字生成失败,暂无已保存的对话。'
|
||||
if (Number(row.status) === 3) return '本次通话未接听,暂无文字对话。'
|
||||
if (Number(row.status) === 4) return '本次通话已取消,暂无文字对话。'
|
||||
return '本次面诊暂无已保存的文字对话。'
|
||||
}
|
||||
|
||||
export function consultationCallLabel(row: ConsultationCallRecord): string {
|
||||
return Number(row.call_type) === 1 ? '语音面诊' : Number(row.call_type) === 2 ? '视频面诊' : '面诊记录'
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 所有时间由服务端配置提供,浏览器不保留独立的截止日期。
|
||||
export type DoctorWebConsultationPolicy = {
|
||||
cutoff_at: string
|
||||
approaching_at: string
|
||||
urgent_at: string
|
||||
server_time: number
|
||||
}
|
||||
|
||||
export type DoctorWebConsultationPhase = 'prepare' | 'approaching' | 'last_day' | 'expired'
|
||||
|
||||
export function normalizeDoctorWebConsultationPolicy(value: unknown): DoctorWebConsultationPolicy | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const policy = value as DoctorWebConsultationPolicy
|
||||
const timestamps = [policy.approaching_at, policy.urgent_at, policy.cutoff_at].map(date =>
|
||||
typeof date === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(date) ? Date.parse(date) : NaN
|
||||
)
|
||||
if (timestamps.some(time => !Number.isFinite(time)) || timestamps[0] > timestamps[1] || timestamps[1] > timestamps[2]) return undefined
|
||||
if (!Number.isFinite(policy.server_time) || policy.server_time <= 0) return undefined
|
||||
return { cutoff_at: policy.cutoff_at, approaching_at: policy.approaching_at, urgent_at: policy.urgent_at, server_time: policy.server_time }
|
||||
}
|
||||
|
||||
export function doctorWebConsultationDate(policy: DoctorWebConsultationPolicy): string {
|
||||
// 无论医生电脑所在时区为何,公告始终显示北京时间。
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
|
||||
}).formatToParts(new Date(policy.cutoff_at))
|
||||
const part = (type: string) => parts.find(item => item.type === type)?.value || ''
|
||||
const seconds = part('second') === '00' ? '' : `:${part('second')}`
|
||||
return `${part('year')}年${part('month')}月${part('day')}日${part('hour')}:${part('minute')}${seconds}(北京时间)`
|
||||
}
|
||||
|
||||
export function doctorWebConsultationMessage(policy?: DoctorWebConsultationPolicy): string {
|
||||
return `网页端面诊已${policy ? `于${doctorWebConsultationDate(policy)}` : ''}停用,请医生使用桌面应用进行面诊。`
|
||||
}
|
||||
|
||||
export function doctorWebConsultationReminder(policy?: DoctorWebConsultationPolicy, now = Date.now()) {
|
||||
if (!policy) return {
|
||||
phase: 'prepare' as DoctorWebConsultationPhase,
|
||||
type: 'info' as const,
|
||||
title: '系统公告:医生桌面应用安装指引',
|
||||
description: '请各位医生提前下载并安装桌面应用,使用当前医生账号登录。',
|
||||
countdown: ''
|
||||
}
|
||||
const cutoffDate = doctorWebConsultationDate(policy)
|
||||
const remaining = Date.parse(policy.cutoff_at) - now
|
||||
const days = Math.floor(remaining / 86400000)
|
||||
const hours = Math.floor(remaining / 3600000) % 24
|
||||
const minutes = Math.floor(remaining / 60000) % 60
|
||||
const countdown = remaining <= 0 ? '' : remaining < 60000 ? '不足1分钟' :
|
||||
`${days > 0 ? `${days}天` : ''}${hours > 0 ? `${hours}小时` : ''}${minutes > 0 ? `${minutes}分钟` : ''}`
|
||||
|
||||
if (remaining <= 0) return {
|
||||
phase: 'expired' as DoctorWebConsultationPhase,
|
||||
type: 'error' as const,
|
||||
title: '系统公告:医生网页端面诊已停用',
|
||||
description: doctorWebConsultationMessage(policy),
|
||||
countdown: ''
|
||||
}
|
||||
if (now >= Date.parse(policy.urgent_at)) return {
|
||||
phase: 'last_day' as DoctorWebConsultationPhase,
|
||||
type: 'error' as const,
|
||||
title: '系统公告:医生网页端面诊停用最后提醒',
|
||||
description: `请各位医生在${cutoffDate}前完成桌面应用的安装与登录。届时接诊台、问诊列表的网页端面诊功能将同步停用,请使用桌面应用进行面诊。`,
|
||||
countdown
|
||||
}
|
||||
if (now >= Date.parse(policy.approaching_at)) return {
|
||||
phase: 'approaching' as DoctorWebConsultationPhase,
|
||||
type: 'warning' as const,
|
||||
title: '系统公告:医生网页端面诊即将停用',
|
||||
description: `网页端面诊将于${cutoffDate}停用。请各位医生尽快下载桌面应用,完成安装并使用当前医生账号登录,接诊台、问诊列表将同步执行此规则。`,
|
||||
countdown
|
||||
}
|
||||
return {
|
||||
phase: 'prepare' as DoctorWebConsultationPhase,
|
||||
type: 'info' as const,
|
||||
title: '系统公告:请提前安装医生桌面应用',
|
||||
description: `请各位医生提前下载并安装桌面应用,使用当前医生账号登录试用。自${cutoffDate}起,接诊台、问诊列表的网页端面诊功能将停用,请提前做好准备。`,
|
||||
countdown: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function isConsultationDoctor(user: Record<string, any>): boolean {
|
||||
return Number(user.root) !== 1 && Array.isArray(user.role_ids) && user.role_ids.some(role => Number(role) === 1)
|
||||
}
|
||||
|
||||
export function hasDoctorWebConsultationExpired(policy?: DoctorWebConsultationPolicy, now = Date.now()): boolean {
|
||||
return !!policy && now >= Date.parse(policy.cutoff_at)
|
||||
}
|
||||
|
||||
export function isDoctorWebConsultationBlocked(
|
||||
user: Record<string, any>,
|
||||
policy?: DoctorWebConsultationPolicy,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
return isConsultationDoctor(user) && hasDoctorWebConsultationExpired(policy, now)
|
||||
}
|
||||
@@ -144,6 +144,17 @@
|
||||
>
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column label="订单号" prop="order_no" min-width="150" />
|
||||
|
||||
<el-table-column label="处方业务订单号" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.prescription_orders?.length" class="flex flex-col gap-1">
|
||||
<span v-for="order in row.prescription_orders" :key="order.id" class="break-all">
|
||||
{{ order.order_no }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未关联</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="患者信息" min-width="150">
|
||||
<template #default="{ row }">
|
||||
@@ -302,6 +313,14 @@
|
||||
<el-tag :type="getStatusTag(detailData.status)">
|
||||
{{ getStatusText(detailData.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方业务订单号" :span="2">
|
||||
<div v-if="detailData.prescription_orders?.length" class="flex flex-col gap-1">
|
||||
<span v-for="order in detailData.prescription_orders" :key="order.id" class="break-all">
|
||||
{{ order.order_no }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未关联</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="患者姓名">
|
||||
{{ detailData.patient?.patient_name || '待关联' }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="reception-page">
|
||||
<DoctorWebConsultationNotice />
|
||||
<!-- 页头 -->
|
||||
<div class="reception-header">
|
||||
<div class="header-left">
|
||||
@@ -84,7 +85,7 @@
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="发起通话" placement="top">
|
||||
<button class="row-icon-btn call" @click="handleCall(row)">
|
||||
<button class="row-icon-btn call" :disabled="webConsultationBlocked" @click="handleCall(row)">
|
||||
<el-icon><Phone /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
@@ -189,6 +190,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
|
||||
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
|
||||
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
|
||||
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
|
||||
import NoteTimeline from './components/NoteTimeline.vue'
|
||||
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
|
||||
@@ -211,6 +214,7 @@ const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/e
|
||||
|
||||
const POLL_MS = 5_000
|
||||
const PAGE_SIZE = 15
|
||||
const { webConsultationBlocked, ensureWebConsultationAllowed } = useDoctorWebConsultation()
|
||||
|
||||
interface QueueRow {
|
||||
id: number
|
||||
@@ -438,6 +442,7 @@ const handleSearch = async () => {
|
||||
}
|
||||
|
||||
const handleCall = async (row: QueueRow) => {
|
||||
if (!ensureWebConsultationAllowed()) return
|
||||
const sourcePatientId = Number(row.source_patient_id || 0)
|
||||
const diagnosisId = Number(row.diagnosis_id || 0)
|
||||
if (!sourcePatientId) {
|
||||
@@ -454,6 +459,7 @@ const handleCall = async (row: QueueRow) => {
|
||||
appointment_id: Number(row.id),
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
if (!ensureWebConsultationAllowed()) return
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="appointment-list">
|
||||
<DoctorWebConsultationNotice />
|
||||
<el-card class="list-card !border-none" shadow="never">
|
||||
<!-- 单行工具栏:Tab(主) + 搜索(辅) -->
|
||||
<div class="list-toolbar">
|
||||
@@ -559,6 +560,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="apt-h5">
|
||||
<DoctorWebConsultationNotice />
|
||||
<template v-if="currentView === 'list'">
|
||||
<header class="apt-h5-topbar">
|
||||
<div class="apt-h5-search">
|
||||
@@ -412,6 +413,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmAppointmentH5">
|
||||
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
|
||||
import { getWeappConfig } from '@/api/channel/weapp'
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<section class="consultation-transcripts">
|
||||
<header class="transcript-toolbar">
|
||||
<div>
|
||||
<h3>面诊文字对话</h3>
|
||||
<p>按每次通话分别查看已保存的文字记录。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="refresh"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon class="transcript-error" />
|
||||
<div v-loading="loading" class="transcript-body">
|
||||
<template v-if="records.length">
|
||||
<aside class="transcript-sessions" aria-label="历次面诊记录">
|
||||
<el-input v-model="search" clearable placeholder="搜索面诊时间或对话内容" :prefix-icon="Search" />
|
||||
<p class="session-count">{{ filteredRecords.length }} 条面诊记录</p>
|
||||
<div class="session-list">
|
||||
<button
|
||||
v-for="row in pageRecords"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="session-item"
|
||||
:class="{ selected: selectedId === row.id }"
|
||||
:aria-pressed="selectedId === row.id"
|
||||
@click="selectedId = row.id"
|
||||
>
|
||||
<span class="session-time">{{ row.start_time_text || '开始时间未记录' }}</span>
|
||||
<span class="session-meta">{{ consultationCallLabel(row) }} · 记录 #{{ row.id }}</span>
|
||||
<el-tag size="small" :type="consultationTranscriptStatus(row).type">{{ consultationTranscriptStatus(row).label }}</el-tag>
|
||||
<span class="session-preview">{{ preview(row) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-if="filteredRecords.length > PAGE_SIZE"
|
||||
v-model:current-page="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="filteredRecords.length"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<article v-if="selectedRecord" class="transcript-detail" :key="`${diagnosisId}-${selectedRecord.id}`">
|
||||
<header class="transcript-detail-header">
|
||||
<div>
|
||||
<h4>{{ selectedRecord.start_time_text || '开始时间未记录' }} · {{ consultationCallLabel(selectedRecord) }}</h4>
|
||||
<p>
|
||||
通话记录 #{{ selectedRecord.id }}
|
||||
<span v-if="selectedRecord.duration_text"> · 时长 {{ selectedRecord.duration_text }}</span>
|
||||
<span v-if="selectedRecord.end_time_text"> · 结束 {{ selectedRecord.end_time_text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-button v-if="selectedText" :loading="copying" @click="copyTranscript">复制全文</el-button>
|
||||
</header>
|
||||
<div class="transcript-status">
|
||||
<el-tag :type="consultationTranscriptStatus(selectedRecord).type">{{ consultationTranscriptStatus(selectedRecord).label }}</el-tag>
|
||||
<span>{{ callStatusText(selectedRecord.status) }}</span>
|
||||
<span v-if="selectedRecord.transcription_segment_count">{{ selectedRecord.transcription_segment_count }} 段对话</span>
|
||||
</div>
|
||||
<p v-if="selectedRecord.transcription_status === 'partial'" class="transcript-note">本次文字记录不完整,以下展示已保存的内容。</p>
|
||||
<p v-else-if="selectedRecord.transcription_status === 'failed' && selectedText" class="transcript-note">本次转写未完成,以下展示已保存的内容。</p>
|
||||
<p v-else-if="selectedRecord.transcription_status === 'running' && selectedText" class="transcript-note">转写仍在进行,可刷新查看最新保存的文字。</p>
|
||||
<pre v-if="selectedText" class="transcript-text">{{ selectedText }}</pre>
|
||||
<el-empty v-else :description="consultationTranscriptEmptyText(selectedRecord)" :image-size="72" />
|
||||
</article>
|
||||
<el-empty v-else description="没有匹配的面诊记录,请调整搜索内容" class="transcript-detail" :image-size="72" />
|
||||
</template>
|
||||
<el-empty v-else-if="!loading && !error" description="该诊单暂无面诊通话记录" :image-size="80" class="transcript-empty" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type { ConsultationCallRecord } from '@/api/tcm'
|
||||
import { useConsultationTranscripts } from '@/hooks/useConsultationTranscripts'
|
||||
import {
|
||||
consultationCallLabel,
|
||||
consultationTranscriptEmptyText,
|
||||
consultationTranscriptStatus,
|
||||
consultationTranscriptText
|
||||
} from '@/utils/consultation-transcripts'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const props = defineProps<{ diagnosisId: number }>()
|
||||
const { records, loading, error, refresh } = useConsultationTranscripts(() => props.diagnosisId)
|
||||
const search = ref('')
|
||||
const selectedId = ref<number | null>(null)
|
||||
const page = ref(1)
|
||||
const copying = ref(false)
|
||||
const PAGE_SIZE = 10
|
||||
const filteredRecords = computed(() => {
|
||||
const term = search.value.trim().toLocaleLowerCase()
|
||||
return records.value.filter(row => row.diagnosis_id === props.diagnosisId &&
|
||||
(!term || `${row.start_time_text || ''} ${row.id} ${consultationTranscriptText(row)}`.toLocaleLowerCase().includes(term)))
|
||||
})
|
||||
const pageRecords = computed(() => filteredRecords.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
|
||||
const selectedRecord = computed(() => filteredRecords.value.find(row => row.id === selectedId.value))
|
||||
const selectedText = computed(() => consultationTranscriptText(selectedRecord.value))
|
||||
watch(filteredRecords, rows => {
|
||||
if (!rows.some(row => row.id === selectedId.value)) {
|
||||
const selected = rows.find(row => consultationTranscriptText(row)) || rows[0]
|
||||
selectedId.value = selected?.id ?? null
|
||||
page.value = selected ? Math.floor(rows.indexOf(selected) / PAGE_SIZE) + 1 : 1
|
||||
}
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(rows.length / PAGE_SIZE)))
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
watch(search, () => { page.value = 1 }, { flush: 'sync' })
|
||||
|
||||
function preview(row: ConsultationCallRecord): string {
|
||||
const text = consultationTranscriptText(row).replace(/\s+/g, ' ')
|
||||
return text ? text.slice(0, 90) + (text.length > 90 ? '…' : '') : consultationTranscriptEmptyText(row)
|
||||
}
|
||||
function callStatusText(value?: number): string {
|
||||
return ({ 1: '进行中', 2: '已结束', 3: '未接听', 4: '已取消' } as Record<number, string>)[Number(value)] || '状态未记录'
|
||||
}
|
||||
async function copyTranscript() {
|
||||
const text = selectedText.value
|
||||
if (!text || copying.value) return
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
feedback.msgWarning('当前浏览器不支持自动复制,请选中文字复制')
|
||||
return
|
||||
}
|
||||
copying.value = true
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
feedback.msgSuccess('已复制本次面诊文字对话')
|
||||
} catch {
|
||||
feedback.msgWarning('复制失败,请选中文字复制')
|
||||
} finally {
|
||||
copying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.consultation-transcripts { padding: 4px 0 16px; }
|
||||
.transcript-toolbar, .transcript-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.transcript-toolbar { margin-bottom: 20px; }
|
||||
.transcript-toolbar h3, .transcript-detail h4 { margin: 0; color: var(--el-text-color-primary); font-weight: 600; }
|
||||
.transcript-toolbar h3 { font-size: 18px; }
|
||||
.transcript-toolbar p, .transcript-detail-header p { margin: 7px 0 0; color: var(--el-text-color-secondary); line-height: 1.7; font-size: 13px; }
|
||||
.transcript-error { margin-bottom: 16px; }
|
||||
.transcript-body { display: flex; min-height: 360px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.transcript-sessions { flex: 0 0 282px; min-width: 0; padding: 18px 18px 0 0; border-right: 1px solid var(--el-border-color-lighter); }
|
||||
.session-count { margin: 12px 0 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.session-list { display: flex; flex-direction: column; gap: 8px; max-height: 580px; overflow-y: auto; margin-bottom: 12px; }
|
||||
.session-item { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; padding: 13px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 7px; background: var(--el-bg-color); text-align: left; cursor: pointer; font: inherit; }
|
||||
.session-item:hover, .session-item.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.session-item:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
|
||||
.session-time { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
|
||||
.session-meta, .session-preview { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
|
||||
.session-preview { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
||||
.transcript-detail { flex: 1; min-width: 0; padding: 20px 0 0 24px; }
|
||||
.transcript-detail h4 { font-size: 16px; line-height: 1.7; }
|
||||
.transcript-status { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 16px 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.transcript-note { padding: 10px 12px; background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: 13px; line-height: 1.7; }
|
||||
.transcript-text { margin: 0; padding: 20px 0; color: var(--el-text-color-primary); white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 15px; line-height: 2; user-select: text; }
|
||||
.transcript-empty { flex: 1; }
|
||||
@media (max-width: 900px) {
|
||||
.transcript-body { flex-direction: column; }
|
||||
.transcript-sessions { flex-basis: auto; padding: 16px 0; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.session-list { max-height: 300px; }
|
||||
.transcript-detail { padding: 20px 0 0; }
|
||||
.transcript-detail-header { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
@@ -744,6 +744,20 @@
|
||||
<el-empty v-else description="请先保存诊单后再查看视频录制回放记录" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-if="hasCallTranscriptPermission"
|
||||
label="面诊文字对话"
|
||||
name="callTranscript"
|
||||
:disabled="!formData.id"
|
||||
lazy
|
||||
>
|
||||
<consultation-transcript-panel
|
||||
v-if="visible && activeTab === 'callTranscript' && formData.id"
|
||||
:key="Number(formData.id)"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
/>
|
||||
<el-empty v-else-if="!formData.id" description="请先保存诊单后再查看面诊文字对话" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-if="hasPermission(['tcm.diagnosis/chat'])"
|
||||
label="聊天记录"
|
||||
name="chat"
|
||||
@@ -827,6 +841,7 @@ import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
|
||||
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
|
||||
import CaseRecordList from './components/CaseRecordList.vue'
|
||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||
import ConsultationTranscriptPanel from './components/ConsultationTranscriptPanel.vue'
|
||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||
import AssignLogPanel from './components/AssignLogPanel.vue'
|
||||
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
|
||||
@@ -862,6 +877,7 @@ const submitting = ref(false)
|
||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||
const hasCallTranscriptPermission = computed(() => hasPermission(['tcm.diagnosis/huifang']) || hasPermission(['tcm.diagnosis/getCallRecords']))
|
||||
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
|
||||
const patientBasicLocked = ref(false)
|
||||
const canEditPatientBasicFromApi = ref(true)
|
||||
@@ -930,12 +946,15 @@ watch(() => activeTab.value, async (tab) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([visible, activeTab], () => {
|
||||
watch([visible, activeTab, hasCallTranscriptPermission], () => {
|
||||
if (!visible.value) return
|
||||
if (activeTab.value === 'chat' && !hasPermission(['tcm.diagnosis/chat'])) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
if (activeTab.value === 'visit' && !hasPermission(['tcm.diagnosis/huifang'])) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
if (activeTab.value === 'callTranscript' && !hasCallTranscriptPermission.value) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="tcm-diagnosis">
|
||||
<DoctorWebConsultationNotice />
|
||||
<el-card class="filter-card !border-none" shadow="never">
|
||||
<!-- 第一行:日期 + 患者搜索 -->
|
||||
<div class="filter-main">
|
||||
@@ -767,6 +768,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmDiagnosis">
|
||||
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
|
||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
|
||||
import { orderCreate } from '@/api/order'
|
||||
import { cancelAppointment } from '@/api/doctor'
|
||||
|
||||
Reference in New Issue
Block a user