diff --git a/admin/src/api/app.ts b/admin/src/api/app.ts index fec3423be..a2a103a46 100755 --- a/admin/src/api/app.ts +++ b/admin/src/api/app.ts @@ -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' }) diff --git a/admin/src/api/setting/desktop_workstation.ts b/admin/src/api/setting/desktop_workstation.ts index 5077a7bde..b129a8a28 100755 --- a/admin/src/api/setting/desktop_workstation.ts +++ b/admin/src/api/setting/desktop_workstation.ts @@ -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 +} + export function getDesktopWorkstationConfig() { return request.get({ url: '/setting.desktop_workstation/getConfig' diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index 5a70075eb..5a5e35668 100755 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -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( + { 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 }) diff --git a/admin/src/components/chat-dialog/index.vue b/admin/src/components/chat-dialog/index.vue index 3ba575ff0..92a815e49 100755 --- a/admin/src/components/chat-dialog/index.vue +++ b/admin/src/components/chat-dialog/index.vue @@ -34,6 +34,7 @@ +
@@ -131,6 +132,8 @@ + + diff --git a/admin/src/hooks/useConsultationTranscripts.ts b/admin/src/hooks/useConsultationTranscripts.ts new file mode 100644 index 000000000..451972ea9 --- /dev/null +++ b/admin/src/hooks/useConsultationTranscripts.ts @@ -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([]) + 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 } +} diff --git a/admin/src/hooks/useDesktopWorkstationDownloads.ts b/admin/src/hooks/useDesktopWorkstationDownloads.ts new file mode 100644 index 000000000..9b84339fb --- /dev/null +++ b/admin/src/hooks/useDesktopWorkstationDownloads.ts @@ -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({ version: '', packages: [] }) +const downloadsLoading = ref(false) +const downloadsFailed = ref(false) +let pending: Promise | 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() + 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 => { + 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 } +} diff --git a/admin/src/hooks/useDoctorWebConsultation.ts b/admin/src/hooks/useDoctorWebConsultation.ts new file mode 100644 index 000000000..8fcc984bd --- /dev/null +++ b/admin/src/hooks/useDoctorWebConsultation.ts @@ -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 | 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 | undefined + const updateClock = () => { now.value = Date.now() + (userStore.serverTimeOffsetMs || 0) } + const policy = computed(() => normalizeDoctorWebConsultationPolicy(userStore.userInfo.web_consultation_policy)) + + const loadPolicy = (force = false): Promise => { + 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 + } +} diff --git a/admin/src/stores/modules/user.ts b/admin/src/stores/modules/user.ts index 9ba1da04d..7636c6700 100755 --- a/admin/src/stores/modules/user.ts +++ b/admin/src/stores/modules/user.ts @@ -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 状态 diff --git a/admin/src/utils/consultation-transcripts.ts b/admin/src/utils/consultation-transcripts.ts new file mode 100644 index 000000000..624622925 --- /dev/null +++ b/admin/src/utils/consultation-transcripts.ts @@ -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() + 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 ? '视频面诊' : '面诊记录' +} diff --git a/admin/src/utils/doctor-web-consultation.ts b/admin/src/utils/doctor-web-consultation.ts new file mode 100644 index 000000000..8b2cdd6f4 --- /dev/null +++ b/admin/src/utils/doctor-web-consultation.ts @@ -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): 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, + policy?: DoctorWebConsultationPolicy, + now = Date.now() +): boolean { + return isConsultationDoctor(user) && hasDoctorWebConsultationExpired(policy, now) +} diff --git a/admin/src/views/order/index.vue b/admin/src/views/order/index.vue index babfce450..e32de3abe 100755 --- a/admin/src/views/order/index.vue +++ b/admin/src/views/order/index.vue @@ -144,6 +144,17 @@ > + + + +