i
This commit is contained in:
gr
2026-10-22 11:12:29 +08:00
480 changed files with 9840 additions and 953 deletions
+8
View File
@@ -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'
+23
View File
@@ -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 })
+18 -2
View File
@@ -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
}
}
+6 -1
View File
@@ -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)
}
+19
View File
@@ -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 || '待关联' }}
+7 -1
View File
@@ -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,
+2
View File
@@ -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>
+20 -1
View File
@@ -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 (
+2
View File
@@ -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'