This commit is contained in:
Your Name
2026-03-23 18:02:16 +08:00
parent 18fee2e8f8
commit 250d173c2f
525 changed files with 10659 additions and 793 deletions
+26
View File
@@ -1,5 +1,31 @@
import config from '@/config'
import { RequestCodeEnum } from '@/enums/requestEnums'
import useAppStore from '@/stores/modules/app'
import { getToken } from '@/utils/auth'
import request from '@/utils/request'
/** 本地上传图片(与素材库「本地上传」同一接口),返回 data 含 url 相对路径 */
export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') {
const appStore = useAppStore()
const formData = new FormData()
formData.append('file', file, filename)
formData.append('cid', '0')
const url = `${config.baseUrl}${config.urlPrefix}/upload/image`
const res = await fetch(url, {
method: 'POST',
headers: {
token: getToken() || '',
version: appStore.config.version || ''
},
body: formData
})
const json = await res.json()
if (json.code !== RequestCodeEnum.SUCCESS) {
throw new Error(json.msg || '上传失败')
}
return json.data as { id: number; uri: string; url: string }
}
export function fileCateAdd(params: Record<string, any>) {
return request.post({ url: '/file/addCate', params })
}
+53 -1
View File
@@ -109,6 +109,11 @@ export function getCallRecords(params: any) {
return request.get({ url: '/tcm.diagnosis/getCallRecords', params })
}
// 将 TRTC 房间号写入通话记录(与云端录制回调 room 关联)
export function bindCallRoom(params: { diagnosis_id: number; room_id: string }) {
return request.post({ url: '/tcm.diagnosis/bindCallRoom', params })
}
// ========== 小程序分享 ==========
// 生成小程序码
@@ -121,7 +126,12 @@ export function generateOrderQrcode(params: any) {
return request.post({ url: '/tcm.diagnosis/generateOrderQrcode', params })
}
// ========== 企业微信聊天记录 ==========
// ========== IM / 企业微信聊天记录 ==========
/** 腾讯云 IM 单聊漫游消息(诊单维度:患者 patient_* 与医生 doctor_* */
export function getImChatMessages(params: { diagnosis_id: number }) {
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
}
// 获取企业微信聊天记录
export function getWechatChatRecords(params: any) {
@@ -150,11 +160,26 @@ export function getMsgAuditPermitUsers() {
// ========== 中医处方单 ==========
// 处方列表
export function prescriptionLists(params: any) {
return request.get({ url: '/tcm.prescription/lists', params })
}
// 添加处方
export function prescriptionAdd(params: any) {
return request.post({ url: '/tcm.prescription/add', params })
}
// 编辑处方
export function prescriptionEdit(params: any) {
return request.post({ url: '/tcm.prescription/edit', params })
}
// 删除处方
export function prescriptionDelete(params: { id: number }) {
return request.post({ url: '/tcm.prescription/delete', params })
}
// 处方详情
export function prescriptionDetail(params: { id: number }) {
return request.get({ url: '/tcm.prescription/detail', params })
@@ -174,3 +199,30 @@ export function prescriptionGetByAppointment(params: { appointment_id: number })
export function prescriptionVoid(params: { id: number }) {
return request.post({ url: '/tcm.prescription/void', params })
}
// ========== 处方库 ==========
// 处方库列表
export function prescriptionLibraryLists(params: any) {
return request.get({ url: '/tcm.prescriptionLibrary/lists', params })
}
// 添加处方库
export function prescriptionLibraryAdd(params: any) {
return request.post({ url: '/tcm.prescriptionLibrary/add', params })
}
// 编辑处方库
export function prescriptionLibraryEdit(params: any) {
return request.post({ url: '/tcm.prescriptionLibrary/edit', params })
}
// 删除处方库
export function prescriptionLibraryDelete(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionLibrary/delete', params })
}
// 处方库详情
export function prescriptionLibraryDetail(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
}
+604 -10
View File
@@ -67,9 +67,31 @@
@mousedown="onCallKitDragStart"
>
<span class="drag-handle-text">视频通话</span>
<el-icon class="drag-handle-icon"><Rank /></el-icon>
<div class="call-kit-drag-handle-actions">
<el-button
type="primary"
link
class="call-kit-screenshot-btn"
:loading="captureUploading"
@mousedown.stop
@click.stop="handleCallKitScreenshot"
>
<el-icon><Camera /></el-icon>
<span class="call-kit-screenshot-text">截屏</span>
</el-button>
<el-button
type="danger"
link
class="call-kit-close-btn"
@mousedown.stop
@click.stop="handleCallKitClose"
>
<el-icon><Close /></el-icon>
</el-button>
<el-icon class="drag-handle-icon"><Rank /></el-icon>
</div>
</div>
<div class="call-kit-content">
<div ref="callKitContentRef" class="call-kit-content">
<TUICallKit
:allowedMinimized="true"
:allowedFullScreen="true"
@@ -82,10 +104,23 @@
</template>
<script setup lang="ts">
import { ref, nextTick, watch, computed } from 'vue'
import { Loading, Close, Rank } from '@element-plus/icons-vue'
import { getCallSignature } from '@/api/tcm'
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
import { Loading, Close, Rank, Camera } from '@element-plus/icons-vue'
import { uploadImageBlob } from '@/api/file'
import {
bindCallRoom,
getCallSignature,
startCall,
tcmDiagnosisDetail,
tcmDiagnosisEdit
} from '@/api/tcm'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import {
formatTUICallUserError,
getTUICallPackageArrearsMessage,
isTUICallPackageAbilityError
} from '@/utils/tuicall-error'
import useUserStore from '@/stores/modules/user'
import {
ConversationList,
@@ -103,7 +138,15 @@ import {
} from '@tencentcloud/chat-uikit-vue3'
import TencentCloudChat from '@tencentcloud/chat'
import '@/assets/chat-uikit.css'
import { TUICallKit, TUICallKitServer, TUICallType } from '@tencentcloud/call-uikit-vue'
import {
TUICallKit,
TUICallKitServer,
TUICallType,
TUIStore,
StoreName,
NAME
} from '@tencentcloud/call-uikit-vue'
import { TUICallEvent } from '@tencentcloud/call-engine-js'
const assistant_ids=ref('');
const visible = ref(false)
const isReady = ref(false)
@@ -120,6 +163,445 @@ const { login, logout } = useLoginState()
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
const userStore = useUserStore()
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
const CALL_STATUS_IDLE = 'idle'
const CALL_STATUS_CALLING = 'calling'
const CALL_STATUS_CONNECTED = 'connected'
/** 通话绑定 TRTC 房间号到诊单(云端录制回调按 room_id 关联) */
const lastBoundRoomKey = ref('')
let tearDownCallRoomBinding: (() => void) | null = null
let bindRoomRetryTimer: ReturnType<typeof setTimeout> | null = null
let bindRoomRetryAttempts = 0
const BIND_ROOM_MAX_ATTEMPTS = 40
const BIND_ROOM_RETRY_MS = 100
/** 从 TUIStore 读取房间号;默认 0 表示尚未分配 */
function readRoomIdFromStore(): string {
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
if (raw === null || raw === undefined) return ''
if (typeof raw === 'number') {
if (raw === 0) return ''
return String(raw)
}
const s = String(raw).trim()
if (!s || s === '0') return ''
return s
}
/** 引擎实例上可能存在房间号(不同版本字段名不一,仅作兜底) */
function readRoomIdFromEngineFallback(): string {
try {
const eng = TUICallKitServer.getTUICallEngineInstance?.() as Record<string, unknown> | null
if (!eng) return ''
const candidates = [eng.roomId, eng.roomID, eng._roomId, eng._roomID, eng.strRoomID]
for (const c of candidates) {
if (c === null || c === undefined || c === '') continue
const s = String(c).trim()
if (s && s !== '0') return s
}
} catch {
//
}
return ''
}
function readAnyRoomId(): string {
return readRoomIdFromStore() || readRoomIdFromEngineFallback()
}
/** 从引擎 calls/call/groupCall 的 Promise 返回值里解析房间号(含 data 嵌套) */
function normalizeRoomPair(roomID: unknown, strRoomID: unknown): string {
if (strRoomID != null && String(strRoomID).trim() !== '' && String(strRoomID) !== '0') {
return String(strRoomID).trim()
}
if (typeof roomID === 'number' && roomID > 0) return String(roomID)
if (typeof roomID === 'string') {
const t = roomID.trim()
if (t && t !== '0') return t
}
return ''
}
function extractRoomFromEngineResult(ret: unknown): string {
if (ret == null || typeof ret !== 'object') return ''
const r = ret as Record<string, unknown>
const direct =
normalizeRoomPair(r.roomID, r.strRoomID) ||
normalizeRoomPair(r.roomId, r.strRoomId)
if (direct) return direct
const d = r.data
if (d && typeof d === 'object') {
const dd = d as Record<string, unknown>
const nested =
normalizeRoomPair(dd.roomID, dd.strRoomID) ||
normalizeRoomPair(dd.roomId, dd.strRoomId)
if (nested) return nested
}
return ''
}
function clearBindRoomRetry() {
if (bindRoomRetryTimer) {
clearTimeout(bindRoomRetryTimer)
bindRoomRetryTimer = null
}
bindRoomRetryAttempts = 0
}
/** 在 calling / connected 阶段轮询房间号(ROOM_ID 可能比 status 晚一拍) */
function scheduleBindRoomRetry() {
clearBindRoomRetry()
const tick = () => {
bindRoomRetryTimer = null
const status = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
if (status === CALL_STATUS_IDLE) return
if (status !== CALL_STATUS_CALLING && status !== CALL_STATUS_CONNECTED) return
const rid = readAnyRoomId()
if (rid) {
void doBindCallRoom(rid)
return
}
bindRoomRetryAttempts++
if (bindRoomRetryAttempts >= BIND_ROOM_MAX_ATTEMPTS) {
console.warn(
'[chat-dialog] 仍未解析到有效房间号(已重试',
BIND_ROOM_MAX_ATTEMPTS,
'次)'
)
return
}
bindRoomRetryTimer = setTimeout(tick, BIND_ROOM_RETRY_MS)
}
bindRoomRetryTimer = setTimeout(tick, 0)
}
/**
* 在 TUICallKitServer.call / calls / groupCall 执行完毕后,引擎已返回 roomId,
* _updateCallStoreAfterCall 会写入 TUIStore;工具栏多走 call() 而非 calls(),必须 hook 这三者。
*/
async function flushAndBindRoomAfterTUICallApi(apiName: string) {
if (patientId.value == null || diagnosisId.value == null) return
await nextTick()
for (let i = 0; i < 40; i++) {
const rid = readAnyRoomId()
if (rid) {
await doBindCallRoom(rid)
return
}
await new Promise((r) => setTimeout(r, 50))
}
console.warn('[chat-dialog] 发起通话后仍未解析到房间号(API:', apiName, ')')
}
/**
* 在 TUICallEngine 上包装 calls/call/groupCall:房间号往往在 Promise 返回值里,
* 仅靠 TUIStore 轮询可能拿不到或晚一拍。
*/
function installEngineRoomCapture() {
const w = window as unknown as Record<string, unknown>
if (w.__zytEngineRoomCapture__) return
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
| (Record<string, unknown> & { __zytEngineRoomCapture__?: boolean })
| null
| undefined
if (!eng || typeof eng.calls !== 'function') return
w.__zytEngineRoomCapture__ = true
eng.__zytEngineRoomCapture__ = true
const wrap = (methodName: 'calls' | 'call' | 'groupCall') => {
const original = eng[methodName]
if (typeof original !== 'function') return
eng[methodName] = async function patched(this: unknown, ...args: unknown[]) {
const ret = await (original as (...a: unknown[]) => Promise<unknown>).apply(eng, args)
const fromRet = extractRoomFromEngineResult(ret)
if (fromRet) {
void doBindCallRoom(fromRet)
} else if (import.meta.env.DEV) {
console.debug('[chat-dialog] engine.' + methodName + ' 返回未含 roomID,将回退读 Store', ret)
}
await flushAndBindRoomAfterTUICallApi('engine.' + methodName)
return ret
}
}
wrap('calls')
wrap('call')
wrap('groupCall')
}
let engineRoomEventHandlers: Array<{ event: TUICallEvent; fn: (e: unknown) => void }> = []
function bindEngineRoomEvents() {
try {
type EngineWithEvents = Record<string, unknown> & {
__zytRoomEvents?: boolean
on?: (event: TUICallEvent | string, fn: (e: unknown) => void) => void
}
const eng = TUICallKitServer.getTUICallEngineInstance?.() as EngineWithEvents | null | undefined
const subscribe = eng?.on
if (!eng || typeof subscribe !== 'function' || eng.__zytRoomEvents) return
eng.__zytRoomEvents = true
const onRoomHint = (ev: unknown) => {
const fromEv = extractRoomFromEngineResult(ev)
if (fromEv) {
void doBindCallRoom(fromEv)
return
}
void tryBindRoomIfReady()
}
const events = [TUICallEvent.USER_ENTER, TUICallEvent.ON_CALL_BEGIN] as const
for (const event of events) {
subscribe.call(eng, event, onRoomHint)
engineRoomEventHandlers.push({ event, fn: onRoomHint })
}
} catch (e) {
console.warn('[chat-dialog] 绑定引擎房间事件失败', e)
}
}
function unbindEngineRoomEvents() {
try {
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
| ({ off?: (e: TUICallEvent, fn: (ev: unknown) => void) => void } & {
__zytRoomEvents?: boolean
})
| null
| undefined
if (!eng?.off) {
engineRoomEventHandlers = []
return
}
for (const { event, fn } of engineRoomEventHandlers) {
eng.off(event, fn)
}
eng.__zytRoomEvents = false
} catch {
//
}
engineRoomEventHandlers = []
}
function installTUICallKitRoomHooks() {
const w = window as unknown as Record<string, unknown>
if (w.__zytTUICallRoomHooks__) return
w.__zytTUICallRoomHooks__ = true
const server = TUICallKitServer as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>
const wrap = (methodName: 'calls' | 'call' | 'groupCall') => {
const original = server[methodName]
if (typeof original !== 'function') return
server[methodName] = async function patched(...args: unknown[]) {
const result = await original.apply(server, args)
await flushAndBindRoomAfterTUICallApi(methodName)
return result
}
}
wrap('calls')
wrap('call')
wrap('groupCall')
}
async function doBindCallRoom(rid: string) {
if (patientId.value == null || diagnosisId.value == null) return
const key = `${diagnosisId.value}:${rid}`
if (key === lastBoundRoomKey.value) {
clearBindRoomRetry()
return
}
lastBoundRoomKey.value = key
try {
await bindCallRoom({ diagnosis_id: diagnosisId.value, room_id: rid })
clearBindRoomRetry()
console.log('[chat-dialog] bindCallRoom 成功', { room_id: rid })
} catch (e) {
console.warn('[chat-dialog] bindCallRoom 失败', e)
lastBoundRoomKey.value = ''
}
}
async function tryBindRoomIfReady() {
if (patientId.value == null || diagnosisId.value == null) return
const status = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
if (status === CALL_STATUS_IDLE) {
clearBindRoomRetry()
lastBoundRoomKey.value = ''
return
}
if (status !== CALL_STATUS_CALLING && status !== CALL_STATUS_CONNECTED) return
await nextTick()
const rid = readAnyRoomId()
if (rid) {
await doBindCallRoom(rid)
return
}
scheduleBindRoomRetry()
}
async function ensureCallRecordStarted() {
if (patientId.value == null || diagnosisId.value == null) return
try {
await startCall({
diagnosis_id: diagnosisId.value,
patient_id: patientId.value,
call_type: 2
})
} catch (e) {
console.warn('[chat-dialog] startCall 记录失败', e)
}
}
function setupCallRoomBinding() {
tearDownCallRoomBinding?.()
const onCallStatus = (newData?: string) => {
if (newData === CALL_STATUS_IDLE) {
clearBindRoomRetry()
lastBoundRoomKey.value = ''
return
}
if (newData === CALL_STATUS_CONNECTED) {
void tryBindRoomIfReady()
}
}
const onRoomId = () => {
void tryBindRoomIfReady()
}
TUIStore.watch(StoreName.CALL, {
[NAME.CALL_STATUS]: onCallStatus,
[NAME.ROOM_ID]: onRoomId
})
tearDownCallRoomBinding = () => {
try {
TUIStore.unwatch(StoreName.CALL, {
[NAME.CALL_STATUS]: onCallStatus,
[NAME.ROOM_ID]: onRoomId
})
} catch {
//
}
clearBindRoomRetry()
tearDownCallRoomBinding = null
}
}
/** 防止 console + 引擎事件 与 unhandledrejection 连续弹多次 */
let lastPackageToastAt = 0
const PACKAGE_TOAST_DEBOUNCE_MS = 2600
function notifyPackageArrearsOnce() {
const now = Date.now()
if (now - lastPackageToastAt < PACKAGE_TOAST_DEBOUNCE_MS) return
lastPackageToastAt = now
feedback.msgError(getTUICallPackageArrearsMessage())
showCallKitWindow.value = false
}
/**
* SDK 在 calls 失败时往往只 console.error(见 CallService),Chat UIKit 工具栏又会吞掉 Promise
* 故需:1) 挂钩 console 识别套餐文案;2) 监听 TUICallEngine 的 error 事件。
*/
let savedConsoleError: typeof console.error | null = null
let consoleErrorHooked = false
let engineErrorHandler: ((e: unknown) => void) | null = null
function hookConsoleErrorForPackageHint() {
if (consoleErrorHooked) return
savedConsoleError = console.error.bind(console)
consoleErrorHooked = true
console.error = (...args: unknown[]) => {
savedConsoleError!(...args)
if (!visible.value || !isCallReady.value) return
const text = args
.map((a) => {
if (typeof a === 'string') return a
if (a instanceof Error) return a.message
try {
return JSON.stringify(a)
} catch {
return String(a)
}
})
.join(' ')
if (isTUICallPackageAbilityError(text)) notifyPackageArrearsOnce()
}
}
function unhookConsoleErrorForPackageHint() {
if (consoleErrorHooked && savedConsoleError) {
console.error = savedConsoleError
}
consoleErrorHooked = false
savedConsoleError = null
}
function bindEnginePackageErrorListener() {
try {
const engine = TUICallKitServer.getTUICallEngineInstance?.()
if (!engine?.on) return
if (engineErrorHandler) {
engine.off?.('error', engineErrorHandler)
}
engineErrorHandler = (payload: unknown) => {
if (!visible.value || !isCallReady.value) return
if (isTUICallPackageAbilityError(payload)) notifyPackageArrearsOnce()
}
engine.on('error', engineErrorHandler)
} catch (e) {
console.warn('绑定 TUICallEngine error 监听失败:', e)
}
}
function unbindEnginePackageErrorListener() {
try {
const engine = TUICallKitServer.getTUICallEngineInstance?.()
if (engine?.off && engineErrorHandler) {
engine.off('error', engineErrorHandler)
}
} catch {
//
}
engineErrorHandler = null
}
/** 医生端进入会话后发给患者的信令:患者端不展示气泡,仅用于更新「已进入诊室」状态 */
async function sendDoctorEnteredConsultRoomSignal(sdkAppId: number, toUserId: string) {
try {
const chat = TencentCloudChat.create({ SDKAppID: sdkAppId })
const payload = {
businessID: 'doctor_entered_consult_room',
time: Date.now()
}
const message = chat.createCustomMessage({
to: toUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: {
data: JSON.stringify(payload)
}
})
await chat.sendMessage(message)
console.log('已发送医生进入诊室信令(患者端聊天列表不展示)')
} catch (e) {
console.warn('发送医生进入诊室信令失败:', e)
}
}
/** 未捕获 Promise(若存在) */
const onTuiCallUnhandledRejection = (e: PromiseRejectionEvent) => {
if (!visible.value || !isCallReady.value) return
if (isTUICallPackageAbilityError(e.reason)) notifyPackageArrearsOnce()
}
onMounted(() => {
hookConsoleErrorForPackageHint()
window.addEventListener('unhandledrejection', onTuiCallUnhandledRejection)
})
onUnmounted(() => {
tearDownCallRoomBinding?.()
unbindEngineRoomEvents()
unhookConsoleErrorForPackageHint()
unbindEnginePackageErrorListener()
window.removeEventListener('unhandledrejection', onTuiCallUnhandledRejection)
})
// 更新用户头像到 IM
const updateUserAvatarToIM = async (avatarUrl: string,appid: number) => {
try {
@@ -244,15 +726,28 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
;(window as any).__TUICallKitInited__ = true
} catch (err: any) {
console.warn('TUICallKit 初始化失败,聊天仍可用,音视频通话不可用:', err?.message || err)
if (isTUICallPackageAbilityError(err)) {
notifyPackageArrearsOnce()
}
}
}
isCallReady.value = !!(window as any).__TUICallKitInited__
if (isCallReady.value) {
// 仅在通话时显示视频窗口,无通话时隐藏
// 仅在通话时显示视频窗口,无通话时隐藏;发起通话前落库、接通后绑定房间号(云端录制)
TUICallKitServer.setCallback({
beforeCalling: () => { showCallKitWindow.value = true },
afterCalling: () => { showCallKitWindow.value = false }
beforeCalling: () => {
showCallKitWindow.value = true
void ensureCallRecordStarted()
},
afterCalling: () => {
showCallKitWindow.value = false
}
})
installTUICallKitRoomHooks()
installEngineRoomCapture()
bindEngineRoomEvents()
setupCallRoomBinding()
bindEnginePackageErrorListener()
}
console.log('Chat UIKit 登录成功,患者用户ID:', patientUserId.value)
@@ -285,6 +780,8 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 激活该会话
setActiveConversation(conversationId.value)
console.log('已激活会话')
await sendDoctorEnteredConsultRoomSignal(sdkAPPid, patientUserId.value)
} catch (err) {
console.error('创建/激活会话失败:', err)
}
@@ -338,7 +835,11 @@ const startGroupVideoCall = async () => {
})
} catch (err: any) {
console.error('发起群视频失败:', err)
feedback.msgError(err?.message || '发起群视频失败')
if (isTUICallPackageAbilityError(err)) {
notifyPackageArrearsOnce()
} else {
feedback.msgError(formatTUICallUserError(err, '发起群视频失败'))
}
}
}
@@ -354,6 +855,8 @@ const posStartY = ref(0)
// TUICallKit 视频窗口拖动
const callKitWrapperRef = ref<HTMLElement>()
const callKitContentRef = ref<HTMLElement>()
const captureUploading = ref(false)
const callKitX = ref(0)
const callKitY = ref(0)
const isCallKitDragging = ref(false)
@@ -389,6 +892,67 @@ const onCallKitDragEnd = () => {
document.removeEventListener('mouseup', onCallKitDragEnd)
}
/** 截取当前视频画面并上传,同步到对应患者诊单的「舌苔照片」 */
const handleCallKitScreenshot = async () => {
if (patientId.value == null) {
feedback.msgWarning('缺少患者信息,无法同步舌苔照片')
return
}
const el = callKitContentRef.value
if (!el) return
captureUploading.value = true
try {
const blob = await captureVideoFrameFromElement(el)
if (!blob) {
feedback.msgWarning('截屏失败,请确认视频画面已加载')
return
}
const data = await uploadImageBlob(blob, `callshot-${Date.now()}.jpg`)
const path = data.url || data.uri
if (!path) {
feedback.msgError('上传未返回图片路径')
return
}
if (diagnosisId.value == null) {
feedback.msgWarning('请先保存诊单后再从视频同步舌苔照片')
return
}
const detail = await tcmDiagnosisDetail({ id: diagnosisId.value })
if (Number(detail.patient_id) !== Number(patientId.value)) {
feedback.msgError('患者与当前诊单不匹配,无法同步')
return
}
const previous: string[] = [...((detail.tongue_images || []) as string[])]
if (previous.length >= 9) {
feedback.msgWarning('舌苔照片已达上限 9 张')
return
}
const next = [...previous, path]
const payload = JSON.parse(JSON.stringify(detail)) as Record<string, unknown>
payload.tongue_images = next
await tcmDiagnosisEdit(payload)
feedback.msgSuccess('舌苔照片已同步到诊单')
} catch (e: unknown) {
const err = e as Error
console.error(err)
feedback.msgError(err?.message || '上传失败')
} finally {
captureUploading.value = false
}
}
/** 关闭视频浮窗并挂断当前通话 */
const handleCallKitClose = async () => {
if (isCallReady.value) {
try {
await TUICallKitServer.hangup()
} catch (err) {
console.warn('关闭视频窗口时挂断失败:', err)
}
}
showCallKitWindow.value = false
}
const windowStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`
@@ -580,6 +1144,36 @@ defineExpose({ open })
color: #fff;
}
.call-kit-drag-handle-actions {
display: flex;
align-items: center;
gap: 4px;
}
.call-kit-screenshot-btn {
padding: 4px 6px;
color: #79bbff;
&:hover {
color: #a0cfff;
}
}
.call-kit-screenshot-text {
margin-left: 2px;
font-size: 12px;
}
.call-kit-close-btn {
cursor: pointer;
padding: 4px;
color: #f56c6c;
&:hover {
color: #f89898;
}
}
.drag-handle-icon {
color: #999;
font-size: 16px;
+305 -22
View File
@@ -2,7 +2,7 @@
<el-drawer
v-model="visible"
title="中医处方单"
size="900px"
size="1200px"
:close-on-click-modal="false"
@close="handleClose"
>
@@ -52,6 +52,16 @@
<el-input v-model="formData.tongue" placeholder="舌象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="舌象详情" prop="tongue_image">
<el-input v-model="formData.tongue_image" placeholder="舌象详情" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象详情" prop="pulse_condition">
<el-input v-model="formData.pulse_condition" placeholder="脉象详情" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="临床诊断" prop="clinical_diagnosis">
<el-input
@@ -69,6 +79,7 @@
<div class="section-title">
中药处方 (RP)
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
</div>
<div class="mb-3">
<el-form-item label="处方价格" prop="amount" class="!mb-0">
@@ -109,32 +120,113 @@
<div class="info-section">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="处方类型" prop="prescription_type">
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="!w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数" prop="dose_count">
<el-form-item label="服用天数" prop="usage_days">
<el-input-number v-model="formData.usage_days" :min="1" :max="365" class="!w-full" />
</el-form-item>
</el-col>
<!-- <el-col :span="8">
<el-form-item label="剂数" prop="dose_count">
<el-input-number v-model="formData.dose_count" :min="1" :max="999" class="!w-full" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="类型" prop="dose_unit">
<el-select v-model="formData.dose_unit" placeholder="请选择类型" class="!w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-form-item label="剂量单位" prop="dose_unit">
<el-select v-model="formData.dose_unit" placeholder="请选择" class="!w-full">
<el-option label="" value="" />
<el-option label="" value="" />
<el-option label="袋" value="袋" />
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="膏" value="膏" />
<el-option label="贴" value="贴" />
</el-select>
</el-form-item>
</el-col> -->
<el-col :span="24">
<el-form-item label="用法" prop="usage_instruction">
<el-input v-model="formData.usage_instruction" placeholder="例如:水煎服,一日二次" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服用时间" prop="usage_time">
<el-select v-model="formData.usage_time" placeholder="请选择服用时间" class="!w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-option label="饭中" value="饭中" />
<el-option label="空腹" value="空腹" />
<el-option label="睡前" value="睡前" />
<el-option label="晨起" value="晨起" />
<el-option label="随时" value="随时" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用说明" prop="usage_instruction">
<el-select v-model="formData.usage_instruction" placeholder="请选择服用说明" class="!w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-col :span="12">
<el-form-item label="服用方式" prop="usage_way">
<el-select v-model="formData.usage_way" placeholder="请选择服用方式" class="!w-full">
<el-option label="温水送服" value="温水送服" />
<el-option label="开水冲服" value="开水冲服" />
<el-option label="黄酒送服" value="黄酒送服" />
<el-option label="淡盐水送服" value="淡盐水送服" />
<el-option label="米汤送服" value="米汤送服" />
<el-option label="嚼服" value="嚼服" />
<el-option label="含化" value="含化" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="忌口" prop="dietary_taboo">
<el-select v-model="formData.dietary_taboo" multiple placeholder="请选择忌口内容(可多选)" class="!w-full">
<el-option label="辛辣食物" value="辛辣食物" />
<el-option label="生冷食物" value="生冷食物" />
<el-option label="油腻食物" value="油腻食物" />
<el-option label="海鲜" value="海鲜" />
<el-option label="牛羊肉" value="牛羊肉" />
<el-option label="鸡蛋" value="鸡蛋" />
<el-option label="豆制品" value="豆制品" />
<el-option label="酒类" value="酒类" />
<el-option label="浓茶" value="浓茶" />
<el-option label="咖啡" value="咖啡" />
<el-option label="烟草" value="烟草" />
<el-option label="萝卜" value="萝卜" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="其他说明" prop="usage_notes">
<el-input v-model="formData.usage_notes" type="textarea" :rows="2" placeholder="其他服用注意事项" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="医师" prop="doctor_name">
<el-input v-model="formData.doctor_name" placeholder="医师姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否共享" prop="is_shared">
<el-switch
v-model="formData.is_shared"
:active-value="1"
:inactive-value="0"
active-text="所有人可见"
inactive-text="仅自己可见"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="医师签名" prop="doctor_signature" required>
<div class="signature-pad-wrap">
@@ -219,7 +311,20 @@
<div class="prescription-footer">
<div class="footer-left">
<div class="footer-dosage">
服用{{ savedPrescription.dose_count }}, {{ savedPrescription.dose_unit || '浓缩水丸' }}, {{ savedPrescription.usage_instruction }}
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}, {{ savedPrescription.prescription_type }}
{{ savedPrescription.usage_instruction }}
</div>
<div class="footer-dosage">
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
<text style="margin-left: 20px;"></text>服用方式: {{ savedPrescription.usage_way ? ' ' + savedPrescription.usage_way : '' }}
</div>
<div v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length" class="footer-dietary">
忌口{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('') : savedPrescription.dietary_taboo }}
</div>
<div v-if="savedPrescription.usage_notes" class="footer-notes">
{{ savedPrescription.usage_notes }}
</div>
<!-- <div class="footer-amount">金额 {{ savedPrescription.amount }}</div> -->
</div>
@@ -374,17 +479,83 @@
</div>
</template>
</el-drawer>
<!-- 处方库选择弹窗 -->
<el-dialog
v-model="showLibraryDialog"
title="从处方库导入"
width="800px"
:close-on-click-modal="false"
>
<div class="mb-4">
<el-input
v-model="librarySearchName"
placeholder="请输入处方名称搜索"
clearable
@keyup.enter="searchLibrary"
@clear="searchLibrary"
>
<template #append>
<el-button :icon="Search" @click="searchLibrary" />
</template>
</el-input>
</div>
<el-table
v-loading="libraryLoading"
:data="libraryList"
height="400"
@row-click="handleSelectLibrary"
style="cursor: pointer"
>
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
<el-table-column label="药材数量" width="100">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="药材明细" min-width="300" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.herbs && row.herbs.length > 0">
{{ row.herbs.map((h: any) => `${h.name} ${h.dosage}g`).join('、') }}
</span>
<span v-else class="text-gray-400">暂无药材</span>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="100" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click.stop="handleImportLibrary(row)">
导入
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="libraryPage"
v-model:page-size="libraryPageSize"
:total="libraryTotal"
:page-sizes="[10, 15, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadLibraryList"
@size-change="loadLibraryList"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import feedback from '@/utils/feedback'
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail } from '@/api/tcm'
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
import { getDictData } from '@/api/app'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { Download, Document } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
interface Herb {
name: string
@@ -394,6 +565,8 @@ interface Herb {
interface PrescriptionForm {
diagnosis_id: number
appointment_id: number
prescription_name: string
prescription_type: string
patient_name: string
gender: number
gender_desc: string
@@ -403,15 +576,23 @@ interface PrescriptionForm {
prescription_date: string
pulse: string
tongue: string
tongue_image: string
pulse_condition: string
clinical_diagnosis: string
case_record: any
herbs: Herb[]
dose_count: number
dose_unit: string
usage_days: number
usage_instruction: string
usage_time: string
usage_way: string
dietary_taboo: string[]
usage_notes: string
amount: number
doctor_name: string
doctor_signature: string
is_shared: number
}
const emit = defineEmits(['success'])
@@ -426,6 +607,15 @@ const signatureCanvasRef = ref<HTMLCanvasElement>()
const isDrawing = ref(false)
const savedPrescription = ref<any>(null)
// 处方库相关
const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
const libraryList = ref<any[]>([])
const librarySearchName = ref('')
const libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
const templateConfig = reactive({
stationName: '成都双流甄养堂互联网医院',
showDisclaimer: true,
@@ -435,6 +625,8 @@ const templateConfig = reactive({
const formData = reactive<PrescriptionForm>({
diagnosis_id: 0,
appointment_id: 0,
prescription_name: '',
prescription_type: '浓缩水丸',
patient_name: '',
gender: 0,
gender_desc: '女',
@@ -444,15 +636,23 @@ const formData = reactive<PrescriptionForm>({
prescription_date: '',
pulse: '',
tongue: '',
tongue_image: '',
pulse_condition: '',
clinical_diagnosis: '',
case_record: null as any,
herbs: [],
dose_count: 1,
dose_unit: '浓缩水丸',
usage_instruction: '饭前',
dose_unit: '',
usage_days: 7,
usage_instruction: '水煎服,一日二次',
usage_time: '饭前',
usage_way: '温水送服',
dietary_taboo: [],
usage_notes: '',
amount: 0,
doctor_name: '',
doctor_signature: ''
doctor_signature: '',
is_shared: 0
})
const userStore = useUserStore()
@@ -599,6 +799,8 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.diagnosis_id = Number(diagnosisId)
formData.appointment_id = appointmentId
formData.prescription_name = ''
formData.prescription_type = '浓缩水丸'
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
formData.gender_desc = formData.gender === 1 ? '男' : '女'
@@ -608,15 +810,23 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.prescription_date = new Date().toISOString().slice(0, 10)
formData.pulse = diagnosis.pulse || ''
formData.tongue = diagnosis.tongue_coating || diagnosis.tongue || ''
formData.tongue_image = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis)
formData.case_record = caseRecord
formData.herbs = []
formData.dose_count = 1
formData.dose_unit = '浓缩水丸'
formData.usage_instruction = '饭前'
formData.dose_unit = ''
formData.usage_days = 7
formData.usage_instruction = '水煎服,一日二次'
formData.usage_time = '饭前'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.amount = 0
formData.doctor_name = userStore.userInfo?.name || ''
formData.doctor_signature = ''
formData.is_shared = 0
savedPrescription.value = null
visible.value = true
@@ -721,6 +931,59 @@ const handleRemoveHerb = (index: number) => {
formData.herbs.splice(index, 1)
}
// 处方库相关方法
const loadLibraryList = async () => {
libraryLoading.value = true
try {
const res = await prescriptionLibraryLists({
page_no: libraryPage.value,
page_size: libraryPageSize.value,
prescription_name: librarySearchName.value,
is_public: ''
})
libraryList.value = res.lists || []
libraryTotal.value = res.count || 0
} catch (error) {
console.error('加载处方库失败:', error)
feedback.msgError('加载处方库失败')
} finally {
libraryLoading.value = false
}
}
const searchLibrary = () => {
libraryPage.value = 1
loadLibraryList()
}
const handleSelectLibrary = (row: any) => {
// 点击行也可以导入
handleImportLibrary(row)
}
const handleImportLibrary = (row: any) => {
if (!row.herbs || row.herbs.length === 0) {
feedback.msgWarning('该处方没有药材信息')
return
}
// 深拷贝药材数据并导入
const importedHerbs = JSON.parse(JSON.stringify(row.herbs))
formData.herbs = importedHerbs
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${importedHerbs.length}味药材`)
showLibraryDialog.value = false
}
// 监听处方库弹窗打开,自动加载数据
watch(showLibraryDialog, (newVal) => {
if (newVal) {
librarySearchName.value = ''
libraryPage.value = 1
loadLibraryList()
}
})
const handleSave = async () => {
if (!formData.clinical_diagnosis?.trim()) {
feedback.msgWarning('请输入临床诊断')
@@ -751,6 +1014,8 @@ const handleSave = async () => {
const res = await prescriptionAdd({
diagnosis_id: formData.diagnosis_id,
appointment_id: formData.appointment_id,
prescription_name: formData.prescription_name,
prescription_type: formData.prescription_type,
patient_name: formData.patient_name,
gender: formData.gender,
age: formData.age,
@@ -759,15 +1024,23 @@ const handleSave = async () => {
prescription_date: formData.prescription_date,
pulse: formData.pulse,
tongue: formData.tongue,
tongue_image: formData.tongue_image,
pulse_condition: formData.pulse_condition,
clinical_diagnosis: formData.clinical_diagnosis,
case_record: formData.case_record,
herbs: formData.herbs,
dose_count: formData.dose_count,
dose_unit: formData.dose_unit || '浓缩水丸',
dose_unit: formData.dose_unit,
usage_days: formData.usage_days,
usage_instruction: formData.usage_instruction,
usage_time: formData.usage_time,
usage_way: formData.usage_way,
dietary_taboo: formData.dietary_taboo,
usage_notes: formData.usage_notes,
amount: formData.amount,
doctor_name: formData.doctor_name,
doctor_signature: formData.doctor_signature
doctor_signature: formData.doctor_signature,
is_shared: formData.is_shared
})
const id = res?.id
if (id) {
@@ -825,6 +1098,8 @@ const handleNewPrescription = async () => {
// 将患者信息、诊断信息填入表单,新建处方时保留
formData.diagnosis_id = saved.diagnosis_id ?? formData.diagnosis_id
formData.appointment_id = saved.appointment_id ?? formData.appointment_id
formData.prescription_name = ''
formData.prescription_type = saved.prescription_type || '浓缩水丸'
formData.patient_name = saved.patient_name || ''
formData.gender = saved.gender ?? 0
formData.gender_desc = saved.gender === 1 ? '男' : '女'
@@ -834,13 +1109,21 @@ const handleNewPrescription = async () => {
formData.prescription_date = new Date().toISOString().slice(0, 10)
formData.pulse = saved.pulse || ''
formData.tongue = saved.tongue || ''
formData.tongue_image = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = saved.clinical_diagnosis || ''
formData.dose_count = 1
formData.dose_unit = saved.dose_unit || '浓缩水丸'
formData.usage_instruction = '饭前'
formData.dose_unit = saved.dose_unit || ''
formData.usage_days = 7
formData.usage_instruction = '水煎服,一日二次'
formData.usage_time = '饭前'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.amount = 0
formData.doctor_name = userStore.userInfo?.name || saved.doctor_name || ''
formData.doctor_signature = ''
formData.is_shared = 0
formData.herbs = []
// 新建处方时重新获取病历(旧处方可能没有 case_record
formData.case_record = saved.case_record && Object.keys(saved.case_record).length > 0
+2 -1
View File
@@ -95,6 +95,7 @@ import { ref, onUnmounted, nextTick, computed } from 'vue'
import { VideoCamera, CircleClose, Phone } from '@element-plus/icons-vue'
import { getCallSignature, endCall } from '@/api/tcm'
import feedback from '@/utils/feedback'
import { formatTUICallUserError } from '@/utils/tuicall-error'
import { TUICallKitAPI, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
interface CallInfo {
@@ -646,7 +647,7 @@ const startCall = async () => {
} catch (error: any) {
console.error('启动通话失败:', error)
const errorMessage = error.message || '启动通话失败,请稍后重试'
const errorMessage = formatTUICallUserError(error, '启动通话失败,请稍后重试')
feedback.msgError(errorMessage)
callRejected.value = true
statusText.value = errorMessage
+31 -18
View File
@@ -2,28 +2,41 @@
* perm 操作权限处理
* 指令用法:
* <el-button v-perms="['auth.menu/edit']">编辑</el-button>
*
* 注意:禁止对 Vue 管理的节点使用 parentNode.removeChild,否则会破坏 vnode 与真实 DOM 的对应关系,
* Vue 3 内部 WeakMap 缓存会报错:Invalid value used as weak map key。
*/
import type { DirectiveBinding } from 'vue'
import useUserStore from '@/stores/modules/user'
function applyPerms(el: HTMLElement, binding: DirectiveBinding<string[]>) {
const { value } = binding
const userStore = useUserStore()
const permissions = userStore.perms || []
const all_permission = '*'
if (!Array.isArray(value)) {
throw new Error('like v-perms="[\'auth.menu/edit\']"')
}
if (value.length === 0) {
el.style.removeProperty('display')
return
}
const hasPermission = permissions.some((key: string) => {
return all_permission == key || value.includes(key)
})
if (!hasPermission) {
el.style.display = 'none'
} else {
el.style.removeProperty('display')
}
}
export default {
mounted: (el: HTMLElement, binding: any) => {
const { value } = binding
const userStore = useUserStore()
const permissions = userStore.perms
const all_permission = '*'
if (Array.isArray(value)) {
if (value.length > 0) {
const hasPermission = permissions.some((key: string) => {
return all_permission == key || value.includes(key)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
} else {
throw new Error('like v-perms="[\'auth.menu/edit\']"')
}
mounted: (el: HTMLElement, binding: DirectiveBinding<string[]>) => {
applyPerms(el, binding)
},
updated: (el: HTMLElement, binding: DirectiveBinding<string[]>) => {
applyPerms(el, binding)
}
}
-13
View File
@@ -2,10 +2,6 @@
* 权限控制
*/
import 'nprogress/nprogress.css'
import NProgress from 'nprogress'
import config from './config'
import { PageEnum } from './enums/pageEnum'
import router, { findFirstValidRoute } from './router'
@@ -51,16 +47,11 @@ const addRoutesRecursively = (routes: any, parentPath = '') => {
}
}
// NProgress配置
NProgress.configure({ showSpinner: false })
const loginPath = PageEnum.LOGIN
const defaultPath = PageEnum.INDEX
// 免登录白名单
const whiteList: string[] = [PageEnum.LOGIN, PageEnum.ERROR_403]
router.beforeEach(async (to, from, next) => {
// 开始 Progress Bar
NProgress.start()
document.title = to.meta.title ?? config.title
const userStore = useUserStore()
const tabsStore = useTabsStore()
@@ -105,7 +96,3 @@ router.beforeEach(async (to, from, next) => {
next({ path: loginPath, query: { redirect: to.fullPath } })
}
})
router.afterEach(() => {
NProgress.done()
})
+35
View File
@@ -0,0 +1,35 @@
/** 从视频通话区域截取当前最大的一路 video 帧(WebRTC 画面) */
export async function captureVideoFrameFromElement(
container: HTMLElement
): Promise<Blob | null> {
const videos = Array.from(container.querySelectorAll('video')) as HTMLVideoElement[]
let best: HTMLVideoElement | null = null
let bestArea = 0
for (const v of videos) {
if (v.readyState < 2) continue
const vw = v.videoWidth || v.clientWidth
const vh = v.videoHeight || v.clientHeight
const area = vw * vh
if (area > bestArea) {
bestArea = area
best = v
}
}
if (!best) return null
const vw = best.videoWidth || best.clientWidth
const vh = best.videoHeight || best.clientHeight
if (!vw || !vh) return null
const canvas = document.createElement('canvas')
canvas.width = vw
canvas.height = vh
const ctx = canvas.getContext('2d')
if (!ctx) return null
try {
ctx.drawImage(best, 0, 0, vw, vh)
} catch {
return null
}
return new Promise((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.92)
})
}
-5
View File
@@ -1,6 +1,5 @@
import { AxiosError, type AxiosRequestConfig } from 'axios'
import { merge } from 'lodash'
import NProgress from 'nprogress'
import configs from '@/config'
import { PageEnum } from '@/enums/pageEnum'
@@ -15,7 +14,6 @@ import type { AxiosHooks } from './type'
// 处理axios的钩子函数
const axiosHooks: AxiosHooks = {
requestInterceptorsHook(config) {
NProgress.start()
const { withToken, isParamsToData } = config.requestOptions
const params = config.params || {}
const headers = config.headers || {}
@@ -38,11 +36,9 @@ const axiosHooks: AxiosHooks = {
return config
},
requestInterceptorsCatchHook(err) {
NProgress.done()
return err
},
async responseInterceptorsHook(response) {
NProgress.done()
const { isTransformResponse, isReturnDefaultResponse } = response.config.requestOptions
//返回默认响应,当需要获取响应头及其他数据时可使用
@@ -80,7 +76,6 @@ const axiosHooks: AxiosHooks = {
}
},
responseInterceptorsCatchHook(error) {
NProgress.done()
if (error.code !== AxiosError.ERR_CANCELED) {
error.message && feedback.msgError(error.message)
}
+56
View File
@@ -0,0 +1,56 @@
function collectErrorText(err: unknown): string {
const parts: string[] = []
let cur: unknown = err
let depth = 0
while (cur != null && depth < 6) {
if (typeof cur === 'string') {
parts.push(cur)
break
}
if (typeof cur === 'object') {
const o = cur as Record<string, unknown>
if (typeof o.message === 'string') parts.push(o.message)
if (typeof o.name === 'string') parts.push(o.name)
if (typeof o.code !== 'undefined') parts.push(String(o.code))
cur = o.cause
} else {
parts.push(String(cur))
break
}
depth++
}
return parts.join(' ').toLowerCase()
}
/** 腾讯云通话套餐/能力类错误常见码(如 wasm 日志 error_code:-1001 */
export const TUICALL_PACKAGE_ERROR_CODE = -1001
/** 是否为「套餐不含该能力 / 需续费」类错误(如 TUICallEngineError: The package you purchased does not support this ability */
export function isTUICallPackageAbilityError(err: unknown): boolean {
if (err && typeof err === 'object') {
const o = err as Record<string, unknown>
const code = o.code ?? o.error_code
if (code === TUICALL_PACKAGE_ERROR_CODE || code === String(TUICALL_PACKAGE_ERROR_CODE)) {
return true
}
}
const text = collectErrorText(err)
return (
text.includes('does not support this ability') ||
text.includes('package you purchased') ||
(text.includes('-1001') && text.includes('does not support'))
)
}
/** 用户可见短文案 */
export function getTUICallPackageArrearsMessage(): string {
return '套餐欠费或未开通该能力,请联系管理员在腾讯云续费或升级套餐。'
}
/** 发起通话等失败时:套餐类错误用友好文案,否则返回原始信息 */
export function formatTUICallUserError(err: unknown, defaultMessage = '通话操作失败'): string {
if (isTUICallPackageAbilityError(err)) return getTUICallPackageArrearsMessage()
if (err instanceof Error) return err.message || defaultMessage
if (typeof err === 'string') return err || defaultMessage
return defaultMessage
}
+598
View File
@@ -0,0 +1,598 @@
<template>
<div class="paiban-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>医生排班管理</span>
</div>
</template>
<el-form :model="form" label-width="100px" class="paiban-form">
<!-- 选择医生 -->
<el-form-item label="选择医生:">
<div class="doctor-list">
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
<el-radio
v-for="doctor in doctorList"
:key="doctor.id"
:value="doctor.id"
class="doctor-radio"
>
{{ doctor.name }}
</el-radio>
</el-radio-group>
</div>
</el-form-item>
<!-- 排班时间 -->
<el-form-item label="排班时间:">
<!-- 未选择医生提示 -->
<el-empty
v-if="!selectedDoctorId"
description="请先选择医生"
:image-size="80"
/>
<!-- 医生无排班提示 -->
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="80"
/>
<!-- 有排班时显示日期和时间段 -->
<div v-else class="paiban-time-container">
<!-- 日期选择 -->
<div class="date-selector">
<el-button
v-for="dateOption in dateOptions"
:key="dateOption.date"
:type="form.date === dateOption.date ? 'primary' : ''"
@click="selectDate(dateOption.date)"
class="date-button"
>
{{ dateOption.label }}
</el-button>
</div>
<!-- 时间段区域 -->
<div v-if="form.date" class="time-slots-container">
<!-- 时间段标题和刷新按钮 -->
<!-- <div class="time-slots-header">
<span class="header-title">排班时段</span>
<el-button
text
type="primary"
size="small"
:loading="refreshing"
@click="handleRefreshSlots"
>
<template #icon>
<Refresh />
</template>
刷新
</el-button>
</div> -->
<!-- 时间段网格 -->
<div class="time-slots-grid">
<div
v-for="slot in filteredTimeSlots"
:key="slot.time"
class="time-slot-item"
:class="{
'available': slot.available,
'unavailable': !slot.available,
'selected': form.selectedTime === slot.time
}"
@click="selectTimeSlot(slot)"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : '已约' }}
</div>
</div>
</div>
</div>
</div>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
import { getDoctors } from '@/api/tcm'
import { getAvailableSlots, rosterLists } from '@/api/doctor'
import feedback from '@/utils/feedback'
dayjs.extend(isoWeek)
interface TimeSlot {
time: string
available: boolean
quota: number
}
interface DateOption {
date: string
label: string
}
const loading = ref(false)
const refreshing = ref(false)
const doctorList = ref<any[]>([])
const timeSlots = ref<TimeSlot[]>([])
const selectedDoctorId = ref(0)
const doctorRosterDates = ref<string[]>([])
let autoRefreshTimer: number | null = null
let isLoadingSlots = false // 防止并发请求
const form = reactive({
date: '',
selectedTime: ''
})
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
const dateOptions = computed<DateOption[]>(() => {
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
return []
}
const options: DateOption[] = []
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const today = dayjs().startOf('day')
// 只显示有排班的日期,且日期大于等于今天
for (const date of doctorRosterDates.value) {
const dateObj = dayjs(date)
// 过滤掉今天之前的日期
if (dateObj.isBefore(today)) {
continue
}
const label = `${dateObj.format('MM月DD日')} (${weekDays[dateObj.day()]})`
options.push({
date: date,
label
})
}
return options
})
// 过滤时间段:如果是今天,只显示当前时间之后的时间段
const filteredTimeSlots = computed(() => {
if (!form.date || timeSlots.value.length === 0) {
return []
}
const today = dayjs().format('YYYY-MM-DD')
const isToday = form.date === today
if (!isToday) {
// 不是今天,显示所有时间段
return timeSlots.value
}
// 是今天,只显示当前时间之后的时间段
const now = dayjs()
return timeSlots.value.map(slot => {
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
// 如果时间已过,标记为不可用
if (isPast) {
return {
...slot,
available: false
}
}
return slot
})
})
// 加载医生列表
const loadDoctors = async () => {
try {
loading.value = true
const res = await getDoctors()
doctorList.value = res || []
// 默认选中第一个医生
if (doctorList.value.length > 0 && !selectedDoctorId.value) {
selectedDoctorId.value = doctorList.value[0].id
await handleDoctorChange()
}
} catch (error) {
console.error('加载医生列表失败:', error)
feedback.msgError('加载医生列表失败')
} finally {
loading.value = false
}
}
// 医生改变
const handleDoctorChange = async () => {
form.selectedTime = ''
form.date = ''
timeSlots.value = []
doctorRosterDates.value = []
if (selectedDoctorId.value) {
await loadDoctorRoster()
}
}
// 加载医生排班信息
const loadDoctorRoster = async () => {
if (!selectedDoctorId.value) {
return
}
try {
loading.value = true
// 获取未来7天的日期范围
const startDate = dayjs().format('YYYY-MM-DD')
const endDate = dayjs().add(6, 'day').format('YYYY-MM-DD')
// 查询医生在这个日期范围内的排班
const res = await rosterLists({
doctor_id: selectedDoctorId.value,
start_date: startDate,
end_date: endDate,
status: 1 // 只查询出诊状态
})
// 提取有排班的日期
if (res?.lists && res.lists.length > 0) {
doctorRosterDates.value = [...new Set(res.lists.map((item: any) => item.date))] as string[]
doctorRosterDates.value.sort()
// 自动选择日期:优先选择今天,如果今天没有排班则选择第一个有排班的日期
await nextTick()
if (doctorRosterDates.value.length > 0) {
const today = dayjs().format('YYYY-MM-DD')
const defaultDate = doctorRosterDates.value.includes(today)
? today
: doctorRosterDates.value[0]
selectDate(defaultDate)
}
} else {
doctorRosterDates.value = []
feedback.msgWarning('该医生暂无排班')
}
} catch (error) {
console.error('加载医生排班失败:', error)
feedback.msgError('加载医生排班失败')
doctorRosterDates.value = []
} finally {
loading.value = false
}
}
// 选择日期
const selectDate = (date: string) => {
form.date = date
form.selectedTime = ''
if (selectedDoctorId.value) {
loadTimeSlots()
}
}
// 加载时间段
const loadTimeSlots = async (silent = false) => {
if (!selectedDoctorId.value || !form.date) {
return
}
// 如果正在加载,跳过本次请求
if (isLoadingSlots) {
console.log('正在加载中,跳过本次请求')
return
}
try {
isLoadingSlots = true
if (!silent) {
loading.value = true
}
// 加载全天时段(9:00-18:00,每15分钟一个)
const response = await getAvailableSlots({
doctor_id: selectedDoctorId.value,
appointment_date: form.date,
period: 'all' // 获取全天时段
})
timeSlots.value = (response?.slots || []).map((slot: any) => ({
time: slot.time,
available: slot.available,
quota: slot.available ? 1 : 0
}))
} catch (error) {
console.error('加载时间段失败:', error)
if (!silent) {
feedback.msgError('加载时间段失败')
}
timeSlots.value = []
} finally {
isLoadingSlots = false
if (!silent) {
loading.value = false
}
}
}
// 选择时间段
const selectTimeSlot = (slot: TimeSlot) => {
if (!slot.available) {
feedback.msgWarning('该时间段不可预约')
return
}
form.selectedTime = slot.time
}
// 刷新时间段
const handleRefreshSlots = async () => {
if (!selectedDoctorId.value || !form.date) {
return
}
// 如果正在加载,提示用户
if (isLoadingSlots) {
feedback.msgWarning('正在刷新中,请稍候')
return
}
try {
refreshing.value = true
const previousSelection = form.selectedTime
form.selectedTime = ''
await loadTimeSlots()
if (previousSelection) {
const slot = timeSlots.value.find(s => s.time === previousSelection)
if (slot && slot.available) {
form.selectedTime = previousSelection
}
}
feedback.msgSuccess('刷新成功')
} catch (error) {
console.error('刷新失败:', error)
feedback.msgError('刷新失败,请重试')
} finally {
refreshing.value = false
}
}
// 启动自动刷新定时器
const startAutoRefresh = () => {
// 清除已存在的定时器
stopAutoRefresh()
// 每5秒自动刷新时间段
autoRefreshTimer = window.setInterval(() => {
if (selectedDoctorId.value && form.date) {
loadTimeSlots(true) // silent模式,不显示loading
}
}, 5000)
}
// 停止自动刷新定时器
const stopAutoRefresh = () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
}
onMounted(() => {
loadDoctors()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped lang="scss">
.paiban-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.paiban-form {
:deep(.el-form-item__label) {
font-weight: 500;
}
}
.doctor-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
.doctor-radio {
margin-right: 0;
:deep(.el-radio__label) {
display: flex;
align-items: center;
}
}
}
.paiban-time-container {
width: 100%;
}
.date-selector {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
.date-button {
min-width: 130px;
height: 40px;
font-size: 14px;
border-radius: 8px;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
}
.time-slots-container {
background-color: #f8f9fa;
border-radius: 8px;
padding: 16px;
}
.time-slots-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.time-slots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
max-height: 450px;
overflow-y: auto;
padding: 2px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #dcdfe6;
border-radius: 3px;
&:hover {
background-color: #c0c4cc;
}
}
.time-slot-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0px 8px;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
min-height: 70px;
.slot-time {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.slot-status {
font-size: 12px;
color: #909399;
padding: 0px 8px;
border-radius: 4px;
background-color: #f4f4f5;
&.status-available {
color: #67c23a;
background-color: #f0f9ff;
}
}
&.available {
border-color: #e4e7ed;
&:hover {
border-color: #409eff;
background-color: #ecf5ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
}
}
&.unavailable {
background-color: #f5f7fa;
border-color: #e4e7ed;
cursor: not-allowed;
opacity: 0.6;
.slot-time {
color: #c0c4cc;
}
.slot-status {
color: #c0c4cc;
background-color: #f5f7fa;
}
&:hover {
transform: none;
box-shadow: none;
}
}
&.selected {
border-color: #409eff;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
.slot-time {
color: #fff;
}
.slot-status {
color: #fff;
background-color: rgba(255, 255, 255, 0.2);
}
}
}
}
:deep(.el-radio-group) {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
:deep(.el-radio) {
margin-right: 0;
}
</style>
@@ -0,0 +1,663 @@
<!-- 处方管理 -->
<template>
<div class="prescription-list">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="处方名称">
<el-input
v-model="formData.prescription_name"
placeholder="请输入处方名称"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="患者姓名">
<el-input
v-model="formData.patient_name"
placeholder="请输入患者姓名"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[200px]" label="是否共享">
<el-select v-model="formData.is_shared" placeholder="全部" clearable>
<el-option label="全部" :value="''" />
<el-option label="仅自己可见" :value="0" />
<el-option label="所有人可见" :value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<el-button type="primary" @click="handleAdd" v-perms="['cf.prescription/add']">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增处方
</el-button>
<div class="mt-4">
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="处方编号" prop="sn" min-width="140" />
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
<el-table-column label="类型" prop="prescription_type" min-width="100" />
<el-table-column label="患者姓名" prop="patient_name" min-width="100" />
<el-table-column label="性别" min-width="60">
<template #default="{ row }">
{{ row.gender === 1 ? '男' : '女' }}
</template>
</el-table-column>
<el-table-column label="年龄" prop="age" min-width="60" />
<el-table-column label="药材数量" min-width="90">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="剂数" min-width="80">
<template #default="{ row }">
{{ row.dose_count }}{{ row.dose_unit }}
</template>
</el-table-column>
<el-table-column label="服用天数" min-width="90">
<template #default="{ row }">
{{ row.usage_days }}
</template>
</el-table-column>
<el-table-column label="是否共享" min-width="100">
<template #default="{ row }">
<el-tag :type="row.is_shared ? 'success' : 'info'">
{{ row.is_shared ? '所有人可见' : '仅自己可见' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="医师" prop="doctor_name" min-width="100" />
<el-table-column label="处方日期" prop="prescription_date" min-width="120" />
<el-table-column label="创建时间" prop="create_time" min-width="160" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
查看
</el-button>
<el-button type="primary" link @click="handleEdit(row)" v-perms="['cf.prescription/edit']">
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row.id)" v-perms="['cf.prescription/del']">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="showEdit"
:title="editMode === 'add' ? '新增处方' : editMode === 'edit' ? '编辑处方' : '查看处方'"
width="900px"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="editForm"
:rules="rules"
label-width="120px"
:disabled="editMode === 'view'"
>
<el-form-item label="处方名称" prop="prescription_name">
<el-input
v-model="editForm.prescription_name"
placeholder="请输入处方名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="editForm.patient_name" placeholder="请输入患者姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="门诊号" prop="visit_no">
<el-input v-model="editForm.visit_no" placeholder="自动生成或手动输入" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="editForm.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="年龄" prop="age">
<el-input-number
v-model="editForm.age"
:min="0"
:max="150"
placeholder="请输入年龄"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="处方日期" prop="prescription_date">
<el-date-picker
v-model="editForm.prescription_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="面象" prop="tongue">
<el-input v-model="editForm.tongue" placeholder="请输入面象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="舌象" prop="tongue_image">
<el-input v-model="editForm.tongue_image" placeholder="请输入舌象" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="脉象" prop="pulse">
<el-input v-model="editForm.pulse" placeholder="请输入脉象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象详情" prop="pulse_condition">
<el-input v-model="editForm.pulse_condition" placeholder="请输入脉象详情" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="临床诊断" prop="clinical_diagnosis">
<el-input
v-model="editForm.clinical_diagnosis"
type="textarea"
:rows="3"
placeholder="请输入临床诊断"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<el-button
type="primary"
size="small"
@click="addHerb"
:disabled="editMode === 'view'"
>
添加药材
</el-button>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="150">
<template #default="{ row, $index }">
<el-input
v-model="row.name"
placeholder="请输入药材名称"
:disabled="editMode === 'view'"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row, $index }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
:disabled="editMode === 'view'"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" v-if="editMode !== 'view'">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="removeHerb($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="剂数" prop="dose_count">
<el-input-number
v-model="editForm.dose_count"
:min="1"
placeholder="剂数"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="剂量单位" prop="dose_unit">
<el-select v-model="editForm.dose_unit" placeholder="请选择" class="w-full">
<el-option label="剂" value="剂" />
<el-option label="丸" value="丸" />
<el-option label="袋" value="袋" />
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="膏" value="膏" />
<el-option label="贴" value="贴" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="处方类型" prop="prescription_type">
<el-select v-model="editForm.prescription_type" placeholder="请选择处方类型" class="w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
</el-select>
</el-form-item> </el-col>
<el-col :span="8">
<el-form-item label="服用天数" prop="usage_days">
<el-input-number
v-model="editForm.usage_days"
:min="1"
:max="365"
placeholder="服用天数"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="用法" prop="usage_instruction">
<el-input
v-model="editForm.usage_instruction"
placeholder="例如:水煎服,一日二次"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="服用时间" prop="usage_time">
<el-select v-model="editForm.usage_time" placeholder="请选择服用时间" class="w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-option label="饭中" value="饭中" />
<el-option label="空腹" value="空腹" />
<el-option label="睡前" value="睡前" />
<el-option label="晨起" value="晨起" />
<el-option label="随时" value="随时" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服用方式" prop="usage_way">
<el-select v-model="editForm.usage_way" placeholder="请选择服用方式" class="w-full">
<el-option label="温水送服" value="温水送服" />
<el-option label="开水冲服" value="开水冲服" />
<el-option label="黄酒送服" value="黄酒送服" />
<el-option label="淡盐水送服" value="淡盐水送服" />
<el-option label="米汤送服" value="米汤送服" />
<el-option label="嚼服" value="嚼服" />
<el-option label="含化" value="含化" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="忌口" prop="dietary_taboo">
<el-select
v-model="editForm.dietary_taboo"
multiple
placeholder="请选择忌口内容(可多选)"
class="w-full"
>
<el-option label="辛辣食物" value="辛辣食物" />
<el-option label="生冷食物" value="生冷食物" />
<el-option label="油腻食物" value="油腻食物" />
<el-option label="海鲜" value="海鲜" />
<el-option label="牛羊肉" value="牛羊肉" />
<el-option label="鸡蛋" value="鸡蛋" />
<el-option label="豆制品" value="豆制品" />
<el-option label="酒类" value="酒类" />
<el-option label="浓茶" value="浓茶" />
<el-option label="咖啡" value="咖啡" />
<el-option label="烟草" value="烟草" />
<el-option label="萝卜" value="萝卜" />
</el-select>
</el-form-item>
<el-form-item label="其他说明" prop="usage_notes">
<el-input
v-model="editForm.usage_notes"
type="textarea"
:rows="2"
placeholder="其他服用注意事项"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="医师姓名" prop="doctor_name">
<el-input v-model="editForm.doctor_name" placeholder="请输入医师姓名" />
</el-form-item>
<el-form-item label="是否共享" prop="is_shared">
<el-switch
v-model="editForm.is_shared"
:active-value="1"
:inactive-value="0"
active-text="所有人可见"
inactive-text="仅自己可见"
/>
<div class="text-xs text-gray-400 mt-1">
勾选后所有医生都可以查看和使用此处方模板
</div>
</el-form-item>
</el-form>
<template #footer v-if="editMode !== 'view'">
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="prescriptionList">
import { prescriptionLists, prescriptionAdd, prescriptionEdit, prescriptionDelete } from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import type { FormInstance, FormRules } from 'element-plus'
// 表单数据
const formData = reactive({
prescription_name: '',
patient_name: '',
is_shared: ''
})
const showEdit = ref(false)
const editMode = ref<'add' | 'edit' | 'view'>('add')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
// 编辑表单
const editForm = reactive({
id: 0,
prescription_name: '',
prescription_type: '浓缩水丸',
patient_name: '',
gender: 1,
age: 0,
visit_no: '',
prescription_date: '',
tongue: '',
tongue_image: '',
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>,
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
usage_instruction: '水煎服,一日二次',
usage_time: '饭前',
usage_way: '温水送服',
dietary_taboo: [] as string[],
usage_notes: '',
doctor_name: '',
is_shared: 0,
diagnosis_id: 0
})
// 表单验证规则
const rules: FormRules = {
prescription_name: [
{ required: true, message: '请输入处方名称', trigger: 'blur' }
],
patient_name: [
{ required: true, message: '请输入患者姓名', trigger: 'blur' }
],
gender: [
{ required: true, message: '请选择性别', trigger: 'change' }
],
prescription_date: [
{ required: true, message: '请选择处方日期', trigger: 'change' }
],
clinical_diagnosis: [
{ required: true, message: '请输入临床诊断', trigger: 'blur' }
],
dose_count: [
{ required: true, message: '请输入剂数', trigger: 'blur' }
],
doctor_name: [
{ required: true, message: '请输入医师姓名', trigger: 'blur' }
]
}
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: prescriptionLists,
params: formData
})
// 添加药材
const addHerb = () => {
editForm.herbs.push({
name: '',
dosage: 0
})
}
// 删除药材
const removeHerb = (index: number) => {
editForm.herbs.splice(index, 1)
}
// 重置表单
const resetForm = () => {
editForm.id = 0
editForm.prescription_name = ''
editForm.prescription_type = '浓缩水丸'
editForm.patient_name = ''
editForm.gender = 1
editForm.age = 0
editForm.visit_no = ''
editForm.prescription_date = new Date().toISOString().split('T')[0]
editForm.tongue = ''
editForm.tongue_image = ''
editForm.pulse = ''
editForm.pulse_condition = ''
editForm.clinical_diagnosis = ''
editForm.herbs = []
editForm.dose_count = 7
editForm.dose_unit = '剂'
editForm.usage_days = 7
editForm.usage_instruction = '水煎服,一日二次'
editForm.usage_time = '饭前'
editForm.usage_way = '温水送服'
editForm.dietary_taboo = []
editForm.usage_notes = ''
editForm.doctor_name = ''
editForm.is_shared = 0
editForm.diagnosis_id = 0
}
// 新增处方
const handleAdd = () => {
resetForm()
editMode.value = 'add'
showEdit.value = true
}
// 查看处方
const handleView = (row: any) => {
editMode.value = 'view'
// 深拷贝数据到表单
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
editForm.prescription_type = row.prescription_type || '浓缩水丸'
editForm.patient_name = row.patient_name || ''
editForm.gender = row.gender ?? 1
editForm.age = row.age ?? 0
editForm.visit_no = row.visit_no || ''
editForm.prescription_date = row.prescription_date || ''
editForm.tongue = row.tongue || ''
editForm.tongue_image = row.tongue_image || ''
editForm.pulse = row.pulse || ''
editForm.pulse_condition = row.pulse_condition || ''
editForm.clinical_diagnosis = row.clinical_diagnosis || ''
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.dose_count = row.dose_count ?? 7
editForm.dose_unit = row.dose_unit || '剂'
editForm.usage_days = row.usage_days ?? 7
editForm.usage_instruction = row.usage_instruction || '水煎服,一日二次'
editForm.usage_time = row.usage_time || '饭前'
editForm.usage_way = row.usage_way || '温水送服'
editForm.dietary_taboo = row.dietary_taboo ? (Array.isArray(row.dietary_taboo) ? row.dietary_taboo : row.dietary_taboo.split(',').filter((v: string) => v)) : []
editForm.usage_notes = row.usage_notes || ''
editForm.doctor_name = row.doctor_name || ''
editForm.is_shared = row.is_shared ?? 0
editForm.diagnosis_id = row.diagnosis_id ?? 0
showEdit.value = true
}
// 编辑处方
const handleEdit = (row: any) => {
editMode.value = 'edit'
// 深拷贝数据到表单
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
editForm.prescription_type = row.prescription_type || '浓缩水丸'
editForm.patient_name = row.patient_name || ''
editForm.gender = row.gender ?? 1
editForm.age = row.age ?? 0
editForm.visit_no = row.visit_no || ''
editForm.prescription_date = row.prescription_date || ''
editForm.tongue = row.tongue || ''
editForm.tongue_image = row.tongue_image || ''
editForm.pulse = row.pulse || ''
editForm.pulse_condition = row.pulse_condition || ''
editForm.clinical_diagnosis = row.clinical_diagnosis || ''
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.dose_count = row.dose_count ?? 7
editForm.dose_unit = row.dose_unit || '剂'
editForm.usage_days = row.usage_days ?? 7
editForm.usage_instruction = row.usage_instruction || '水煎服,一日二次'
editForm.usage_time = row.usage_time || '饭前'
editForm.usage_way = row.usage_way || '温水送服'
editForm.dietary_taboo = row.dietary_taboo ? (Array.isArray(row.dietary_taboo) ? row.dietary_taboo : row.dietary_taboo.split(',').filter((v: string) => v)) : []
editForm.usage_notes = row.usage_notes || ''
editForm.doctor_name = row.doctor_name || ''
editForm.is_shared = row.is_shared ?? 0
editForm.diagnosis_id = row.diagnosis_id ?? 0
showEdit.value = true
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
// 验证药材
if (!editForm.herbs || editForm.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
return
}
for (let i = 0; i < editForm.herbs.length; i++) {
const herb = editForm.herbs[i]
if (!herb.name || !herb.name.trim()) {
feedback.msgError(`${i + 1}味药材名称不能为空`)
return
}
if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`${i + 1}味药材剂量必须大于0`)
return
}
}
submitLoading.value = true
try {
const params = { ...editForm }
if (editMode.value === 'add') {
await prescriptionAdd(params)
feedback.msgSuccess('添加成功')
} else {
await prescriptionEdit(params)
feedback.msgSuccess('编辑成功')
}
showEdit.value = false
getLists()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
// 删除处方
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除该处方吗?')
await prescriptionDelete({ id })
feedback.msgSuccess('删除成功')
getLists()
}
onMounted(() => {
getLists()
})
</script>
<style scoped lang="scss">
.prescription-list {
padding: 20px;
}
</style>
@@ -0,0 +1,332 @@
<!-- 处方库 -->
<template>
<div class="prescription-library">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="处方名称">
<el-input
v-model="formData.prescription_name"
placeholder="请输入处方名称"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[200px]" label="是否公开">
<el-select v-model="formData.is_public" placeholder="全部" clearable>
<el-option label="全部" :value="''" />
<el-option label="仅自己可见" :value="0" />
<el-option label="所有人可见" :value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<el-button type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增处方
</el-button>
<div class="mt-4">
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
<el-table-column label="药材数量" min-width="100">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="药材明细" min-width="300" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.herbs && row.herbs.length > 0">
{{ row.herbs.map((h: any) => `${h.name} ${h.dosage}g`).join('、') }}
</span>
<span v-else class="text-gray-400">暂无药材</span>
</template>
</el-table-column>
<el-table-column label="是否公开" min-width="120">
<template #default="{ row }">
<el-tag :type="row.is_public ? 'success' : 'info'">
{{ row.is_public ? '所有人可见' : '仅自己可见' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" min-width="100" />
<el-table-column label="创建时间" prop="create_time" min-width="160" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)">
查看
</el-button>
<el-button type="primary" link @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="showEdit"
:title="editMode === 'add' ? '新增处方' : editMode === 'edit' ? '编辑处方' : '查看处方'"
width="800px"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="editForm"
:rules="rules"
label-width="120px"
:disabled="editMode === 'view'"
>
<el-form-item label="处方名称" prop="prescription_name">
<el-input
v-model="editForm.prescription_name"
placeholder="请输入处方名称,例如:六味地黄丸、补中益气汤"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<el-button
type="primary"
size="small"
@click="addHerb"
:disabled="editMode === 'view'"
>
添加药材
</el-button>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="200">
<template #default="{ row }">
<el-input
v-model="row.name"
placeholder="请输入药材名称"
:disabled="editMode === 'view'"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="150">
<template #default="{ row }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
:disabled="editMode === 'view'"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" v-if="editMode !== 'view'">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="removeHerb($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-form-item label="是否公开" prop="is_public">
<el-switch
v-model="editForm.is_public"
:active-value="1"
:inactive-value="0"
active-text="所有人可见"
inactive-text="仅自己可见"
/>
<div class="text-xs text-gray-400 mt-1">
勾选后所有医生都可以查看和使用此处方模板
</div>
</el-form-item>
</el-form>
<template #footer v-if="editMode !== 'view'">
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="prescriptionLibrary">
import {
prescriptionLibraryLists,
prescriptionLibraryAdd,
prescriptionLibraryEdit,
prescriptionLibraryDelete
} from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import type { FormInstance, FormRules } from 'element-plus'
// 表单数据
const formData = reactive({
prescription_name: '',
is_public: ''
})
const showEdit = ref(false)
const editMode = ref<'add' | 'edit' | 'view'>('add')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
// 编辑表单
const editForm = reactive({
id: 0,
prescription_name: '',
herbs: [] as Array<{ name: string; dosage: number }>,
is_public: 0
})
// 表单验证规则
const rules: FormRules = {
prescription_name: [
{ required: true, message: '请输入处方名称', trigger: 'blur' }
]
}
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: prescriptionLibraryLists,
params: formData
})
// 添加药材
const addHerb = () => {
editForm.herbs.push({
name: '',
dosage: 0
})
}
// 删除药材
const removeHerb = (index: number) => {
editForm.herbs.splice(index, 1)
}
// 重置表单
const resetForm = () => {
editForm.id = 0
editForm.prescription_name = ''
editForm.herbs = []
editForm.is_public = 0
}
// 新增处方
const handleAdd = () => {
resetForm()
editMode.value = 'add'
showEdit.value = true
}
// 查看处方
const handleView = (row: any) => {
editMode.value = 'view'
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
showEdit.value = true
}
// 编辑处方
const handleEdit = (row: any) => {
editMode.value = 'edit'
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
showEdit.value = true
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
// 验证药材
if (!editForm.herbs || editForm.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
return
}
for (let i = 0; i < editForm.herbs.length; i++) {
const herb = editForm.herbs[i]
if (!herb.name || !herb.name.trim()) {
feedback.msgError(`${i + 1}味药材名称不能为空`)
return
}
if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`${i + 1}味药材剂量必须大于0`)
return
}
}
submitLoading.value = true
try {
const params = { ...editForm }
if (editMode.value === 'add') {
await prescriptionLibraryAdd(params)
feedback.msgSuccess('添加成功')
} else {
await prescriptionLibraryEdit(params)
feedback.msgSuccess('编辑成功')
}
showEdit.value = false
getLists()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
// 删除处方
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除该处方吗?')
await prescriptionLibraryDelete({ id })
feedback.msgSuccess('删除成功')
getLists()
}
onMounted(() => {
getLists()
})
</script>
<style scoped lang="scss">
.prescription-library {
padding: 20px;
}
</style>
@@ -2,157 +2,419 @@
<el-drawer
v-model="visible"
title="创建处方单"
size="800px"
size="90%"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
label-width="100px"
:rules="rules"
label-width="120px"
class="prescription-form"
>
<!-- 患者基本信息 -->
<div class="info-section">
<div class="section-title">患者信息</div>
<el-form-item label="姓名">
<span>{{ formData.patient_name }}</span>
</el-form-item>
<el-form-item label="性别">
<span>{{ formData.gender }}</span>
</el-form-item>
<el-form-item label="年龄">
<span>{{ formData.age }}</span>
</el-form-item>
<el-form-item label="诊号">
<span>{{ formData.diagnosis_no }}</span>
</el-form-item>
<el-form-item label="日期">
<span>{{ formData.date }}</span>
</el-form-item>
<el-form-item label="科室">
<span>{{ formData.department }}</span>
</el-form-item>
<el-form-item label="费别">
<span>{{ formData.fee_type }}</span>
</el-form-item>
<el-form-item label="手机号">
<span>{{ formData.phone }}</span>
</el-form-item>
</div>
<!-- 诊断信息 -->
<div class="info-section">
<div class="section-title">诊断信息</div>
<el-form-item label="诊断" prop="diagnosis">
<el-input
v-model="formData.diagnosis"
type="textarea"
:rows="3"
placeholder="请输入诊断信息"
/>
</el-form-item>
</div>
<!-- 药品信息 -->
<div class="info-section">
<div class="section-title">
药品信息
<el-button type="primary" size="small" @click="handleAddMedicine">
添加药品
</el-button>
</div>
<div
v-for="(medicine, index) in formData.medicines"
:key="index"
class="medicine-item"
>
<el-form-item :label="`药品${index + 1}`">
<el-input
v-model="medicine.name"
placeholder="药品名称"
class="mb-2"
/>
<el-input
v-model="medicine.usage"
type="textarea"
:rows="2"
placeholder="用法用量"
/>
<el-button
type="danger"
size="small"
link
@click="handleRemoveMedicine(index)"
class="mt-2"
>
删除
</el-button>
<el-form-item label="处方名称" prop="prescription_name">
<el-input
v-model="formData.prescription_name"
placeholder="请输入处方名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="处方类型" prop="prescription_type">
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="formData.patient_name" placeholder="请输入患者姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="门诊号" prop="visit_no">
<el-input v-model="formData.visit_no" placeholder="自动生成或手动输入" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="年龄" prop="age">
<el-input-number
v-model="formData.age"
:min="0"
:max="150"
placeholder="请输入年龄"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="处方日期" prop="prescription_date">
<el-date-picker
v-model="formData.prescription_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="面象" prop="tongue">
<el-input v-model="formData.tongue" placeholder="请输入面象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="舌象" prop="tongue_image">
<el-input v-model="formData.tongue_image" placeholder="请输入舌象" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="脉象" prop="pulse">
<el-input v-model="formData.pulse" placeholder="请输入脉象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象详情" prop="pulse_condition">
<el-input v-model="formData.pulse_condition" placeholder="请输入脉象详情" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="临床诊断" prop="clinical_diagnosis">
<el-input
v-model="formData.clinical_diagnosis"
type="textarea"
:rows="3"
placeholder="请输入临床诊断"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<el-button
type="primary"
size="small"
@click="addHerb"
>
添加药材
</el-button>
<el-table :data="formData.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="150">
<template #default="{ row }">
<el-input
v-model="row.name"
placeholder="请输入药材名称"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="removeHerb($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="剂数" prop="dose_count">
<el-input-number
v-model="formData.dose_count"
:min="1"
placeholder="剂数"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="剂量单位" prop="dose_unit">
<el-select v-model="formData.dose_unit" placeholder="请选择" class="w-full">
<el-option label="剂" value="剂" />
<el-option label="丸" value="丸" />
<el-option label="袋" value="袋" />
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="膏" value="膏" />
<el-option label="贴" value="贴" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数" prop="usage_days">
<el-input-number
v-model="formData.usage_days"
:min="1"
:max="365"
placeholder="服用天数"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="用法" prop="usage_instruction">
<el-input
v-model="formData.usage_instruction"
placeholder="例如:水煎服,一日二次"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="服用时间" prop="usage_time">
<el-select v-model="formData.usage_time" placeholder="请选择服用时间" class="w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-option label="饭中" value="饭中" />
<el-option label="空腹" value="空腹" />
<el-option label="睡前" value="睡前" />
<el-option label="晨起" value="晨起" />
<el-option label="随时" value="随时" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服用方式" prop="usage_way">
<el-select v-model="formData.usage_way" placeholder="请选择服用方式" class="w-full">
<el-option label="温水送服" value="温水送服" />
<el-option label="开水冲服" value="开水冲服" />
<el-option label="黄酒送服" value="黄酒送服" />
<el-option label="淡盐水送服" value="淡盐水送服" />
<el-option label="米汤送服" value="米汤送服" />
<el-option label="嚼服" value="嚼服" />
<el-option label="含化" value="含化" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="忌口" prop="dietary_taboo">
<el-select
v-model="formData.dietary_taboo"
multiple
placeholder="请选择忌口内容(可多选)"
class="w-full"
>
<el-option label="辛辣食物" value="辛辣食物" />
<el-option label="生冷食物" value="生冷食物" />
<el-option label="油腻食物" value="油腻食物" />
<el-option label="海鲜" value="海鲜" />
<el-option label="牛羊肉" value="牛羊肉" />
<el-option label="鸡蛋" value="鸡蛋" />
<el-option label="豆制品" value="豆制品" />
<el-option label="酒类" value="酒类" />
<el-option label="浓茶" value="浓茶" />
<el-option label="咖啡" value="咖啡" />
<el-option label="烟草" value="烟草" />
<el-option label="萝卜" value="萝卜" />
</el-select>
</el-form-item>
<el-form-item label="其他说明" prop="usage_notes">
<el-input
v-model="formData.usage_notes"
type="textarea"
:rows="2"
placeholder="其他服用注意事项"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="医师姓名" prop="doctor_name">
<el-input v-model="formData.doctor_name" placeholder="请输入医师姓名" />
</el-form-item>
<el-form-item label="是否共享" prop="is_shared">
<el-switch
v-model="formData.is_shared"
:active-value="1"
:inactive-value="0"
active-text="所有人可见"
inactive-text="仅自己可见"
/>
<div class="text-xs text-gray-400 mt-1">
勾选后所有医生都可以查看和使用此处方模板
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button type="primary" @click="handleSave" :loading="submitLoading">保存</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { prescriptionAdd } from '@/api/tcm'
import feedback from '@/utils/feedback'
interface Medicine {
name: string
usage: string
}
interface PrescriptionForm {
appointment_id: number
patient_name: string
gender: string
age: string
diagnosis_no: string
date: string
department: string
fee_type: string
phone: string
diagnosis: string
medicines: Medicine[]
}
import type { FormInstance, FormRules } from 'element-plus'
const emit = defineEmits(['success'])
const visible = ref(false)
const formRef = ref()
const formData = reactive<PrescriptionForm>({
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const formData = reactive({
appointment_id: 0,
diagnosis_id: 0,
prescription_name: '',
prescription_type: '浓缩水丸',
patient_name: '',
gender: '',
age: '',
diagnosis_no: '',
date: '',
department: '',
fee_type: '',
phone: '',
diagnosis: '',
medicines: []
gender: 1,
age: 0,
visit_no: '',
prescription_date: '',
tongue: '',
tongue_image: '',
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>,
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
usage_instruction: '水煎服,一日二次',
usage_time: '饭前',
usage_way: '温水送服',
dietary_taboo: [] as string[],
usage_notes: '',
doctor_name: '',
is_shared: 0
})
// 表单验证规则
const rules: FormRules = {
prescription_name: [
{ required: true, message: '请输入处方名称', trigger: 'blur' }
],
patient_name: [
{ required: true, message: '请输入患者姓名', trigger: 'blur' }
],
gender: [
{ required: true, message: '请选择性别', trigger: 'change' }
],
prescription_date: [
{ required: true, message: '请选择处方日期', trigger: 'change' }
],
clinical_diagnosis: [
{ required: true, message: '请输入临床诊断', trigger: 'blur' }
],
dose_count: [
{ required: true, message: '请输入剂数', trigger: 'blur' }
],
doctor_name: [
{ required: true, message: '请输入医师姓名', trigger: 'blur' }
]
}
// 添加药材
const addHerb = () => {
formData.herbs.push({
name: '',
dosage: 0
})
}
// 删除药材
const removeHerb = (index: number) => {
formData.herbs.splice(index, 1)
}
// 重置表单
const resetForm = () => {
formData.appointment_id = 0
formData.diagnosis_id = 0
formData.prescription_name = ''
formData.prescription_type = '浓缩水丸'
formData.patient_name = ''
formData.gender = 1
formData.age = 0
formData.visit_no = ''
formData.prescription_date = new Date().toISOString().split('T')[0]
formData.tongue = ''
formData.tongue_image = ''
formData.pulse = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = ''
formData.herbs = []
formData.dose_count = 7
formData.dose_unit = '剂'
formData.usage_days = 7
formData.usage_instruction = '水煎服,一日二次'
formData.usage_time = '饭前'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.doctor_name = ''
formData.is_shared = 0
}
// 打开抽屉
const open = (data: any) => {
formData.appointment_id = data.id
resetForm()
formData.appointment_id = data.id || 0
formData.diagnosis_id = data.diagnosis_id || 0
formData.patient_name = data.patient_name || ''
formData.gender = data.patient_gender || ''
formData.age = data.patient_age || ''
formData.diagnosis_no = data.id.toString()
formData.date = data.appointment_date || ''
formData.department = '中医科'
formData.fee_type = data.appointment_type_desc || ''
formData.phone = data.patient_phone || ''
formData.diagnosis = ''
formData.medicines = []
formData.gender = data.patient_gender === '' ? 1 : 0
formData.age = parseInt(data.patient_age) || 0
formData.visit_no = data.id?.toString() || ''
formData.prescription_date = data.appointment_date || new Date().toISOString().split('T')[0]
formData.doctor_name = data.doctor_name || ''
visible.value = true
}
@@ -163,48 +425,41 @@ const handleClose = () => {
formRef.value?.resetFields()
}
// 添加药品
const handleAddMedicine = () => {
formData.medicines.push({
name: '',
usage: ''
})
}
// 删除药品
const handleRemoveMedicine = (index: number) => {
formData.medicines.splice(index, 1)
}
// 保存处方单
const handleSave = async () => {
if (!formData.diagnosis) {
feedback.msgWarning('请输入诊断信息')
if (!formRef.value) return
await formRef.value.validate()
// 验证药材
if (!formData.herbs || formData.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
return
}
if (formData.medicines.length === 0) {
feedback.msgWarning('请至少添加一个药品')
return
}
// 验证药品信息
for (const medicine of formData.medicines) {
if (!medicine.name || !medicine.usage) {
feedback.msgWarning('请完善药品信息')
for (let i = 0; i < formData.herbs.length; i++) {
const herb = formData.herbs[i]
if (!herb.name || !herb.name.trim()) {
feedback.msgError(`${i + 1}味药材名称不能为空`)
return
}
if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`${i + 1}味药材剂量必须大于0`)
return
}
}
submitLoading.value = true
try {
// TODO: 调用保存处方单的API
// await savePrescription(formData)
await prescriptionAdd(formData)
feedback.msgSuccess('处方单创建成功')
handleClose()
emit('success')
} catch (error) {
feedback.msgError('保存失败')
console.error(error)
} finally {
submitLoading.value = false
}
}
@@ -215,41 +470,26 @@ defineExpose({
<style scoped lang="scss">
.prescription-form {
.info-section {
margin-bottom: 24px;
padding: 16px;
background: #f5f7fa;
border-radius: 4px;
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
display: flex;
justify-content: space-between;
align-items: center;
}
.el-form-item {
margin-bottom: 12px;
span {
color: #606266;
}
}
padding: 0 20px;
.w-full {
width: 100%;
}
.medicine-item {
margin-bottom: 16px;
padding: 12px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
.el-form-item {
margin-bottom: 0;
}
.mt-1 {
margin-top: 4px;
}
.mt-2 {
margin-top: 8px;
}
.text-xs {
font-size: 12px;
}
.text-gray-400 {
color: #909399;
}
}
</style>
@@ -0,0 +1,149 @@
<template>
<div class="call-record-panel">
<el-alert
type="info"
show-icon
:closable="false"
class="mb-4"
title="视频通话与云端录制"
>
<p class="call-record-tip">
下列为与本诊单关联的通话记录若在腾讯云 TRTC
控制台开启了云端录制并填写回调地址录制生成后会自动写入录制回放
</p>
<p class="call-record-tip muted">
回调 URL 示例<code>{{ callbackHint }}</code>
若配置了 <code>trtc.recording_callback_token</code>请在 URL 后附加
<code>?token=你的密钥</code>
</p>
</el-alert>
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无通话记录">
<el-table-column label="开始时间" width="170" prop="start_time_text" />
<el-table-column label="结束时间" width="170" prop="end_time_text" />
<el-table-column label="通话类型" width="100">
<template #default="{ row }">
{{ row.call_type === 1 ? '语音' : '视频' }}
</template>
</el-table-column>
<el-table-column label="时长" width="110" prop="duration_text" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
{{ statusText(row.status) }}
</template>
</el-table-column>
<el-table-column label="录制" width="100">
<template #default="{ row }">
{{ row.recording_status_text || '—' }}
</template>
</el-table-column>
<el-table-column label="录制回放" min-width="280">
<template #default="{ row }">
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
<div
v-for="(url, idx) in row.recording_urls_list"
:key="idx"
class="recording-item"
>
<video
v-if="isPlayableVideo(url)"
:src="url"
controls
preload="metadata"
class="recording-video"
/>
<el-link v-else :href="url" target="_blank" type="primary">
打开链接 {{ idx + 1 }}
</el-link>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { getCallRecords } from '@/api/tcm'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const rows = ref<any[]>([])
const callbackHint = computed(() => {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/api/trtc/recording-notify`
})
const load = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
rows.value = (await getCallRecords({ diagnosis_id: props.diagnosisId })) || []
} catch (e) {
console.error(e)
rows.value = []
} finally {
loading.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
function statusText(status: number) {
const m: Record<number, string> = {
1: '进行中',
2: '已结束',
3: '未接听',
4: '已取消'
}
return m[status] ?? '—'
}
function isPlayableVideo(url: string) {
if (!url || typeof url !== 'string') return false
return /\.(mp4|webm|ogg)(\?|$)/i.test(url)
}
</script>
<style scoped lang="scss">
.call-record-panel {
.call-record-tip {
margin: 0 0 8px;
line-height: 1.5;
font-size: 13px;
&.muted {
color: var(--el-text-color-secondary);
}
code {
font-size: 12px;
word-break: break-all;
}
}
.recording-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.recording-video {
max-width: 100%;
max-height: 180px;
border-radius: 4px;
background: #000;
}
}
</style>
@@ -0,0 +1,355 @@
<template>
<div class="im-chat-record-panel">
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
<p class="panel-tip">
展示腾讯云 IM 单聊记录已合并患者 <code>{{ patientImHint }}</code>
<strong>所有医生 / 医助账号</strong><code>doctor_*</code>分别产生的会话按时间排序
数据来自 IM 漫游若未开通漫游或某账号与该患者无会话对应侧无消息
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="loading" @click="load">
<el-icon class="mr-1"><Refresh /></el-icon>
刷新
</el-button>
</div>
<div v-loading="loading" class="chat-wrap">
<template v-if="!loading && rows.length">
<div class="chat-list">
<div
v-for="row in rows"
:key="row.msg_id"
class="chat-row"
:class="row.is_from_doctor ? 'from-doctor' : 'from-patient'"
>
<div class="meta">
<span class="name">{{ senderLabel(row) }}</span>
<span class="time">{{ formatTime(row.time) }}</span>
<el-tag v-if="typeLabel(row)" size="small" type="info" class="ml-2">
{{ typeLabel(row) }}
</el-tag>
</div>
<div class="bubble">
<template v-if="row.msg_type === 'image' && row.image_url">
<el-image
:src="row.image_url"
:preview-src-list="[row.image_url]"
fit="contain"
class="chat-img"
/>
</template>
<template v-else-if="(row.msg_type === 'file' || row.msg_type === 'sound' || row.msg_type === 'video') && row.file_url">
<el-link :href="row.file_url" target="_blank" type="primary">
{{ row.file_name || '打开文件' }}
</el-link>
</template>
<template v-else>
<template v-for="fr in [parseImFriendly(row)]" :key="row.msg_id + '-body'">
<div v-if="fr" class="friendly-text">
<div class="friendly-main">{{ fr.main }}</div>
<div v-if="fr.sub" class="friendly-sub">{{ fr.sub }}</div>
</div>
<div v-else-if="row.msg_type === 'text' && row.text" class="text-content">
{{ row.text }}
</div>
<div v-else-if="row.text" class="text-content">{{ row.text }}</div>
<span v-else class="muted">无法展示该消息类型</span>
</template>
</template>
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import dayjs from 'dayjs'
import { Refresh } from '@element-plus/icons-vue'
import { getImChatMessages } from '@/api/tcm'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const rows = ref<any[]>([])
const patientImId = ref('')
const patientName = ref('')
const patientImHint = computed(() => patientImId.value || 'patient_*')
function formatTime(ts: number | undefined) {
if (ts == null || !ts) return '—'
const sec = ts > 1e12 ? Math.floor(ts / 1000) : ts
return dayjs.unix(sec).format('YYYY-MM-DD HH:mm:ss')
}
type FriendlyParse = { main: string; sub?: string; tag?: string }
/** 业务时间:支持秒级时间戳或毫秒(字符串数字) */
function formatBizTime(t: unknown): string | undefined {
if (t == null || t === '') return undefined
const n =
typeof t === 'string' && /^\d+$/.test(t.trim())
? parseInt(t.trim(), 10)
: Number(t)
if (!Number.isFinite(n) || n <= 0) return undefined
return n > 1e12
? dayjs(n).format('YYYY-MM-DD HH:mm:ss')
: dayjs.unix(n).format('YYYY-MM-DD HH:mm:ss')
}
/** 将 IM 自定义 / 信令 JSON 解析为可读文案(医生进诊室、音视频通话等) */
function parseImBusinessPayload(raw: string): FriendlyParse | null {
const s = raw.trim()
if (!s.startsWith('{')) return null
let o: any
try {
o = JSON.parse(s)
} catch {
return null
}
if (!('businessID' in o) && !('cmd' in o)) {
return null
}
if (o.businessID === 'doctor_entered_consult_room') {
const t = formatBizTime(o.time)
return { main: '医生已进入诊室', sub: t, tag: '诊室' }
}
/** 小程序端:患者打开与医生的会话时发送(与 TUIKit/chat.vue 一致) */
if (o.businessID === 'patient_opened_chat') {
const parts: string[] = []
if (o.patientName) parts.push(`患者:${o.patientName}`)
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID${o.patientId}`)
if (o.doctorId != null && o.doctorId !== '') parts.push(`关联医生:${o.doctorId}`)
const t = formatBizTime(o.time)
if (t) parts.push(t)
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
}
if (o.businessID === 'user_typing_status') {
return { main: '对方正在输入…', tag: '状态' }
}
if (o.businessID === 'consultation_complete') {
return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' }
}
if (o.businessID === 1 || o.businessID === '1') {
let inner: any = o.data
if (typeof inner === 'string') {
try {
inner = JSON.parse(inner)
} catch {
return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' }
}
}
if (inner && typeof inner === 'object') {
return parseRtcInner(inner)
}
return null
}
if (o.businessID === 'rtc_call' || o.cmd) {
return parseRtcInner(o)
}
return null
}
function parseRtcInner(inner: any): FriendlyParse {
const callType = inner.call_type
const callTypeLabel =
callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话'
const cmd = String(inner.cmd || '')
const cmdMap: Record<string, string> = {
hangup: '已结束',
invite: '发起通话',
linebusy: '对方忙线',
cancel: '已取消',
reject: '已拒绝',
accept: '已接听',
timeout: '无人接听'
}
const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '')
const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel
const parts: string[] = []
if (inner.inviter) parts.push(`发起方 ${inner.inviter}`)
if (inner.invitee) parts.push(`对方 ${inner.invitee}`)
if (inner.groupID) parts.push(`群组 ${inner.groupID}`)
return {
main,
sub: parts.length ? parts.join('') : undefined,
tag: '通话'
}
}
function parseImFriendly(row: any): FriendlyParse | null {
const raw = (row.text || '').trim()
if (!raw) return null
const looksLikeBizJson =
raw.startsWith('{') && (/\bbusinessID\b/.test(raw) || /\bcmd\b/.test(raw))
if (row.msg_type === 'custom' || looksLikeBizJson) {
return parseImBusinessPayload(raw)
}
return null
}
function typeLabel(row: any) {
const fr = parseImFriendly(row)
if (fr?.tag) return fr.tag
const m: Record<string, string> = {
text: '',
image: '图片',
file: '文件',
sound: '语音',
video: '视频',
location: '位置',
custom: '自定义',
face: '表情',
other: ''
}
return m[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
function senderLabel(row: any) {
if (row.is_from_doctor) {
return row.from_staff_name ? `医生(${row.from_staff_name}` : '医生/员工'
}
return patientName.value ? `患者(${patientName.value}` : '患者'
}
async function load() {
if (!props.diagnosisId) return
loading.value = true
try {
const res = (await getImChatMessages({ diagnosis_id: props.diagnosisId })) as {
lists?: any[]
patient_im_id?: string
patient_name?: string
}
rows.value = res?.lists || []
patientImId.value = res?.patient_im_id || ''
patientName.value = res?.patient_name || ''
} catch (e) {
console.error(e)
rows.value = []
} finally {
loading.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
</script>
<style scoped lang="scss">
.im-chat-record-panel {
min-height: 200px;
}
.panel-tip {
margin: 0;
line-height: 1.55;
font-size: 13px;
code {
font-size: 12px;
}
}
.toolbar {
display: flex;
align-items: center;
}
.chat-wrap {
min-height: 120px;
}
.chat-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: min(60vh, 520px);
overflow-y: auto;
padding: 4px 8px 12px;
}
.chat-row {
display: flex;
flex-direction: column;
max-width: 88%;
&.from-patient {
align-self: flex-start;
.bubble {
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
}
}
&.from-doctor {
align-self: flex-end;
align-items: flex-end;
.meta {
flex-direction: row-reverse;
}
.bubble {
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
}
}
}
.meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
.name {
font-weight: 500;
color: var(--el-text-color-regular);
}
}
.bubble {
border-radius: 8px;
padding: 10px 12px;
word-break: break-word;
font-size: 14px;
line-height: 1.5;
}
.text-content {
white-space: pre-wrap;
}
.chat-img {
max-width: 240px;
max-height: 200px;
border-radius: 4px;
}
.muted {
color: var(--el-text-color-placeholder);
}
.friendly-text {
.friendly-main {
font-size: 14px;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.friendly-sub {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
}
</style>
+31
View File
@@ -586,6 +586,28 @@
</div>
<el-empty v-else description="请先保存诊单开方后病历将在此显示" />
</el-tab-pane>
<el-tab-pane label="回访记录" name="visit" :disabled="!formData.id" v-perms="['tcm.diagnosis/huifang']" v-if="hasPermission(['tcm.diagnosis/huifang'])">
<call-record-panel
v-if="formData.id"
ref="callRecordPanelRef"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else description="请先保存诊单后再查看回访记录" />
</el-tab-pane>
<el-tab-pane
v-if="hasPermission(['tcm.diagnosis/chat'])"
label="聊天记录"
name="chat"
:disabled="!formData.id"
lazy
>
<im-chat-record-panel
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else description="请先保存诊单后再查看聊天记录" />
</el-tab-pane>
</el-tabs>
<!-- 处方详情(查看病历) -->
@@ -613,18 +635,21 @@
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import BloodRecordList from './components/BloodRecordList.vue'
import CaseRecordList from './components/CaseRecordList.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import TcmPrescription from '@/components/tcm-prescription/index.vue'
const emit = defineEmits(['success'])
const appStore = useAppStore()
const getImageUrl = (url: string) => (url?.indexOf?.('http') === 0 ? url : appStore.getImageUrl(url || ''))
const caseRecordListRef = ref()
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
const prescriptionRef = ref()
const visible = ref(false)
@@ -634,6 +659,12 @@ const submitting = ref(false)
const activeTab = ref('basic')
const drawerTitle = computed(() => (mode.value === 'add' ? '新增诊单' : '编辑诊单'))
watch([visible, activeTab], () => {
if (visible.value && activeTab.value === 'chat' && !hasPermission(['tcm.diagnosis/chat'])) {
activeTab.value = 'basic'
}
})
const formData = ref({
id: '',
patient_id: '',
+1 -1
View File
@@ -199,7 +199,7 @@
<img :src="qrcodeUrl" alt="小程序二维码" class="w-64 h-64 border border-gray-200 rounded" />
<div class="mt-4 text-sm text-gray-600">
<div>患者{{ currentQRCodePatient?.patient_name }}</div>
<div class="mt-2">请使用微信扫描二维码</div>
<div class="mt-2">请使用企业微信扫描二维码然后转发给患者</div>
</div>
</div>
<div v-else class="text-center text-gray-500">
+189 -24
View File
@@ -84,8 +84,8 @@
</el-button>
</div>
<div v-if="hasIncomingCall || callConnected" id="call-container" class="call-container">
<TUICallKit />
<div v-if="hasIncomingCall || callConnected" id="call-container" class="call-container phone-call-shell">
<TUICallKit class="TUICallKit-mobile" />
</div>
<div v-else-if="initialized" class="waiting-container">
@@ -98,18 +98,30 @@
</el-empty>
</div>
<!-- 隐藏的 TUICallKit用于接收来电信号 -->
<div v-show="false">
<TUICallKit v-if="initialized && !hasIncomingCall && !callConnected" />
<!-- 离屏挂载手机视口尺寸避免 display:none 宽高为 0与小程序端比例接近 -->
<div
v-if="initialized && !hasIncomingCall && !callConnected"
class="phone-call-kit-host phone-call-kit-host--offscreen"
aria-hidden="true"
>
<TUICallKit class="TUICallKit-mobile" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted, computed, onMounted } from 'vue'
import { ref, onUnmounted, computed, onMounted, nextTick } from 'vue'
import { Phone } from '@element-plus/icons-vue'
import { TUICallKitServer, TUICallKit, STATUS } from '@tencentcloud/call-uikit-vue'
import {
TUICallKitServer,
TUICallKit,
STATUS,
TUIStore,
StoreName,
NAME,
CallRole
} from '@tencentcloud/call-uikit-vue'
import { ElMessage, ElNotification } from 'element-plus'
import request from '@/utils/request'
import useUserStore from '@/stores/modules/user'
@@ -128,22 +140,109 @@ const hasIncomingCall = ref(false)
const callConnected = ref(false) // 添加通话连接状态
const incomingCallInfo = ref<any>(null)
const statusCheckTimer = ref<any>(null)
/** 从 TUIStore 同步:解决「未打开本页时来电,进入后 statusChanged 不触发」的问题 */
let storeUnwatch: (() => void) | null = null
// 页面加载时自动初始化
onMounted(() => {
initPatient()
})
// 定时检查状态(作为备用方案)
/**
* 与 SDK 内 CallStatus 一致:idle / calling / connected
* statusChanged 只在状态「变化」时回调;晚进页面时可能已在 calling,需读 Store。
*/
function syncIncomingFromTUIStore(from: string) {
try {
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) as string
const callRole = TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE) as string
const isGroup = !!TUIStore.getData(StoreName.CALL, NAME.IS_GROUP)
const wasIncoming = hasIncomingCall.value
// 被叫振铃:calling + callee
if (callStatus === 'calling' && callRole === CallRole.CALLEE) {
hasIncomingCall.value = true
incomingCallInfo.value = { isGroup, from }
if (!wasIncoming) {
ElMessage.info('检测到待接听的来电,请点击接听')
ElNotification({
title: '来电提醒',
message: '有待接听的视频通话',
type: 'warning',
duration: 0,
position: 'top-right'
})
}
callConnected.value = false
return
}
// 已接通
if (callStatus === 'connected') {
hasIncomingCall.value = false
incomingCallInfo.value = null
callConnected.value = true
return
}
// 空闲
if (callStatus === 'idle') {
if (hasIncomingCall.value || callConnected.value) {
hasIncomingCall.value = false
callConnected.value = false
incomingCallInfo.value = null
}
}
} catch (e) {
console.warn('syncIncomingFromTUIStore:', from, e)
}
}
function onStoreCallField() {
syncIncomingFromTUIStore('watch')
}
function bindTUIStoreWatch() {
if (storeUnwatch) {
storeUnwatch()
storeUnwatch = null
}
const opts = {
[NAME.CALL_STATUS]: onStoreCallField,
[NAME.CALL_ROLE]: onStoreCallField
}
try {
TUIStore.watch(StoreName.CALL, opts)
storeUnwatch = () => {
try {
TUIStore.unwatch(StoreName.CALL, opts)
} catch {
//
}
}
} catch (e) {
console.warn('TUIStore.watch 失败:', e)
}
}
// 定时从 Store 拉状态(TIM 同步可能有延迟,前 45 秒加密轮询)
const startStatusCheck = () => {
if (statusCheckTimer.value) {
clearInterval(statusCheckTimer.value)
}
let ticks = 0
const maxTicks = 90
statusCheckTimer.value = setInterval(() => {
// 这里可以添加状态检查逻辑
console.log('定时检查 - 当前时间:', new Date().toLocaleTimeString())
}, 5000)
ticks += 1
syncIncomingFromTUIStore('poll')
if (ticks >= maxTicks) {
if (statusCheckTimer.value) {
clearInterval(statusCheckTimer.value)
statusCheckTimer.value = null
}
}
}, 500)
}
// 监听状态变化
@@ -267,10 +366,18 @@ const initPatient = async () => {
ElMessage.info('等待初始化完成...')
// 等待初始化完成
// 等待 TIM/引擎就绪(未在本页时收到的邀请会在登录后由 SDK 同步)
await new Promise(resolve => setTimeout(resolve, 2000))
initialized.value = true
await nextTick()
await new Promise((r) => setTimeout(r, 300))
// 需在 TUICallKit 挂载后订阅 Store(见模板里 v-if="initialized && ..." 的隐藏组件)
bindTUIStoreWatch()
syncIncomingFromTUIStore('post-init')
startStatusCheck()
ElMessage.success('医疗助理端初始化成功,等待来电...')
console.log('=== 医疗助理端初始化完成 ===')
@@ -278,9 +385,6 @@ const initPatient = async () => {
console.log('等待来电中...')
console.log('请在医生端发起群组通话,目标用户ID应该是:', userId)
// 启动状态检查
startStatusCheck()
} catch (error: any) {
console.error('=== 初始化失败 ===', error)
ElMessage.error(error.message || '初始化失败')
@@ -289,6 +393,29 @@ const initPatient = async () => {
}
}
/** 接听后自动关闭本地麦克风(助理端只听不说) */
async function muteLocalMicAfterAccept(): Promise<boolean> {
await nextTick()
await new Promise((r) => setTimeout(r, 400))
try {
const server = TUICallKitServer as typeof TUICallKitServer & {
closeMicrophone?: () => Promise<void>
}
if (typeof server.closeMicrophone === 'function') {
await server.closeMicrophone()
return true
}
const engine = server.getTUICallEngineInstance?.()
if (engine && typeof engine.closeMicrophone === 'function') {
await engine.closeMicrophone()
return true
}
} catch (e) {
console.warn('接听后关闭麦克风失败(可在通话条手动关麦):', e)
}
return false
}
// 接听来电
const acceptCall = async () => {
try {
@@ -297,6 +424,10 @@ const acceptCall = async () => {
ElMessage.success('已接听通话')
hasIncomingCall.value = false
callConnected.value = true // 设置通话连接状态
const muted = await muteLocalMicAfterAccept()
if (muted) {
ElMessage.info('已关闭本地麦克风')
}
} catch (error: any) {
console.error('接听失败:', error)
ElMessage.error(error.message || '接听失败')
@@ -329,6 +460,11 @@ const reset = () => {
onUnmounted(() => {
if (statusCheckTimer.value) {
clearInterval(statusCheckTimer.value)
statusCheckTimer.value = null
}
if (storeUnwatch) {
storeUnwatch()
storeUnwatch = null
}
if (initialized.value) {
@@ -370,12 +506,23 @@ onUnmounted(() => {
justify-content: center;
}
/* 手机视口:375×667(逻辑像素),圆角边框模拟机身 */
.phone-call-shell {
width: 375px;
height: 667px;
max-width: 100%;
margin: 20px auto 0;
padding: 0;
border-radius: 36px;
border: 12px solid #2c2c2e;
box-shadow:
0 0 0 2px #3a3a3c inset,
0 16px 48px rgba(0, 0, 0, 0.22);
background: #000;
box-sizing: border-box;
}
.call-container {
width: 100%;
height: 500px;
margin-top: 20px;
background: #f5f5f5;
border-radius: 8px;
display: flex;
align-items: stretch;
justify-content: stretch;
@@ -391,7 +538,7 @@ onUnmounted(() => {
.waiting-container {
width: 100%;
min-height: 500px;
min-height: 360px;
margin-top: 20px;
background: #f5f5f5;
border-radius: 8px;
@@ -400,11 +547,29 @@ onUnmounted(() => {
justify-content: center;
}
/* 离屏监听:保持真实手机宽高,利于 SDK 内部布局 */
.phone-call-kit-host {
width: 375px;
height: 667px;
overflow: hidden;
border-radius: 28px;
background: #000;
}
.phone-call-kit-host--offscreen {
position: fixed;
left: -9999px;
top: 0;
z-index: -1;
pointer-events: none;
opacity: 0;
}
/* 确保 TUICallKit 组件正确显示 */
:deep(.call-container) {
.tui-call-kit {
width: 100%;
height: 500px;
height: 100%;
}
video {
@@ -434,7 +599,7 @@ onUnmounted(() => {
:deep(.tui-call-kit-window) {
width: 100% !important;
height: 100% !important;
min-height: 500px !important;
min-height: 0 !important;
}
/* 确保视频容器撑满 */
File diff suppressed because one or more lines are too long