更新
This commit is contained in:
+44
-6
@@ -275,14 +275,52 @@ export function generateOrderQrcode(params: any) {
|
||||
|
||||
// ========== IM / 企业微信聊天记录 ==========
|
||||
|
||||
/** 腾讯云 IM 单聊漫游消息(诊单维度:患者 patient_* 与医生 doctor_*) */
|
||||
export function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }) {
|
||||
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
|
||||
export interface ImChatMessagesResponse {
|
||||
lists: any[]
|
||||
patient_im_id?: string
|
||||
patient_name?: string
|
||||
sync_error?: string
|
||||
}
|
||||
|
||||
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
|
||||
export function triggerImChatSync(data: { diagnosis_id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
|
||||
export interface ImChatSyncProgress {
|
||||
sync_token?: string
|
||||
completed: boolean
|
||||
phase?: 'checking_accounts' | 'syncing' | 'completed'
|
||||
checked_accounts?: number
|
||||
candidate_accounts?: number
|
||||
skipped_accounts?: number
|
||||
inserted: number
|
||||
processed_peers: number
|
||||
total_peers: number
|
||||
error?: string
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
/** 保留接口错误原因,并由面板决定何时提示同步完成。 */
|
||||
function unwrapImChatResponse<T>(response: { code: number; data: T; msg?: string }): T {
|
||||
if (Number(response?.code) !== 1) {
|
||||
const detail = response?.data as any
|
||||
throw new Error(response?.msg || detail?.error || detail?.message || '聊天记录请求失败')
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 当前患者所有诊单的 IM 归档消息。 */
|
||||
export async function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }): Promise<ImChatMessagesResponse> {
|
||||
const response = await request.get(
|
||||
{ url: '/tcm.diagnosis/getImChatMessages', params, timeout: 30000 },
|
||||
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
|
||||
)
|
||||
return unwrapImChatResponse<ImChatMessagesResponse>(response)
|
||||
}
|
||||
|
||||
/** 同步一页云端消息,继续传入返回的 token,直到 completed 为 true。 */
|
||||
export async function triggerImChatSync(data: { diagnosis_id: number; sync_token?: string; scope?: 'current' }): Promise<ImChatSyncProgress> {
|
||||
const response = await request.post(
|
||||
{ url: '/tcm.diagnosis/triggerImChatSync', data, timeout: 30000 },
|
||||
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
|
||||
)
|
||||
return unwrapImChatResponse<ImChatSyncProgress>(response)
|
||||
}
|
||||
|
||||
// 获取企业微信聊天记录
|
||||
|
||||
@@ -55,18 +55,19 @@
|
||||
<ImagePicker />
|
||||
<FilePicker />
|
||||
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口,避免“初始化登录未完成”错误 -->
|
||||
<AudioCallPicker v-if="isCallReady" />
|
||||
<AudioCallPicker v-if="isCallReady && canAudioCall" />
|
||||
<!-- 视频接通后 doBindCallRoom → 后端 bindCallRoom 会调 CreateCloudRecording,RecordParams.RecordMode=2(混流/合流),见 TrtcCloudRecordingService -->
|
||||
<VideoCallPicker v-if="isCallReady" />
|
||||
<VideoCallPicker v-if="isCallReady && canVideoCall" />
|
||||
<!-- 群组视频通话(基于 TUICallKitServer.calls,多人通话入口) -->
|
||||
<el-button
|
||||
v-if="isCallReady"
|
||||
v-if="isCallReady && canVideoCall"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="startGroupVideoCall"
|
||||
>
|
||||
群视频
|
||||
</el-button>
|
||||
<span v-if="appointmentType === 'text'" class="text-consultation-hint">图文问诊,不支持音视频通话</span>
|
||||
</div>
|
||||
</template>
|
||||
</MessageInput>
|
||||
@@ -138,12 +139,15 @@ import {
|
||||
bindCallRoom,
|
||||
endCall,
|
||||
getCallSignature,
|
||||
triggerImChatSync,
|
||||
startCall
|
||||
} from '@/api/tcm'
|
||||
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
||||
import { createImChatArchiveTrigger } from '@/utils/im-chat-archive-trigger'
|
||||
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { checkAppointmentOutgoingCall, registerAppointmentCallGuard } from '@/utils/appointment-call-guard'
|
||||
import {
|
||||
formatTUICallUserError,
|
||||
getTUICallPackageArrearsMessage,
|
||||
@@ -185,7 +189,17 @@ const isReady = ref(false)
|
||||
const error = ref('')
|
||||
const patientName = ref('')
|
||||
const appointmentType = ref<string | null | undefined>('video')
|
||||
const appointmentTypeLabel = computed(() => appointmentTypeDescription(appointmentType.value))
|
||||
const appointmentId = ref(0)
|
||||
const confirmedTypeLabel = ref('')
|
||||
const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointmentTypeDescription(appointmentType.value))
|
||||
const serverAllowsVideo = ref(false)
|
||||
const serverAllowsAudio = ref(false)
|
||||
const callDisabledReason = ref('正在确认挂号类型')
|
||||
const canVideoCall = computed(() => serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
|
||||
const canAudioCall = computed(() => serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
|
||||
let releaseAppointmentCallGuard: (() => void) | undefined
|
||||
let chatContextVersion = 0
|
||||
let outgoingCallType = 2
|
||||
const patientId = ref<number | null>(null)
|
||||
const diagnosisId = ref<number | null>(null)
|
||||
const loadingText = ref('正在初始化...')
|
||||
@@ -197,6 +211,68 @@ const { login, logout } = useLoginState()
|
||||
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const syncChatArchive = createImChatArchiveTrigger(triggerImChatSync, () => {
|
||||
console.warn('[chat-dialog] 聊天记录归档未完成,可在诊单聊天记录中重试同步')
|
||||
})
|
||||
let chatArchiveTimer: ReturnType<typeof setInterval> | undefined
|
||||
let chatArchiveDebounce: ReturnType<typeof setTimeout> | undefined
|
||||
function queueChatArchive() {
|
||||
const id = diagnosisId.value
|
||||
if (!visible.value || !isReady.value || !id) return
|
||||
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
|
||||
chatArchiveDebounce = setTimeout(() => {
|
||||
chatArchiveDebounce = undefined
|
||||
void syncChatArchive(id)
|
||||
}, 500)
|
||||
}
|
||||
function stopChatArchiveWatch(flush = false) {
|
||||
if (chatArchiveTimer) clearInterval(chatArchiveTimer)
|
||||
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
|
||||
chatArchiveTimer = undefined
|
||||
chatArchiveDebounce = undefined
|
||||
if (flush && diagnosisId.value && isReady.value) void syncChatArchive(diagnosisId.value)
|
||||
TUIChatEngine?.chat?.off?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
|
||||
}
|
||||
function startChatArchiveWatch() {
|
||||
stopChatArchiveWatch()
|
||||
TUIChatEngine?.chat?.on?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
|
||||
chatArchiveTimer = setInterval(queueChatArchive, 15000)
|
||||
queueChatArchive()
|
||||
}
|
||||
|
||||
function syncAppointmentCallPolicy(res: {
|
||||
appointment_type?: string | null; appointment_type_desc?: string;
|
||||
appointment_id?: number; can_video_call?: boolean; can_audio_call?: boolean; call_disabled_reason?: string
|
||||
}) {
|
||||
if ('appointment_type' in res) appointmentType.value = res.appointment_type
|
||||
confirmedTypeLabel.value = res.appointment_type_desc || ''
|
||||
serverAllowsVideo.value = res.can_video_call === true
|
||||
serverAllowsAudio.value = res.can_audio_call === true
|
||||
callDisabledReason.value = res.call_disabled_reason || '当前挂号不支持该通话方式'
|
||||
if (Number(res.appointment_id) > 0) appointmentId.value = Number(res.appointment_id)
|
||||
}
|
||||
|
||||
async function prepareAppointmentCall(callType: number) {
|
||||
const contextVersion = chatContextVersion
|
||||
const allowed = () => callType === 1 ? canAudioCall.value : canVideoCall.value
|
||||
if (!visible.value || !allowed()) {
|
||||
const message = appointmentType.value === 'text' ? '图文问诊不支持音视频通话' : callDisabledReason.value
|
||||
feedback.msgWarning(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
// 发起前重新读取服务端挂号,防止打开聊天后问诊类型已被修改。
|
||||
const res = await getCallSignature({
|
||||
patient_id: patientId.value, diagnosis_id: diagnosisId.value, appointment_id: appointmentId.value
|
||||
})
|
||||
if (!visible.value || chatContextVersion !== contextVersion) throw new Error('当前问诊已切换,请重新发起通话')
|
||||
syncAppointmentCallPolicy(res)
|
||||
if (!allowed()) {
|
||||
feedback.msgWarning(callDisabledReason.value)
|
||||
throw new Error(callDisabledReason.value)
|
||||
}
|
||||
outgoingCallType = callType
|
||||
}
|
||||
|
||||
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
|
||||
const CALL_STATUS_IDLE = 'idle'
|
||||
const CALL_STATUS_CALLING = 'calling'
|
||||
@@ -628,6 +704,7 @@ function installTUICallKitRoomHooks() {
|
||||
const original = server[methodName]
|
||||
if (typeof original !== 'function') return
|
||||
server[methodName] = async function patched(...args: unknown[]) {
|
||||
await checkAppointmentOutgoingCall(args[0])
|
||||
const result = await original.apply(server, args)
|
||||
await flushAndBindRoomAfterTUICallApi(methodName)
|
||||
return result
|
||||
@@ -695,7 +772,8 @@ async function ensureCallRecordStarted() {
|
||||
await startCall({
|
||||
diagnosis_id: diagnosisId.value,
|
||||
patient_id: patientId.value,
|
||||
call_type: 2
|
||||
appointment_id: appointmentId.value,
|
||||
call_type: outgoingCallType
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[chat-dialog] startCall 记录失败', e)
|
||||
@@ -939,6 +1017,9 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopChatArchiveWatch(true)
|
||||
chatContextVersion++
|
||||
releaseAppointmentCallGuard?.()
|
||||
clearLocalRecordingStartTimer()
|
||||
localCallRecorder.reset()
|
||||
tearDownCallRoomBinding?.()
|
||||
@@ -1001,7 +1082,8 @@ watch(() => activeConversation.value, async (newConversation) => {
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: newConversation.userProfile.userID.replace("patient_", ""),
|
||||
diagnosis_id: diagnosisId.value
|
||||
diagnosis_id: diagnosisId.value,
|
||||
appointment_id: appointmentId.value
|
||||
})
|
||||
syncLochostVodFromSignature(res)
|
||||
patientUserId.value = res.patientUserId
|
||||
@@ -1016,7 +1098,11 @@ watch(() => activeConversation.value, async (newConversation) => {
|
||||
}
|
||||
})
|
||||
|
||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentType?: string | null }) => {
|
||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentId?: number; appointmentType?: string | null }) => {
|
||||
stopChatArchiveWatch(true)
|
||||
const contextVersion = ++chatContextVersion
|
||||
releaseAppointmentCallGuard?.()
|
||||
releaseAppointmentCallGuard = registerAppointmentCallGuard(prepareAppointmentCall)
|
||||
visible.value = true
|
||||
isMinimized.value = false
|
||||
posX.value = 100
|
||||
@@ -1024,6 +1110,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
resetCallKitPositionToLeft()
|
||||
patientName.value = data.patientName
|
||||
appointmentType.value = data.appointmentType
|
||||
appointmentId.value = Number(data.appointmentId || 0)
|
||||
confirmedTypeLabel.value = ''
|
||||
serverAllowsVideo.value = false
|
||||
serverAllowsAudio.value = false
|
||||
callDisabledReason.value = '正在确认挂号类型'
|
||||
patientId.value = data.patientId
|
||||
diagnosisId.value = data.diagnosisId || null
|
||||
error.value = ''
|
||||
@@ -1036,8 +1127,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
// 获取医生的签名信息
|
||||
const res = await getCallSignature({
|
||||
patient_id: data.patientId,
|
||||
diagnosis_id: data.diagnosisId || 0
|
||||
diagnosis_id: data.diagnosisId || 0,
|
||||
appointment_id: appointmentId.value
|
||||
})
|
||||
if (contextVersion !== chatContextVersion || !visible.value) return
|
||||
syncAppointmentCallPolicy(res)
|
||||
syncLochostVodFromSignature(res)
|
||||
assistant_ids.value = res.assistant_id
|
||||
console.log('后端返回的签名数据:', res)
|
||||
@@ -1123,7 +1217,9 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
|
||||
// 等待 UIKit 初始化完成后创建并激活会话
|
||||
setTimeout(async () => {
|
||||
if (contextVersion !== chatContextVersion || !visible.value) return
|
||||
isReady.value = true
|
||||
startChatArchiveWatch()
|
||||
|
||||
// 使用 nextTick 确保组件已渲染
|
||||
await nextTick()
|
||||
@@ -1164,6 +1260,10 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
|
||||
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
|
||||
const startGroupVideoCall = async () => {
|
||||
if (!canVideoCall.value) {
|
||||
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
|
||||
return
|
||||
}
|
||||
if (!isCallReady.value) {
|
||||
feedback.msgWarning('通话服务初始化中,请稍后再试')
|
||||
return
|
||||
@@ -1180,7 +1280,8 @@ const startGroupVideoCall = async () => {
|
||||
console.warn('assistant_id 为空,尝试重新获取')
|
||||
const res = await getCallSignature({
|
||||
patient_id: patientId.value!,
|
||||
diagnosis_id: diagnosisId.value
|
||||
diagnosis_id: diagnosisId.value,
|
||||
appointment_id: appointmentId.value
|
||||
})
|
||||
syncLochostVodFromSignature(res)
|
||||
assistant_ids.value = res.assistant_id
|
||||
@@ -1360,6 +1461,9 @@ const onHeaderMouseUp = () => {
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
stopChatArchiveWatch(true)
|
||||
chatContextVersion++
|
||||
releaseAppointmentCallGuard?.()
|
||||
unbindImHangupMessageListener()
|
||||
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
|
||||
if (isCallReady.value) {
|
||||
@@ -1529,6 +1633,12 @@ defineExpose({ open })
|
||||
}
|
||||
|
||||
/* 消息工具栏样式 */
|
||||
.text-consultation-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
type OutgoingCallGuard = (callType: number) => Promise<void>
|
||||
|
||||
// 通话 SDK 为全局单例;使用当前窗口的校验器,避免首个组件闭包保留旧患者类型。
|
||||
let activeGuard: OutgoingCallGuard | undefined
|
||||
|
||||
export function registerAppointmentCallGuard(guard: OutgoingCallGuard): () => void {
|
||||
activeGuard = guard
|
||||
return () => {
|
||||
if (activeGuard === guard) activeGuard = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAppointmentOutgoingCall(params: unknown): Promise<void> {
|
||||
const guard = activeGuard
|
||||
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
|
||||
const type = params && typeof params === 'object' && 'type' in params ? Number(params.type) : 2
|
||||
await guard(type === 1 ? 1 : 2)
|
||||
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
|
||||
}
|
||||
@@ -6,3 +6,8 @@ export function appointmentTypeDescription(value?: string | null): string {
|
||||
if (value === 'phone') return '电话问诊'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
/** 图文及未知问诊方式不提供视频;只有已存在挂号的历史空值沿用视频。 */
|
||||
export function canAppointmentVideoCall(value?: string | null): boolean {
|
||||
return value == null || value.trim() === '' || value === 'video'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** 合并聊天窗口的收发事件;每次同步只接受后端读取的云端消息,不上报客户端正文。 */
|
||||
export function createImChatArchiveTrigger(
|
||||
syncPage: (params: { diagnosis_id: number; scope: 'current'; sync_token?: string }) => Promise<{
|
||||
sync_token?: string; completed: boolean; errors?: string[]
|
||||
}>,
|
||||
onError: (error: unknown) => void = () => {}
|
||||
) {
|
||||
const jobs = new Map<number, { again: boolean; promise: Promise<void> }>()
|
||||
return (diagnosisId: number): Promise<void> => {
|
||||
if (!Number.isInteger(diagnosisId) || diagnosisId <= 0) return Promise.resolve()
|
||||
const pending = jobs.get(diagnosisId)
|
||||
if (pending) {
|
||||
pending.again = true
|
||||
return pending.promise
|
||||
}
|
||||
const job = { again: false, promise: Promise.resolve() }
|
||||
jobs.set(diagnosisId, job)
|
||||
job.promise = (async () => {
|
||||
try {
|
||||
do {
|
||||
job.again = false
|
||||
let token: string | undefined
|
||||
for (;;) {
|
||||
const result = await syncPage({ diagnosis_id: diagnosisId, scope: 'current', sync_token: token })
|
||||
if (result.completed) {
|
||||
if (result.errors?.length) throw new Error(result.errors.join(';'))
|
||||
break
|
||||
}
|
||||
if (!result.sync_token) throw new Error('聊天记录同步未返回进度')
|
||||
token = result.sync_token
|
||||
}
|
||||
// 同步期间收到新消息时再追一次,覆盖新消息晚于本轮首页的情况。
|
||||
} while (job.again)
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
} finally {
|
||||
jobs.delete(diagnosisId)
|
||||
}
|
||||
})()
|
||||
return job.promise
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { reactive } from 'vue'
|
||||
import type { ImChatMessagesResponse, ImChatSyncProgress } from '@/api/tcm'
|
||||
|
||||
type NoticeKind = 'success' | 'warning' | 'error'
|
||||
type TimerHandle = ReturnType<typeof setTimeout>
|
||||
|
||||
interface HistoryDependencies {
|
||||
load: (diagnosisId: number) => Promise<ImChatMessagesResponse>
|
||||
sync: (diagnosisId: number, token?: string) => Promise<ImChatSyncProgress>
|
||||
notify?: (kind: NoticeKind, message: string) => void
|
||||
now?: () => number
|
||||
setTimer?: (callback: () => void, delay: number) => TimerHandle
|
||||
clearTimer?: (timer: TimerHandle) => void
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
interface Session {
|
||||
diagnosisId: number
|
||||
generation: number
|
||||
archiveRequest?: Promise<boolean>
|
||||
syncRequest?: Promise<void>
|
||||
}
|
||||
|
||||
/** Each visible diagnosis owns its requests and one timer; late responses cannot update another patient. */
|
||||
export function createImChatHistory(deps: HistoryDependencies) {
|
||||
const state = reactive({
|
||||
rows: [] as any[],
|
||||
patientImId: '',
|
||||
patientName: '',
|
||||
loading: false,
|
||||
syncing: false,
|
||||
hasLoaded: false,
|
||||
readError: '',
|
||||
syncError: '',
|
||||
partialErrors: [] as string[],
|
||||
inserted: 0,
|
||||
processedPeers: 0,
|
||||
totalPeers: 0,
|
||||
phase: '',
|
||||
checkedAccounts: 0,
|
||||
candidateAccounts: 0,
|
||||
skippedAccounts: 0,
|
||||
lastSyncAt: null as number | null
|
||||
})
|
||||
const now = deps.now ?? Date.now
|
||||
const setTimer = deps.setTimer ?? setTimeout
|
||||
const clearTimer = deps.clearTimer ?? clearTimeout
|
||||
let diagnosisId = 0
|
||||
let generation = 0
|
||||
let visible = deps.visible ?? true
|
||||
let disposed = false
|
||||
let session: Session | undefined
|
||||
let timer: TimerHandle | undefined
|
||||
|
||||
function current(target: Session) {
|
||||
return !disposed && visible && target === session && target.generation === generation && target.diagnosisId === diagnosisId
|
||||
}
|
||||
|
||||
function clearScheduledSync() {
|
||||
if (timer !== undefined) clearTimer(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
generation++
|
||||
clearScheduledSync()
|
||||
session = undefined
|
||||
state.loading = false
|
||||
state.syncing = false
|
||||
}
|
||||
|
||||
async function loadArchived(target: Session, freshAfterPending = false): Promise<boolean> {
|
||||
if (!current(target)) return false
|
||||
if (target.archiveRequest) {
|
||||
const result = await target.archiveRequest
|
||||
if (!freshAfterPending || !current(target)) return result
|
||||
return loadArchived(target)
|
||||
}
|
||||
state.loading = true
|
||||
const request = Promise.resolve().then(async () => {
|
||||
if (!current(target)) return false
|
||||
try {
|
||||
const response = await deps.load(target.diagnosisId)
|
||||
if (!current(target)) return false
|
||||
if (!Array.isArray(response?.lists)) throw new Error('聊天记录接口返回的数据不完整')
|
||||
state.rows = response.lists
|
||||
state.patientImId = response.patient_im_id || ''
|
||||
state.patientName = response.patient_name || ''
|
||||
state.hasLoaded = true
|
||||
state.readError = ''
|
||||
return true
|
||||
} catch (error) {
|
||||
if (current(target)) state.readError = imChatErrorMessage(error, '读取聊天记录失败')
|
||||
return false
|
||||
}
|
||||
})
|
||||
target.archiveRequest = request
|
||||
try {
|
||||
return await request
|
||||
} finally {
|
||||
if (target.archiveRequest === request) target.archiveRequest = undefined
|
||||
if (current(target)) state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSync(target: Session) {
|
||||
clearScheduledSync()
|
||||
if (!current(target)) return
|
||||
timer = setTimer(() => {
|
||||
timer = undefined
|
||||
if (current(target)) void sync(target)
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
async function sync(target: Session, manual = false): Promise<void> {
|
||||
if (!current(target)) return
|
||||
if (target.syncRequest) return target.syncRequest
|
||||
clearScheduledSync()
|
||||
state.syncing = true
|
||||
state.syncError = ''
|
||||
state.partialErrors = []
|
||||
state.inserted = 0
|
||||
state.processedPeers = 0
|
||||
state.totalPeers = 0
|
||||
state.phase = 'checking_accounts'
|
||||
state.checkedAccounts = 0
|
||||
state.candidateAccounts = 0
|
||||
state.skippedAccounts = 0
|
||||
const request = Promise.resolve().then(async () => {
|
||||
let token: string | undefined
|
||||
let pagesSinceRefresh = 0
|
||||
let lastRefreshAt = now()
|
||||
const errors = new Set<string>()
|
||||
try {
|
||||
while (current(target)) {
|
||||
const progress = await deps.sync(target.diagnosisId, token)
|
||||
if (!current(target)) return
|
||||
if (typeof progress?.completed !== 'boolean') throw new Error('同步接口未返回有效的完成状态')
|
||||
state.inserted = Number(progress.inserted) || 0
|
||||
state.processedPeers = Number(progress.processed_peers) || 0
|
||||
state.totalPeers = Number(progress.total_peers) || 0
|
||||
state.phase = progress.phase || 'syncing'
|
||||
state.checkedAccounts = Number(progress.checked_accounts) || 0
|
||||
state.candidateAccounts = Number(progress.candidate_accounts) || 0
|
||||
state.skippedAccounts = Number(progress.skipped_accounts) || 0
|
||||
for (const error of [progress.error, ...(progress.errors || [])]) {
|
||||
if (typeof error === 'string' && error.trim()) errors.add(error)
|
||||
}
|
||||
state.partialErrors = [...errors]
|
||||
if (progress.completed) {
|
||||
const refreshed = await loadArchived(target, true)
|
||||
if (!current(target)) return
|
||||
state.lastSyncAt = now()
|
||||
if (manual) {
|
||||
if (errors.size) deps.notify?.('warning', `同步完成,部分会话失败:${[...errors].join(';')}`)
|
||||
else if (!refreshed) deps.notify?.('warning', `同步已完成,但读取聊天记录失败:${state.readError}`)
|
||||
else deps.notify?.('success', `聊天记录已更新,本次新增 ${state.inserted} 条`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!progress.sync_token) throw new Error('同步接口未返回继续同步所需的进度标识')
|
||||
token = progress.sync_token
|
||||
pagesSinceRefresh++
|
||||
if (pagesSinceRefresh >= 3 || now() - lastRefreshAt >= 2000) {
|
||||
await loadArchived(target, true)
|
||||
pagesSinceRefresh = 0
|
||||
lastRefreshAt = now()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!current(target)) return
|
||||
state.syncError = imChatErrorMessage(error, '同步聊天记录失败')
|
||||
if (manual) deps.notify?.('error', state.syncError)
|
||||
}
|
||||
})
|
||||
target.syncRequest = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (target.syncRequest === request) target.syncRequest = undefined
|
||||
if (current(target)) {
|
||||
state.syncing = false
|
||||
scheduleSync(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (disposed || !visible || !diagnosisId) return
|
||||
const target: Session = { diagnosisId, generation }
|
||||
session = target
|
||||
void loadArchived(target)
|
||||
void sync(target)
|
||||
}
|
||||
|
||||
function setDiagnosis(value: number) {
|
||||
const nextId = Number(value) > 0 ? Number(value) : 0
|
||||
if (disposed || (nextId === diagnosisId && session)) return
|
||||
invalidate()
|
||||
diagnosisId = nextId
|
||||
state.rows = []
|
||||
state.patientImId = ''
|
||||
state.patientName = ''
|
||||
state.hasLoaded = false
|
||||
state.readError = ''
|
||||
state.syncError = ''
|
||||
state.partialErrors = []
|
||||
state.inserted = 0
|
||||
state.processedPeers = 0
|
||||
state.totalPeers = 0
|
||||
state.phase = ''
|
||||
state.checkedAccounts = 0
|
||||
state.candidateAccounts = 0
|
||||
state.skippedAccounts = 0
|
||||
state.lastSyncAt = null
|
||||
start()
|
||||
}
|
||||
|
||||
function setVisible(value: boolean) {
|
||||
if (disposed || value === visible) return
|
||||
visible = value
|
||||
invalidate()
|
||||
if (visible) start()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
disposed = true
|
||||
invalidate()
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
setDiagnosis,
|
||||
setVisible,
|
||||
dispose,
|
||||
reload: () => session ? loadArchived(session) : Promise.resolve(false),
|
||||
sync: () => session ? sync(session, true) : Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export function imChatErrorMessage(error: unknown, fallback: string): string {
|
||||
if (typeof error === 'string' && error.trim()) return error
|
||||
const detail = error as any
|
||||
return detail?.response?.data?.msg || detail?.msg || detail?.error || detail?.message || fallback
|
||||
}
|
||||
@@ -42,6 +42,13 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="挂号类型">
|
||||
<el-select v-model="queryParams.appointment_type" placeholder="全部" clearable class="!w-[140px]">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="视频问诊" value="video" />
|
||||
<el-option label="图文问诊" value="text" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
|
||||
<el-option label="已预约" :value="1" />
|
||||
@@ -115,7 +122,9 @@
|
||||
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
|
||||
<el-table-column label="挂号类型" width="110">
|
||||
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
|
||||
@@ -178,7 +187,7 @@
|
||||
<el-radio-button label="all">全天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="问诊类型" required>
|
||||
<el-form-item label="挂号类型" required>
|
||||
<el-select v-model="editForm.appointment_type" class="!w-full">
|
||||
<el-option label="视频问诊" value="video" />
|
||||
<el-option label="图文问诊" value="text" />
|
||||
@@ -292,6 +301,7 @@
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
|
||||
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||
import { getAssistants } from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
@@ -302,6 +312,7 @@ const queryParams = reactive({
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
appointment_type: '',
|
||||
status: '' as number | '',
|
||||
channel_source: '' as string
|
||||
})
|
||||
@@ -312,6 +323,7 @@ const queryInit = {
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
appointment_type: '',
|
||||
status: '' as number | '',
|
||||
channel_source: ''
|
||||
}
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约时间" min-width="165">
|
||||
<el-table-column label="挂号类型" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.appointment_id) > 0 ? appointmentTypeDescription(row.appointment_type) : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约时间" min-width="165">
|
||||
<template #default="{ row }">
|
||||
<span :class="['appointment-time', { empty: !row.appointment_time_text }]">
|
||||
{{ row.appointment_time_text || '暂无预约' }}
|
||||
@@ -324,7 +329,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@@ -451,12 +451,14 @@ const handleCall = async (row: QueueRow) => {
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
appointment_id: Number(row.id),
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
appointmentId: Number(row.id),
|
||||
appointmentType: row.appointment_type,
|
||||
signatureData: res
|
||||
})
|
||||
|
||||
@@ -163,7 +163,9 @@
|
||||
</el-table-column>
|
||||
|
||||
|
||||
<!-- <el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip /> -->
|
||||
<el-table-column label="面诊类型" width="110">
|
||||
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="确认诊单" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
@@ -219,8 +221,9 @@
|
||||
</el-button>
|
||||
|
||||
<template v-if="row.status !== 3">
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/videoQr']"
|
||||
<el-button
|
||||
v-if="canAppointmentVideoCall(row.appointment_type)"
|
||||
v-perms="['tcm.diagnosis/videoQr']"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
@@ -237,7 +240,7 @@
|
||||
size="small"
|
||||
@click="handleChat(row)"
|
||||
>
|
||||
通话
|
||||
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['doctor.appointment/complete']"
|
||||
@@ -340,8 +343,8 @@
|
||||
<span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">预约类型</span>
|
||||
<span class="detail-value">{{ detailData.appointment_type_desc }}</span>
|
||||
<span class="detail-label">面诊类型</span>
|
||||
<span class="detail-value">{{ appointmentTypeDescription(detailData.appointment_type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,7 +558,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
@@ -1012,7 +1016,8 @@ const handleChat = async (row: any) => {
|
||||
// 获取聊天签名信息
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
diagnosis_id: diagnosisId,
|
||||
appointment_id: Number(row.id)
|
||||
})
|
||||
|
||||
console.log('获取聊天签名成功:', res)
|
||||
@@ -1022,6 +1027,7 @@ const handleChat = async (row: any) => {
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
appointmentId: Number(row.id),
|
||||
appointmentType: row.appointment_type,
|
||||
signatureData: res // 传入签名数据
|
||||
})
|
||||
@@ -1032,7 +1038,11 @@ const handleChat = async (row: any) => {
|
||||
}
|
||||
|
||||
// 生成小程序二维码(跳转小程序路径:pages/login/login)
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!canAppointmentVideoCall(row.appointment_type)) {
|
||||
feedback.msgWarning('仅视频问诊可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
|
||||
@@ -108,8 +108,12 @@
|
||||
<span class="apt-h5-row-key">预约</span>
|
||||
<span class="apt-h5-row-val">{{ row.appointment_date || '-' }} {{ row.appointment_time || '' }}</span>
|
||||
</div>
|
||||
<div class="apt-h5-row">
|
||||
<span class="apt-h5-row-key">医生</span>
|
||||
<div class="apt-h5-row">
|
||||
<span class="apt-h5-row-key">面诊类型</span>
|
||||
<span class="apt-h5-row-val">{{ appointmentTypeDescription(row.appointment_type) }}</span>
|
||||
</div>
|
||||
<div class="apt-h5-row">
|
||||
<span class="apt-h5-row-key">医生</span>
|
||||
<span class="apt-h5-row-val">{{ row.doctor_name || '-' }}</span>
|
||||
</div>
|
||||
<div class="apt-h5-row">
|
||||
@@ -147,7 +151,7 @@
|
||||
:icon="Phone"
|
||||
@click="handleChat(row)"
|
||||
>
|
||||
通话
|
||||
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasPermission(['tcm.diagnosis/kaifang'])"
|
||||
@@ -179,7 +183,7 @@
|
||||
>
|
||||
<div v-if="moreSheetRow" class="apt-h5-actions">
|
||||
<button
|
||||
v-if="moreSheetRow.status !== 3 && hasPermission(['tcm.diagnosis/videoQr'])"
|
||||
v-if="moreSheetRow.status !== 3 && canAppointmentVideoCall(moreSheetRow.appointment_type) && hasPermission(['tcm.diagnosis/videoQr'])"
|
||||
class="apt-h5-action"
|
||||
@click="runAction('videoQr')"
|
||||
>
|
||||
@@ -236,7 +240,7 @@
|
||||
<div><span>医生</span><strong>{{ detailData.doctor_name || '-' }}</strong></div>
|
||||
<div><span>预约时间</span><strong>{{ detailData.appointment_date || '-' }} {{ detailData.appointment_time || '' }}</strong></div>
|
||||
<div><span>时段</span><strong>{{ detailData.period === 'morning' ? '上午' : detailData.period === 'afternoon' ? '下午' : '-' }}</strong></div>
|
||||
<div><span>预约类型</span><strong>{{ detailData.appointment_type_desc || '-' }}</strong></div>
|
||||
<div><span>面诊类型</span><strong>{{ appointmentTypeDescription(detailData.appointment_type) }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -336,7 +340,7 @@
|
||||
plain
|
||||
@click="handleChat(detailActionRow)"
|
||||
>
|
||||
通话
|
||||
{{ canAppointmentVideoCall(detailActionRow.appointment_type) ? '通话' : '图文沟通' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detailActionRow.status !== 3 && hasPermission(['doctor.appointment/complete'])"
|
||||
@@ -407,7 +411,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmAppointmentH5">
|
||||
<script setup lang="ts" name="tcmAppointmentH5">
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
|
||||
import { getWeappConfig } from '@/api/channel/weapp'
|
||||
import {
|
||||
@@ -656,12 +661,14 @@ const handleChat = async (row: any) => {
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
diagnosis_id: diagnosisId,
|
||||
appointment_id: Number(row.id)
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
appointmentId: Number(row.id),
|
||||
appointmentType: row.appointment_type,
|
||||
signatureData: res
|
||||
})
|
||||
@@ -777,7 +784,11 @@ const qrcodeDialogVisible = ref(false)
|
||||
const qrcodeLoading = ref(false)
|
||||
const qrcodeUrl = ref('')
|
||||
const currentQRCodeRow = ref<any>(null)
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!canAppointmentVideoCall(row.appointment_type)) {
|
||||
feedback.msgWarning('仅视频问诊可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
@@ -1184,8 +1195,9 @@ onUnmounted(() => {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.apt-h5-row-key {
|
||||
flex: 0 0 38px;
|
||||
.apt-h5-row-key {
|
||||
flex: 0 0 60px;
|
||||
white-space: nowrap;
|
||||
color: #8a94a6;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 预约类型 -->
|
||||
<el-form-item label="预约类型:">
|
||||
<!-- 挂号类型 -->
|
||||
<el-form-item label="挂号类型:">
|
||||
<el-radio-group v-model="form.appointmentType">
|
||||
<el-radio value="video">视频问诊</el-radio>
|
||||
<el-radio value="text">图文问诊</el-radio>
|
||||
|
||||
@@ -2,29 +2,47 @@
|
||||
<div class="im-chat-record-panel">
|
||||
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
|
||||
<p class="panel-tip">
|
||||
展示单聊记录:已合并患者 与
|
||||
展示患者所有诊单中与
|
||||
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
||||
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。
|
||||
打开页面后自动加载并同步最新消息,页面可见期间每 30 秒继续检查更新。
|
||||
</p>
|
||||
</el-alert>
|
||||
|
||||
<div class="toolbar mb-3">
|
||||
<el-button type="primary" link :loading="syncing" @click="triggerSync">
|
||||
<el-icon class="mr-1"><Promotion /></el-icon>
|
||||
同步最新(后台异步)
|
||||
同步最新
|
||||
</el-button>
|
||||
<el-button type="primary" link :loading="loading" @click="reloadArchived">
|
||||
<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>
|
||||
|
||||
<p v-if="syncing" class="sync-status" role="status" aria-live="polite">
|
||||
<template v-if="history.phase === 'checking_accounts'">
|
||||
正在核验聊天账号<span v-if="history.candidateAccounts">,{{ history.checkedAccounts }} / {{ history.candidateAccounts }}</span>
|
||||
</template>
|
||||
<template v-else>正在同步聊天记录<span v-if="history.totalPeers">,已处理 {{ history.processedPeers }} / {{ history.totalPeers }} 个会话</span>,新增 {{ history.inserted }} 条</template>
|
||||
</p>
|
||||
<p v-else-if="history.lastSyncAt" class="sync-status">上次同步:{{ formatTime(history.lastSyncAt) }}</p>
|
||||
<p v-if="history.skippedAccounts" class="sync-status">已跳过 {{ history.skippedAccounts }} 个未注册或已失效的聊天账号,已有归档不受影响。</p>
|
||||
<el-alert v-if="history.readError" type="error" show-icon :closable="false" class="mb-3" :title="`读取聊天记录失败:${history.readError}`" />
|
||||
<el-alert v-if="history.syncError" type="error" show-icon :closable="false" class="mb-3" :title="`同步失败:${history.syncError}`" />
|
||||
<el-alert v-if="history.partialErrors.length" type="warning" show-icon :closable="false" class="mb-3" title="聊天记录未完全同步">
|
||||
<p v-for="error in history.partialErrors.slice(0, 3)" :key="error" class="panel-tip">{{ error }}</p>
|
||||
<details v-if="history.partialErrors.length > 3">
|
||||
<summary>查看其余 {{ history.partialErrors.length - 3 }} 项同步问题</summary>
|
||||
<p v-for="error in history.partialErrors.slice(3)" :key="error" class="panel-tip">{{ error }}</p>
|
||||
</details>
|
||||
</el-alert>
|
||||
|
||||
<div v-loading="loading && !rows.length" class="chat-wrap">
|
||||
<template v-if="rows.length">
|
||||
<div class="chat-list">
|
||||
<div
|
||||
v-for="item in rows"
|
||||
:key="item.raw.msg_id"
|
||||
:key="`${item.raw.from_account || ''}-${item.raw.to_account || ''}-${item.raw.msg_id}`"
|
||||
class="chat-row"
|
||||
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
|
||||
>
|
||||
@@ -64,31 +82,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
|
||||
<el-empty v-else-if="!loading" :description="emptyDescription" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch, computed } from 'vue'
|
||||
import { watch, computed, toRefs, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Refresh, Promotion } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
|
||||
import type { FriendlyParse } from '@/utils/im-business-message-parse'
|
||||
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
|
||||
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
|
||||
import { createImChatHistory } from '@/utils/im-chat-history'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const rawRows = shallowRef<any[]>([])
|
||||
const patientImId = ref('')
|
||||
const patientName = ref('')
|
||||
|
||||
const patientImHint = computed(() => patientImId.value || 'patient_*')
|
||||
const documentVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
||||
const controller = createImChatHistory({
|
||||
load: (diagnosisId) => getImChatMessages({ diagnosis_id: diagnosisId, only_archived: 1 }),
|
||||
sync: (diagnosisId, syncToken) => triggerImChatSync({ diagnosis_id: diagnosisId, ...(syncToken ? { sync_token: syncToken } : {}) }),
|
||||
notify: (kind, message) => ElMessage[kind](message),
|
||||
visible: documentVisible()
|
||||
})
|
||||
const history = controller.state
|
||||
const { loading, syncing, rows: rawRows, patientName } = toRefs(history)
|
||||
const emptyDescription = computed(() => {
|
||||
if (history.readError) return '聊天记录读取失败,请重试'
|
||||
if (history.syncError || history.partialErrors.length) return '暂未读取到聊天记录,请重试同步'
|
||||
if (syncing.value) return '正在同步聊天记录…'
|
||||
return history.hasLoaded ? '暂无聊天记录' : '正在加载聊天记录…'
|
||||
})
|
||||
|
||||
interface EnrichedRow {
|
||||
raw: any
|
||||
@@ -109,16 +136,21 @@ const MSG_TYPE_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const rows = computed<EnrichedRow[]>(() =>
|
||||
rawRows.value.map((row) => {
|
||||
const fr = parseImFriendly(row)
|
||||
let tag = ''
|
||||
if (fr?.tag) {
|
||||
tag = fr.tag
|
||||
} else {
|
||||
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
|
||||
}
|
||||
return { raw: row, friendly: fr, tag }
|
||||
})
|
||||
rawRows.value.flatMap((row) => {
|
||||
const messages = row.msg_type === 'composite' && Array.isArray(row.parts)
|
||||
? row.parts.map((part: any, index: number) => ({ ...row, ...part, msg_id: `${row.msg_id}:${index}` }))
|
||||
: [row]
|
||||
return messages.map((row: any) => {
|
||||
const fr = parseImFriendly(row)
|
||||
let tag = ''
|
||||
if (fr?.tag) {
|
||||
tag = fr.tag
|
||||
} else {
|
||||
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
|
||||
}
|
||||
return { raw: row, friendly: fr, tag }
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
function formatTime(ts: number | undefined) {
|
||||
@@ -145,54 +177,37 @@ function senderLabel(row: any) {
|
||||
return patientName.value ? `患者(${patientName.value})` : '患者'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.diagnosisId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = (await getImChatMessages({
|
||||
diagnosis_id: props.diagnosisId,
|
||||
only_archived: 1
|
||||
})) as {
|
||||
lists?: any[]
|
||||
patient_im_id?: string
|
||||
patient_name?: string
|
||||
}
|
||||
rawRows.value = res?.lists || []
|
||||
patientImId.value = res?.patient_im_id || ''
|
||||
patientName.value = res?.patient_name || ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
rawRows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reloadArchived() {
|
||||
load()
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
if (!props.diagnosisId) return
|
||||
syncing.value = true
|
||||
try {
|
||||
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
|
||||
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('发起同步失败')
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
function reloadArchived() {
|
||||
return controller.reload()
|
||||
}
|
||||
|
||||
function triggerSync() {
|
||||
return controller.sync()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
load()
|
||||
},
|
||||
(diagnosisId) => controller.setDiagnosis(diagnosisId),
|
||||
{ immediate: true }
|
||||
)
|
||||
)
|
||||
|
||||
let activated = true
|
||||
function updateVisibility() {
|
||||
controller.setVisible(activated && documentVisible())
|
||||
}
|
||||
onMounted(() => document.addEventListener('visibilitychange', updateVisibility))
|
||||
onActivated(() => {
|
||||
activated = true
|
||||
updateVisibility()
|
||||
})
|
||||
onDeactivated(() => {
|
||||
activated = false
|
||||
updateVisibility()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', updateVisibility)
|
||||
controller.dispose()
|
||||
})
|
||||
|
||||
defineExpose({ refresh: reloadArchived })
|
||||
</script>
|
||||
@@ -209,9 +224,15 @@ defineExpose({ refresh: reloadArchived })
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sync-status {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.chat-wrap {
|
||||
min-height: 120px;
|
||||
|
||||
@@ -259,7 +259,20 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="确认" width="88" align="center">
|
||||
<el-table-column label="挂号类型" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<template v-if="appointmentRows(row).length">
|
||||
<div v-for="apt in appointmentRows(row)" :key="apt.id || `${apt.doctor_id}-${apt.time_text}`" class="mb-2">
|
||||
<div>{{ appointmentTypeDescription(apt.appointment_type) }}</div>
|
||||
<div v-if="appointmentRows(row).length > 1" class="text-xs text-gray-500">
|
||||
{{ apt.doctor_name || '—' }} · {{ apt.time_text || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="apt-none">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="确认" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="isDiagnosisConfirmed(row)" class="status-confirmed">已确认</span>
|
||||
<span v-else class="status-unconfirmed">未确认</span>
|
||||
@@ -388,7 +401,7 @@
|
||||
<el-icon><Remove /></el-icon>取消指派
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && hasActiveAppointment(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
|
||||
command="cancelApt"
|
||||
@@ -731,7 +744,8 @@ import { usePaging } from '@/hooks/usePaging'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { getWeappConfig } from '@/api/channel/weapp'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -1377,10 +1391,11 @@ type VideoCallHint = {
|
||||
end_time?: number
|
||||
}
|
||||
|
||||
const canWatchCallEntry = (row: any) =>
|
||||
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
|
||||
const canWatchCallEntry = (row: any) =>
|
||||
isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
|
||||
|
||||
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
|
||||
const watchCallState = (row: any) =>
|
||||
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
|
||||
|
||||
const watchCallLiveTooltip = (row: any) => {
|
||||
const h = row.video_call_hint as VideoCallHint | undefined
|
||||
@@ -1404,7 +1419,11 @@ const watchCallEnterTooltip = (row: any) => {
|
||||
return watchCallLiveTooltip(row)
|
||||
}
|
||||
|
||||
const onWatchCallEntryClick = (row: any) => {
|
||||
const onWatchCallEntryClick = (row: any) => {
|
||||
if (!canWatchCallEntry(row)) {
|
||||
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
|
||||
return
|
||||
}
|
||||
if (watchCallState(row) !== 'live') {
|
||||
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
|
||||
return
|
||||
@@ -1716,7 +1735,7 @@ const handleRowAction = (cmd: string, row: any) => {
|
||||
case 'cancelAssign': handleCancelAssign(row); break
|
||||
case 'videoQr':
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
|
||||
return
|
||||
}
|
||||
handleVideoQRCode(row)
|
||||
@@ -1753,7 +1772,8 @@ const appointmentRows = (row: any) => {
|
||||
status: row.appointment_status,
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
doctor_name: row.appointment_doctor_name,
|
||||
time_text: row.appointment_time_text
|
||||
time_text: row.appointment_time_text,
|
||||
appointment_type: row.appointment_type
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1779,12 +1799,14 @@ const appointmentCellClasses = (row: any) => {
|
||||
* 找到当前可用的已预约挂号。
|
||||
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
const activeAppointment = (row: any, videoOnly = false) =>
|
||||
appointmentRows(row).find((apt: any) =>
|
||||
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
|
||||
) ?? null
|
||||
|
||||
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
const activeAppointmentRow = (row: any, videoOnly = false) => {
|
||||
const apt = activeAppointment(row, videoOnly)
|
||||
if (!apt) return null
|
||||
|
||||
return {
|
||||
@@ -1794,12 +1816,14 @@ const activeAppointmentRow = (row: any) => {
|
||||
appointment_status: apt.status,
|
||||
appointment_doctor_id: apt.doctor_id,
|
||||
appointment_doctor_name: apt.doctor_name,
|
||||
appointment_time_text: apt.time_text
|
||||
appointment_time_text: apt.time_text,
|
||||
appointment_type: apt.appointment_type
|
||||
}
|
||||
}
|
||||
|
||||
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
/** 诊单二维码适用于所有已预约挂号;视频入口仅适用于视频问诊。 */
|
||||
const hasActiveAppointment = (row: any) => !!activeAppointment(row)
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
|
||||
|
||||
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
@@ -1983,11 +2007,11 @@ const submitFillIdCard = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
|
||||
// 生成视频二维码(跳转登录页)- 仅已预约的视频问诊可生成
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
const qrcodeRow = activeAppointmentRow(row, true)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) {
|
||||
|
||||
@@ -155,13 +155,15 @@
|
||||
>
|
||||
<span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span>
|
||||
<span class="dh5-apt-doctor">{{ apt.doctor_name || '-' }}</span>
|
||||
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
|
||||
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
|
||||
<span class="dh5-apt-type">挂号类型:{{ appointmentTypeDescription(apt.appointment_type) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span>
|
||||
<span class="dh5-apt-doctor">{{ row.appointment_doctor_name || '-' }}</span>
|
||||
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
|
||||
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
|
||||
<span class="dh5-apt-type">挂号类型:{{ appointmentTypeDescription(row.appointment_type) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="dh5-apt-none">未挂号</span>
|
||||
@@ -299,7 +301,7 @@
|
||||
<span>视频二维码</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isAppointmentActiveForVideo(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
|
||||
v-if="hasActiveAppointment(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
|
||||
class="dh5-action"
|
||||
@click="runAction('confirmQr')"
|
||||
>
|
||||
@@ -584,7 +586,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmDiagnosisH5">
|
||||
<script setup lang="ts" name="tcmDiagnosisH5">
|
||||
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
|
||||
import {
|
||||
tcmDiagnosisLists,
|
||||
tcmDiagnosisDelete,
|
||||
@@ -924,7 +927,8 @@ const appointmentRows = (row: any) => {
|
||||
status: row.appointment_status,
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
doctor_name: row.appointment_doctor_name,
|
||||
time_text: row.appointment_time_text
|
||||
time_text: row.appointment_time_text,
|
||||
appointment_type: row.appointment_type
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -949,11 +953,13 @@ const appointmentRowClass = (row: any) => {
|
||||
/**
|
||||
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号,二维码应使用明细里的有效预约。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
const activeAppointment = (row: any, videoOnly = false) =>
|
||||
appointmentRows(row).find((apt: any) =>
|
||||
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
|
||||
) ?? null
|
||||
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
const activeAppointmentRow = (row: any, videoOnly = false) => {
|
||||
const apt = activeAppointment(row, videoOnly)
|
||||
if (!apt) return null
|
||||
|
||||
return {
|
||||
@@ -963,11 +969,13 @@ const activeAppointmentRow = (row: any) => {
|
||||
appointment_status: apt.status,
|
||||
appointment_doctor_id: apt.doctor_id,
|
||||
appointment_doctor_name: apt.doctor_name,
|
||||
appointment_time_text: apt.time_text
|
||||
appointment_time_text: apt.time_text,
|
||||
appointment_type: apt.appointment_type
|
||||
}
|
||||
}
|
||||
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
const hasActiveAppointment = (row: any) => !!activeAppointment(row)
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
|
||||
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
@@ -981,9 +989,10 @@ const isAssignedAssistant = (row: any) => {
|
||||
if (aid === null || aid === undefined || aid === '') return false
|
||||
return Number(aid) === Number(userStore.userInfo?.id)
|
||||
}
|
||||
const canWatchCallEntry = (row: any) =>
|
||||
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
|
||||
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
|
||||
const canWatchCallEntry = (row: any) =>
|
||||
isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
|
||||
const watchCallState = (row: any) =>
|
||||
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
|
||||
const watchCallShowEnterButton = (row: any) => {
|
||||
const st = watchCallState(row)
|
||||
return st === 'live' || st === 'pending_room'
|
||||
@@ -1015,7 +1024,11 @@ const watchCallVisibleForRow = (row: any) => {
|
||||
}
|
||||
const watchCallDialogVisible = ref(false)
|
||||
const watchCallDiagnosisId = ref(0)
|
||||
const onWatchCallEntryClick = (row: any) => {
|
||||
const onWatchCallEntryClick = (row: any) => {
|
||||
if (!canWatchCallEntry(row)) {
|
||||
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
|
||||
return
|
||||
}
|
||||
if (watchCallState(row) !== 'live') {
|
||||
feedback.msgWarning('医生未接通或未同步房间号,请稍后再试')
|
||||
return
|
||||
@@ -1087,7 +1100,7 @@ const runAction = (cmd: string) => {
|
||||
case 'assign': handleSingleAssign(row); break
|
||||
case 'videoQr':
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
|
||||
return
|
||||
}
|
||||
handleVideoQRCode(row); break
|
||||
@@ -1262,9 +1275,9 @@ const qrcodeAppointmentTimeText = computed(() => {
|
||||
})
|
||||
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
const qrcodeRow = activeAppointmentRow(row, true)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
||||
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码'); return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'video'
|
||||
@@ -1805,8 +1818,9 @@ $dh5-card-bg: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.dh5-apt-item {
|
||||
display: flex;
|
||||
.dh5-apt-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
@@ -1830,11 +1844,17 @@ $dh5-card-bg: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dh5-apt-time {
|
||||
.dh5-apt-time {
|
||||
color: $dh5-text-mute;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dh5-apt-type {
|
||||
flex-basis: 100%;
|
||||
color: $dh5-text-mute;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dh5-apt-none {
|
||||
color: $dh5-warn;
|
||||
|
||||
Reference in New Issue
Block a user