This commit is contained in:
Your Name
2026-08-12 17:18:56 +08:00
parent 28cd110dae
commit f48a66b611
24 changed files with 3095 additions and 233 deletions
@@ -145,7 +145,7 @@
<div class="panel-heading panel-heading--table"> <div class="panel-heading panel-heading--table">
<div> <div>
<h2>明细数据列表</h2> <h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=区间有效加粉按员工+客户去重须会话同意剔除已删客户挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约预约接诊=接诊诊单/面诊面诊接诊率=面诊/挂号接诊率=接诊诊单/加粉</p> <p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重须会话同意剔除已删客户与继承客户挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div> </div>
<span>{{ dashboard.rows.length }} 个顶层节点</span> <span>{{ dashboard.rows.length }} 个顶层节点</span>
</div> </div>
@@ -194,13 +194,13 @@
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="面诊率" min-width="96" align="right"> <el-table-column label="面诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column>
<el-table-column label="预约率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="预约接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right"> <el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="接诊率" min-width="96" align="right"> <el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
@@ -330,7 +330,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' } { label: '自定义', value: 'custom' }
] ]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [ const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户' }, { key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户与继承客户' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' }, { key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' }, { key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' }, { key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -0,0 +1,556 @@
<template>
<section class="embedded-panel" v-loading="loading">
<div class="panel-toolbar">
<div>
<h2>医生排班</h2>
<p>查看近 7 日出诊医生及可约时段与医生排班管理同口径</p>
</div>
<div class="toolbar-actions">
<el-button
:icon="Refresh"
:loading="refreshing"
:disabled="!selectedDoctorId || !form.date"
@click="handleRefreshSlots"
>
刷新时段
</el-button>
</div>
</div>
<el-form :model="form" label-width="88px" 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>
<el-empty
v-if="!loading && doctorList.length === 0"
description="暂无可用医生"
:image-size="64"
/>
</div>
</el-form-item>
<el-form-item label="排班时间">
<el-empty v-if="!selectedDoctorId" description="请先选择医生" :image-size="72" />
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="72"
/>
<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' : ''"
class="date-button"
@click="selectDate(dateOption.date)"
>
{{ dateOption.label }}
</el-button>
</div>
<div v-if="form.date" class="time-slots-container">
<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 ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
</div>
</div>
</div>
<el-empty
v-if="filteredTimeSlots.length === 0"
description="当前日期暂无可展示时段"
:image-size="64"
/>
</div>
</div>
</el-form-item>
</el-form>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } 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
hasAppointment: 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: ''
})
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
}
options.push({
date,
label: `${dateObj.format('MM月DD日')} (${weekDays[dateObj.day()]})`
})
}
return options
})
const filteredTimeSlots = computed(() => {
if (!form.date || timeSlots.value.length === 0) {
return []
}
const today = dayjs().format('YYYY-MM-DD')
if (form.date !== today) {
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
})
})
async function loadDoctors() {
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
}
}
async function handleDoctorChange() {
form.selectedTime = ''
form.date = ''
timeSlots.value = []
doctorRosterDates.value = []
if (selectedDoctorId.value) {
await loadDoctorRoster()
}
}
async function loadDoctorRoster() {
if (!selectedDoctorId.value) {
return
}
try {
loading.value = true
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
}
}
function selectDate(date: string) {
form.date = date
form.selectedTime = ''
if (selectedDoctorId.value) {
loadTimeSlots()
}
}
async function loadTimeSlots(silent = false) {
if (!selectedDoctorId.value || !form.date) {
return
}
if (isLoadingSlots) {
return
}
try {
isLoadingSlots = true
if (!silent) {
loading.value = true
}
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,
hasAppointment: Boolean(slot.has_appointment ?? slot.available === false),
quota: slot.available ? 1 : 0
}))
} catch (error) {
console.error('加载时间段失败:', error)
if (!silent) {
feedback.msgError('加载时间段失败')
}
timeSlots.value = []
} finally {
isLoadingSlots = false
if (!silent) {
loading.value = false
}
}
}
function selectTimeSlot(slot: TimeSlot) {
if (!slot.available) {
feedback.msgWarning('该时间段不可预约')
return
}
form.selectedTime = slot.time
}
async function handleRefreshSlots() {
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?.available) {
form.selectedTime = previousSelection
}
}
feedback.msgSuccess('刷新成功')
} catch (error) {
console.error('刷新失败:', error)
feedback.msgError('刷新失败,请重试')
} finally {
refreshing.value = false
}
}
function startAutoRefresh() {
stopAutoRefresh()
autoRefreshTimer = window.setInterval(() => {
if (selectedDoctorId.value && form.date) {
loadTimeSlots(true)
}
}, 5000)
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
}
async function refresh() {
if (selectedDoctorId.value && form.date) {
await loadTimeSlots()
return
}
await loadDoctors()
}
defineExpose({
refresh,
loading
})
onMounted(() => {
loadDoctors()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped lang="scss">
.embedded-panel {
padding-top: 4px;
}
.panel-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f2a37;
}
p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.paiban-form {
:deep(.el-form-item__label) {
font-weight: 500;
}
}
.doctor-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
.doctor-radio {
margin-right: 0;
}
}
.paiban-time-container {
width: 100%;
}
.date-selector {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
.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-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: 0 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: 0 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>
@@ -17,6 +17,7 @@
<el-tab-pane label="患者列表" name="patients" /> <el-tab-pane label="患者列表" name="patients" />
<el-tab-pane label="订单管理" name="orders" /> <el-tab-pane label="订单管理" name="orders" />
<el-tab-pane label="面诊进度" name="progress" /> <el-tab-pane label="面诊进度" name="progress" />
<el-tab-pane label="医生排班" name="paiban" />
</el-tabs> </el-tabs>
<div v-show="activeWorkspace === 'patients'"> <div v-show="activeWorkspace === 'patients'">
@@ -207,6 +208,10 @@
ref="progressPanelRef" ref="progressPanelRef"
@open-diagnosis="openDiagnosis" @open-diagnosis="openDiagnosis"
/> />
<paiban-panel
v-if="activeWorkspace === 'paiban'"
ref="paibanPanelRef"
/>
</el-card> </el-card>
<edit-popup ref="editRef" @success="refreshPage" /> <edit-popup ref="editRef" @success="refreshPage" />
@@ -342,6 +347,7 @@ const EditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.
const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/appointment.vue')) const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/appointment.vue'))
const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue')) const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue'))
const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue')) const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue'))
const PaibanPanel = defineAsyncComponent(() => import('./components/PaibanPanel.vue'))
type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed' type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom' type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
@@ -355,6 +361,7 @@ const editRef = ref<any>()
const appointmentRef = ref<any>() const appointmentRef = ref<any>()
const orderPanelRef = ref<any>() const orderPanelRef = ref<any>()
const progressPanelRef = ref<any>() const progressPanelRef = ref<any>()
const paibanPanelRef = ref<any>()
const qrcodeDialogVisible = ref(false) const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false) const qrcodeLoading = ref(false)
const qrcodeUrl = ref('') const qrcodeUrl = ref('')
@@ -423,6 +430,7 @@ const canFillIdCard = computed(() => hasPermission(['tcm.diagnosis/edit']))
const workspaceLoading = computed(() => { const workspaceLoading = computed(() => {
if (activeWorkspace.value === 'orders') return Boolean(orderPanelRef.value?.loading) if (activeWorkspace.value === 'orders') return Boolean(orderPanelRef.value?.loading)
if (activeWorkspace.value === 'progress') return Boolean(progressPanelRef.value?.loading) if (activeWorkspace.value === 'progress') return Boolean(progressPanelRef.value?.loading)
if (activeWorkspace.value === 'paiban') return Boolean(paibanPanelRef.value?.loading)
return pager.loading return pager.loading
}) })
@@ -437,6 +445,7 @@ function handleFilterChange() {
function refreshPage() { function refreshPage() {
if (activeWorkspace.value === 'orders') return orderPanelRef.value?.refresh?.() if (activeWorkspace.value === 'orders') return orderPanelRef.value?.refresh?.()
if (activeWorkspace.value === 'progress') return progressPanelRef.value?.refresh?.() if (activeWorkspace.value === 'progress') return progressPanelRef.value?.refresh?.()
if (activeWorkspace.value === 'paiban') return paibanPanelRef.value?.refresh?.()
return getLists() return getLists()
} }
@@ -5,7 +5,7 @@ from __future__ import annotations
import hashlib import hashlib
import struct import struct
import zlib import zlib
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from copy import deepcopy from copy import deepcopy
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from os import PathLike from os import PathLike
@@ -165,6 +165,25 @@ class DemoDoctorRepository:
"status_text": "已结束", "status_text": "已结束",
"recording_status_text": "录制完成", "recording_status_text": "录制完成",
"recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"], "recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"],
"transcription_status": "completed",
"transcription_status_text": "文字已生成",
"transcript_text": "患者:最近睡眠比上周好一些。\n医生:继续记录睡眠和空腹血糖。",
"transcript_segments": [
{
"segment_id": "demo-1",
"speaker_role": "patient",
"speaker_user_id": "patient_301",
"timestamp": 1,
"text": "最近睡眠比上周好一些。",
},
{
"segment_id": "demo-2",
"speaker_role": "doctor",
"speaker_user_id": "doctor_1001",
"timestamp": 2,
"text": "继续记录睡眠和空腹血糖。",
},
],
"start_time_text": f"{self._today.isoformat()} 09:10:00", "start_time_text": f"{self._today.isoformat()} 09:10:00",
"end_time_text": f"{self._today.isoformat()} 09:22:00", "end_time_text": f"{self._today.isoformat()} 09:22:00",
"duration_text": "12分00秒", "duration_text": "12分00秒",
@@ -2235,6 +2254,10 @@ class DemoDoctorRepository:
"status_text": "已结束", "status_text": "已结束",
"recording_status_text": "待上传", "recording_status_text": "待上传",
"recording_urls_list": [], "recording_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
"transcript_segments": [],
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "), "start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"end_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "), "end_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"duration_text": "0秒", "duration_text": "0秒",
@@ -2533,6 +2556,10 @@ class DemoDoctorRepository:
"status_text": "呼叫中", "status_text": "呼叫中",
"recording_status_text": "未录制", "recording_status_text": "未录制",
"recording_urls_list": [], "recording_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
"transcript_segments": [],
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "), "start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
"end_time_text": "", "end_time_text": "",
"duration_text": "", "duration_text": "",
@@ -2600,6 +2627,128 @@ class DemoDoctorRepository:
"cloud_recording": {"started": False, "message": "演示模式不录制"}, "cloud_recording": {"started": False, "message": "演示模式不录制"},
} }
def _demo_call_record(
self, diagnosis_id: int, call_record_id: int | str
) -> dict[str, Any]:
record = next(
(
row
for row in self._call_records.get(diagnosis_id, [])
if str(row.get("id") or row.get("call_record_id") or "")
== str(call_record_id)
),
None,
)
if record is None:
raise RepositoryNotFoundError(f"call record {call_record_id} not found")
return record
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> dict[str, Any]:
"""Start an in-memory transcript attached to one demo call record."""
clean_session = transcription_session_id.strip()
if not clean_session:
raise ValueError("transcription_session_id is required")
with self._lock:
self._find_consultation(diagnosis_id)
record = self._demo_call_record(diagnosis_id, call_record_id)
existing = str(record.get("transcription_session_id") or "")
if existing and existing != clean_session:
raise ValueError("another transcription session already exists")
record.update(
{
"transcription_session_id": clean_session,
"transcription_language": language.strip() or "zh-CN",
"transcription_status": "running",
"transcription_status_text": "录音转写中",
"transcript_text": "",
"transcript_segments": [],
}
)
return deepcopy(record)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
"""Idempotently append/update completed segments in demo mode."""
with self._lock:
record = self._demo_call_record(diagnosis_id, call_record_id)
if record.get("transcription_session_id") != transcription_session_id:
raise ValueError("transcription session does not match the call record")
stored = record.setdefault("transcript_segments", [])
for incoming in segments:
segment_id = str(incoming.get("segment_id") or "").strip()
text = str(incoming.get("text") or "").strip()
if not segment_id or not text:
raise ValueError("transcript segment_id and text are required")
normalized = {
"segment_id": segment_id,
"speaker_role": str(incoming.get("speaker_role") or "unknown"),
"speaker_user_id": str(incoming.get("speaker_user_id") or ""),
"timestamp": int(incoming.get("timestamp") or 0),
"text": text,
}
current = next(
(row for row in stored if row.get("segment_id") == segment_id), None
)
if current is None:
stored.append(normalized)
else:
current.update(normalized)
stored.sort(key=lambda row: (int(row.get("timestamp") or 0), row["segment_id"]))
labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"}
record["transcript_text"] = "\n".join(
f"{labels.get(str(row.get('speaker_role')), '未知说话人')}{row['text']}"
for row in stored
)
return deepcopy(record)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> dict[str, Any]:
"""Finalize demo transcript fields on the same call record."""
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
with self._lock:
record = self._demo_call_record(diagnosis_id, call_record_id)
if record.get("transcription_session_id") != transcription_session_id:
raise ValueError("transcription session does not match the call record")
actual_count = len(record.get("transcript_segments") or [])
final_status = clean_status
if clean_status == "completed" and actual_count < expected_segment_count:
final_status = "partial"
record["transcription_status"] = final_status
record["transcription_status_text"] = {
"completed": "文字已生成",
"partial": "文字部分保存",
"failed": "文字生成失败",
}[final_status]
record["transcription_segment_count"] = actual_count
record["transcription_finished_at"] = datetime.now().replace(
microsecond=0
).isoformat(sep=" ")
return deepcopy(record)
def my_self(self) -> Session: def my_self(self) -> Session:
"""Compatibility alias for :meth:`get_session`.""" """Compatibility alias for :meth:`get_session`."""
@@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes import mimetypes
import re import re
import time import time
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from contextlib import suppress from contextlib import suppress
from datetime import date from datetime import date
from io import BytesIO from io import BytesIO
@@ -622,6 +622,36 @@ class DoctorRepository(Protocol):
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any: def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call.""" """Bind a TRTC room to the active call."""
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Start a transcript stored on one exact call record."""
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Idempotently persist completed transcript segments."""
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize and materialize the transcript text on a call record."""
def my_self(self) -> Session: def my_self(self) -> Session:
"""Compatibility alias for :meth:`get_session`.""" """Compatibility alias for :meth:`get_session`."""
@@ -2174,7 +2204,7 @@ class RemoteDoctorRepository:
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any: def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create the server-side call record before ringing participants.""" """Create the server-side call record before ringing participants."""
return self.client.post( payload = self.client.post(
"tcm.diagnosis/startCall", "tcm.diagnosis/startCall",
{ {
"diagnosis_id": diagnosis_id, "diagnosis_id": diagnosis_id,
@@ -2182,6 +2212,33 @@ class RemoteDoctorRepository:
"call_type": call_type, "call_type": call_type,
}, },
) )
if not isinstance(payload, Mapping):
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id object", data=payload
)
raw_id = next(
(
payload[key]
for key in ("call_record_id", "callRecordId", "id")
if key in payload
),
None,
)
try:
if isinstance(raw_id, bool):
raise ValueError
call_record_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
) from exc
if call_record_id <= 0:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any: def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis.""" """End the active call/recording associated with a diagnosis."""
@@ -2198,6 +2255,104 @@ class RemoteDoctorRepository:
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()}, {"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
) )
@staticmethod
def _transcription_identity(
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
) -> dict[str, Any]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id is required")
clean_session = transcription_session_id.strip()
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription_session_id must contain 1 to 128 characters")
return {
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
"transcription_session_id": clean_session,
}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Create the server transcript session for one call record."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
body["language"] = language.strip()[:32] or "zh-CN"
return self.client.post("tcm.diagnosis/startCallTranscription", body)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Upsert a bounded batch using each segment_id as the idempotency key."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
normalized: list[dict[str, Any]] = []
if len(segments) > 50:
raise ValueError("at most 50 transcript segments may be submitted at once")
for segment in segments:
segment_id = str(segment.get("segment_id") or "").strip()
text = str(segment.get("text") or "").strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id is invalid")
if not text or len(text) > 4_000:
raise ValueError("transcript text is invalid")
normalized.append(
{
"segment_id": segment_id,
"speaker_user_id": str(segment.get("speaker_user_id") or "")[:160],
"speaker_role": str(segment.get("speaker_role") or "unknown")[:20],
"timestamp": max(int(segment.get("timestamp") or 0), 0),
"text": text,
}
)
if not normalized:
raise ValueError("at least one transcript segment is required")
body["segments"] = normalized
return self.client.post("tcm.diagnosis/upsertCallTranscriptSegments", body)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize a transcript; repeated requests use the same session identity."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
if expected_segment_count < 0:
raise ValueError("expected_segment_count must not be negative")
body.update(
{
"expected_segment_count": expected_segment_count,
"status": clean_status,
}
)
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
# Compatibility aliases keep UI naming independent from endpoint history. # Compatibility aliases keep UI naming independent from endpoint history.
def my_self(self) -> Session: def my_self(self) -> Session:
"""Compatibility alias for :meth:`get_session`.""" """Compatibility alias for :meth:`get_session`."""
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QInputDialog, QInputDialog,
QLabel, QLabel,
QMessageBox, QMessageBox,
QPlainTextEdit,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QSizePolicy, QSizePolicy,
@@ -1411,7 +1412,7 @@ class DiagnosisDialog(QDialog):
layout.setContentsMargins(16, 14, 16, 18) layout.setContentsMargins(16, 14, 16, 18)
layout.setSpacing(12) layout.setSpacing(12)
toolbar = QHBoxLayout() toolbar = QHBoxLayout()
hint = QLabel("每条通话记录可包含多个回放地址,并可追加指定记录的视频") hint = QLabel("每条通话记录可包含多个回放地址;录音转写完成后可查看本次面诊对话文字")
hint.setObjectName("DiagnosisDialogGuidance") hint.setObjectName("DiagnosisDialogGuidance")
hint.setWordWrap(True) hint.setWordWrap(True)
toolbar.addWidget(hint, 1) toolbar.addWidget(hint, 1)
@@ -1553,7 +1554,7 @@ class DiagnosisDialog(QDialog):
("房间号", 180), ("房间号", 180),
("时长", 110), ("时长", 110),
("状态", 90), ("状态", 90),
("录制", 100), ("录制 / 文字", 120),
("操作", 130), ("操作", 130),
), ),
"assign": ( "assign": (
@@ -3365,11 +3366,71 @@ class DiagnosisDialog(QDialog):
cell.stop() cell.stop()
self._inline_recording_cells.clear() self._inline_recording_cells.clear()
@staticmethod
def _call_transcript_text(row: Any) -> str:
raw = first_value(
row,
"transcript_text",
"call_transcript",
"conversation_text",
"transcript",
default="",
)
if isinstance(raw, Mapping):
raw = first_value(raw, "text", "full_text", "content", default="")
text = str(raw or "").strip()
if text:
return text
segments = first_value(row, "transcript_segments", "segments", default=[]) or []
if isinstance(segments, Sequence) and not isinstance(
segments, (str, bytes, bytearray)
):
labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"}
lines: list[str] = []
for segment in segments:
content = str(first_value(segment, "text", "sourceText", default="") or "").strip()
if not content:
continue
role = str(
first_value(segment, "speaker_role", "speakerRole", default="unknown")
or "unknown"
)
lines.append(f"{labels.get(role, '未知说话人')}{content}")
return "\n".join(lines)
return ""
def _view_call_transcript(self, call_record_id: int, transcript: str) -> None:
dialog = QDialog(self)
dialog.setObjectName("DiagnosisCallTranscriptDialog")
dialog.setWindowTitle(f"面诊对话文字 · 通话记录 #{call_record_id}")
dialog.resize(680, 520)
layout = QVBoxLayout(dialog)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(12)
title = QLabel("录音转写对话")
title.setObjectName("DiagnosisDailySectionTitle")
layout.addWidget(title)
hint = QLabel("以下内容由语音自动转写,仅作为面诊记录辅助,请由医生核对。")
hint.setObjectName("DiagnosisDialogGuidance")
hint.setWordWrap(True)
layout.addWidget(hint)
content = QPlainTextEdit(dialog)
content.setObjectName("DiagnosisCallTranscriptText")
content.setReadOnly(True)
content.setPlainText(transcript)
layout.addWidget(content, 1)
close_button = QPushButton("关闭")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(dialog.accept)
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight)
dialog.exec()
def _fill_video(self, rows: Sequence[Any]) -> None: def _fill_video(self, rows: Sequence[Any]) -> None:
self._stop_inline_recordings() self._stop_inline_recordings()
matrix: list[tuple[Any, ...]] = [] matrix: list[tuple[Any, ...]] = []
row_urls: list[list[str]] = [] row_urls: list[list[str]] = []
record_ids: list[int] = [] record_ids: list[int] = []
transcripts: list[str] = []
for row in rows: for row in rows:
raw_urls = first_value(row, "recording_urls_list", "recording_urls", default=[]) or [] raw_urls = first_value(row, "recording_urls_list", "recording_urls", default=[]) or []
if isinstance(raw_urls, str) or not isinstance(raw_urls, Sequence): if isinstance(raw_urls, str) or not isinstance(raw_urls, Sequence):
@@ -3382,6 +3443,8 @@ class DiagnosisDialog(QDialog):
normalized.append(url) normalized.append(url)
row_urls.append(normalized) row_urls.append(normalized)
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0)) record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
transcript = self._call_transcript_text(row)
transcripts.append(transcript)
call_type = _raw_value(row, "call_type_text") call_type = _raw_value(row, "call_type_text")
if call_type is _MISSING: if call_type is _MISSING:
raw_call_type = _raw_value(row, "call_type") raw_call_type = _raw_value(row, "call_type")
@@ -3398,6 +3461,15 @@ class DiagnosisDialog(QDialog):
3: "未接听", 3: "未接听",
4: "已取消", 4: "已取消",
}.get(_int(raw_status, -1), "") }.get(_int(raw_status, -1), "")
recording_status = first_value(
row, "recording_status_text", "record_status_text", "record_status"
)
transcript_status = first_value(
row,
"transcription_status_text",
"transcript_status_text",
default="文字已生成" if transcript else "未生成文字",
)
matrix.append( matrix.append(
( (
"" if normalized else "暂无录制回放", "" if normalized else "暂无录制回放",
@@ -3407,9 +3479,7 @@ class DiagnosisDialog(QDialog):
first_value(row, "room_id", "room_no"), first_value(row, "room_id", "room_no"),
first_value(row, "duration_text", "duration"), first_value(row, "duration_text", "duration"),
status, status,
first_value( f"{display_text(recording_status)}\n{display_text(transcript_status)}",
row, "recording_status_text", "record_status_text", "record_status"
),
"", "",
) )
) )
@@ -3434,6 +3504,20 @@ class DiagnosisDialog(QDialog):
if item is not None: if item is not None:
item.setText("") item.setText("")
call_record_id = record_ids[row_index] call_record_id = record_ids[row_index]
actions: list[QPushButton] = []
transcript = transcripts[row_index]
if transcript and call_record_id > 0:
view_transcript = self._action_button(
"查看文字", f"查看通话记录 #{call_record_id} 的录音转写"
)
view_transcript.setObjectName("DiagnosisVideoTranscriptView")
view_transcript.setProperty("callRecordId", call_record_id)
view_transcript.clicked.connect(
lambda _checked=False, selected=call_record_id, text=transcript: self._view_call_transcript(
selected, text
)
)
actions.append(view_transcript)
if self._editable and self._can_video_upload and call_record_id > 0: if self._editable and self._can_video_upload and call_record_id > 0:
upload = self._action_button( upload = self._action_button(
"追加回放", f"上传并绑定通话记录 #{call_record_id}" "追加回放", f"上传并绑定通话记录 #{call_record_id}"
@@ -3452,12 +3536,16 @@ class DiagnosisDialog(QDialog):
selected selected
) )
) )
upload_host = QWidget() actions.append(upload)
upload_host.setObjectName("DiagnosisVideoRowUploadCell") if actions:
upload_layout = QVBoxLayout(upload_host) action_host = QWidget()
upload_layout.setContentsMargins(0, 0, 0, 0) action_host.setObjectName("DiagnosisVideoRowUploadCell")
upload_layout.addWidget(upload, 0, Qt.AlignmentFlag.AlignCenter) action_layout = QVBoxLayout(action_host)
table.setCellWidget(row_index, 8, upload_host) action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(2)
for action in actions:
action_layout.addWidget(action, 0, Qt.AlignmentFlag.AlignCenter)
table.setCellWidget(row_index, 8, action_host)
upload_item = table.item(row_index, 8) upload_item = table.item(row_index, 8)
if upload_item is not None: if upload_item is not None:
upload_item.setText("") upload_item.setText("")
@@ -130,6 +130,7 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
"patientUserId": "patient_user_id", "patientUserId": "patient_user_id",
"diagnosisId": "diagnosis_id", "diagnosisId": "diagnosis_id",
"patientId": "patient_id", "patientId": "patient_id",
"callRecordId": "call_record_id",
} }
adapted = { adapted = {
json_name: getattr(ticket, attribute_name) json_name: getattr(ticket, attribute_name)
@@ -196,6 +197,7 @@ class VideoCallRequest:
target_user_id: str target_user_id: str
diagnosis_id: Identifier diagnosis_id: Identifier
patient_id: Identifier | None = None patient_id: Identifier | None = None
call_record_id: Identifier | None = None
backend_mode: BackendMode = BackendMode.EMBEDDED backend_mode: BackendMode = BackendMode.EMBEDDED
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -218,6 +220,12 @@ class VideoCallRequest:
"patient_id", "patient_id",
_identifier(self.patient_id, "patientId"), _identifier(self.patient_id, "patientId"),
) )
if self.call_record_id is not None:
object.__setattr__(
self,
"call_record_id",
_identifier(self.call_record_id, "callRecordId"),
)
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode)) object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
@classmethod @classmethod
@@ -257,6 +265,7 @@ class VideoCallRequest:
return { return {
"diagnosis_id": self.diagnosis_id, "diagnosis_id": self.diagnosis_id,
"patient_id": self.patient_id, "patient_id": self.patient_id,
"call_record_id": self.call_record_id,
"backend_mode": self.backend_mode.value, "backend_mode": self.backend_mode.value,
} }
@@ -285,6 +294,13 @@ def normalize_backend_ticket(
_identifier, _identifier,
required=False, required=False,
) )
payload_call_record = _read_aliases(
payload,
("callRecordId", "call_record_id"),
"callRecordId",
_identifier,
required=False,
)
normalized_diagnosis = _merge_identifier( normalized_diagnosis = _merge_identifier(
payload_diagnosis, payload_diagnosis,
@@ -323,6 +339,7 @@ def normalize_backend_ticket(
), ),
diagnosis_id=normalized_diagnosis, diagnosis_id=normalized_diagnosis,
patient_id=normalized_patient, patient_id=normalized_patient,
call_record_id=payload_call_record,
backend_mode=BackendMode.parse(backend_mode), backend_mode=BackendMode.parse(backend_mode),
) )
+237 -1
View File
@@ -74,6 +74,71 @@ def _call_repository_method(method: Callable[..., Any], payload: Mapping[str, An
return _resolve_result(result) return _resolve_result(result)
def _mapping_candidate(value: Any) -> Mapping[str, Any] | None:
if isinstance(value, Mapping):
return value
for attribute in ("raw", "data"):
candidate = getattr(value, attribute, None)
if isinstance(candidate, Mapping):
return candidate
return None
def _extract_call_record_id(result: Any) -> int | str | None:
"""Read a positive call-record identity from common backend envelopes."""
pending = [result]
visited: set[int] = set()
while pending:
candidate = pending.pop(0)
mapping = _mapping_candidate(candidate)
if mapping is None or id(mapping) in visited:
continue
visited.add(id(mapping))
for key in ("call_record_id", "callRecordId", "id"):
value = mapping.get(key)
if isinstance(value, bool) or value is None:
continue
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str) and value.strip():
return value.strip()
for key in ("data", "result", "record", "call_record", "callRecord"):
nested = mapping.get(key)
if isinstance(nested, Mapping):
pending.append(nested)
return None
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
speaker_user_id = str(
segment.get("speaker_user_id", segment.get("speakerUserId", ""))
).strip()
speaker_role = str(segment.get("speaker_role", segment.get("speakerRole", "unknown"))).strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id must contain 1 to 160 characters")
if not text or len(text) > 4_000:
raise ValueError("transcript text must contain 1 to 4000 characters")
if len(speaker_user_id) > 160:
raise ValueError("transcript speaker_user_id is too long")
if speaker_role not in {"doctor", "patient", "unknown"}:
speaker_role = "unknown"
try:
timestamp = int(segment.get("timestamp") or 0)
except (TypeError, ValueError):
timestamp = 0
return {
"segment_id": segment_id,
"transcription_session_id": session_id,
"speaker_user_id": speaker_user_id,
"speaker_role": speaker_role,
"timestamp": max(timestamp, 0),
"text": text,
}
@dataclass(slots=True) @dataclass(slots=True)
class _WorkItem: class _WorkItem:
operation: str operation: str
@@ -169,11 +234,17 @@ class OrderedCallLifecycle:
self.logger = logger self.logger = logger
self.started = False self.started = False
self.ended = False self.ended = False
self.call_record_id: int | str | None = request.call_record_id
self.bound_room_id: str | None = None self.bound_room_id: str | None = None
self._claimed_room_id: str | None = None self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None self._bind_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None self._end_future: Future[bool] | None = None
self._transcription_start_future: Future[bool] | None = None
self._transcription_finish_future: Future[bool] | None = None
self._transcription_session_id: str | None = None
self._transcription_active = False
self._segment_futures: dict[str, Future[bool]] = {}
self._lock = threading.RLock() self._lock = threading.RLock()
self._worker = _OrderedDaemonWorker(logger, request.safe_log_context()) self._worker = _OrderedDaemonWorker(logger, request.safe_log_context())
@@ -181,6 +252,10 @@ class OrderedCallLifecycle:
def worker_is_daemon(self) -> bool: def worker_is_daemon(self) -> bool:
return self._worker.is_daemon return self._worker.is_daemon
@property
def transcription_session_id(self) -> str | None:
return self._transcription_session_id
def start(self) -> Future[bool]: def start(self) -> Future[bool]:
with self._lock: with self._lock:
if self._start_future is not None: if self._start_future is not None:
@@ -196,9 +271,13 @@ class OrderedCallLifecycle:
payload["patient_id"] = self.request.patient_id payload["patient_id"] = self.request.patient_id
def operation() -> bool: def operation() -> bool:
_call_repository_method(method, payload) result = _call_repository_method(method, payload)
record_id = _extract_call_record_id(result)
if record_id is None:
raise ValueError("startCall response did not include the current call_record_id")
with self._lock: with self._lock:
self.started = True self.started = True
self.call_record_id = record_id
self.logger.info( self.logger.info(
"video call record started", "video call record started",
extra={"video_call": self.request.safe_log_context()}, extra={"video_call": self.request.safe_log_context()},
@@ -309,10 +388,167 @@ class OrderedCallLifecycle:
return self._worker.submit("screenshot", operation) return self._worker.submit("screenshot", operation)
def start_transcription(self, session_id: str, *, language: str = "zh-CN") -> Future[bool]:
"""Start persisted realtime transcription for this exact call record."""
clean_session = str(session_id or "").strip()
clean_language = str(language or "zh-CN").strip()[:32] or "zh-CN"
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription session id must contain 1 to 128 characters")
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
if self._transcription_start_future is not None:
if self._transcription_session_id != clean_session:
raise RuntimeError("another transcription session already exists")
return self._transcription_start_future
if self._start_future is None:
self.start()
method = getattr(self.repository, "start_call_transcription", None)
if not callable(method):
raise ValueError("video repository does not implement call transcription storage")
self._transcription_session_id = clean_session
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"transcription_session_id": clean_session,
"language": clean_language,
},
)
with self._lock:
self._transcription_active = True
self.logger.info(
"video call transcription started",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._transcription_start_future = self._worker.submit(
"transcription-start", operation
)
return self._transcription_start_future
def save_transcript_segment(self, segment: Mapping[str, Any]) -> Future[bool]:
"""Upsert one completed, bounded transcript segment without logging its text."""
with self._lock:
session_id = self._transcription_session_id
if not session_id or self._transcription_start_future is None:
raise RuntimeError("call transcription has not started")
if self._transcription_finish_future is not None:
raise RuntimeError("call transcription has already stopped")
cleaned = _clean_transcript_segment(segment, session_id)
segment_id = cleaned["segment_id"]
existing = self._segment_futures.get(segment_id)
if existing is not None:
return existing
method = getattr(self.repository, "upsert_call_transcript_segments", None)
if not callable(method):
raise ValueError("video repository does not implement transcript segment storage")
def operation() -> bool:
with self._lock:
active = self._transcription_active
record_id = self.call_record_id
if not active:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"transcription_session_id": session_id,
"segments": [cleaned],
},
)
return True
future = self._worker.submit("transcription-segment", operation)
self._segment_futures[segment_id] = future
def release_failed(completed: Future[bool]) -> None:
if completed.cancelled() or completed.exception() is not None:
with self._lock:
if self._segment_futures.get(segment_id) is completed:
self._segment_futures.pop(segment_id, None)
future.add_done_callback(release_failed)
return future
def finish_transcription(self, *, status: str = "completed") -> Future[bool]:
"""Flush and finalize the current transcript exactly once."""
clean_status = str(status or "completed").strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
with self._lock:
if self._transcription_finish_future is not None:
return self._transcription_finish_future
if self._transcription_start_future is None or not self._transcription_session_id:
return _settled_future(False)
method = getattr(self.repository, "finish_call_transcription", None)
if not callable(method):
raise ValueError("video repository does not implement transcription finalization")
session_id = self._transcription_session_id
def operation() -> bool:
with self._lock:
active = self._transcription_active
record_id = self.call_record_id
expected_count = len(self._segment_futures)
if not active:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"transcription_session_id": session_id,
"expected_segment_count": expected_count,
"status": clean_status,
},
)
with self._lock:
self._transcription_active = False
self.logger.info(
"video call transcription finalized",
extra={
"video_call": self.request.safe_log_context(),
"transcription_status": clean_status,
"segment_count": expected_count,
},
)
return True
self._transcription_finish_future = self._worker.submit(
"transcription-finish", operation
)
return self._transcription_finish_future
def end(self, reason: str) -> Future[bool]: def end(self, reason: str) -> Future[bool]:
with self._lock: with self._lock:
if self._end_future is not None: if self._end_future is not None:
return self._end_future return self._end_future
if (
self._transcription_start_future is not None
and self._transcription_finish_future is None
):
self.finish_transcription(status="partial")
method = getattr(self.repository, "end_call", None) method = getattr(self.repository, "end_call", None)
def operation() -> bool: def operation() -> bool:
+120 -2
View File
@@ -210,6 +210,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
call_error = Signal(str) # type: ignore[misc] call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc] _start_completed = Signal(bool) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc] _screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
def __init__( def __init__(
self, self,
@@ -284,6 +285,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._start_completed.connect(self._on_lifecycle_started) self._start_completed.connect(self._on_lifecycle_started)
self._screenshot_completed.connect(self._on_screenshot_completed) self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self.web_view.loadFinished.connect(self._on_load_finished) self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url)) self.web_view.setUrl(QUrl(self.location.url))
@@ -426,6 +428,21 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
str(message.get("message") or "截屏图片无效。")[:200], str(message.get("message") or "截屏图片无效。")[:200],
) )
return return
if event == "transcription-start-request":
self._start_transcription(
str(message.get("sessionId") or ""),
str(message.get("language") or "zh-CN"),
)
return
if event == "transcription-segment":
self._save_transcript_segment(message)
return
if event == "transcription-stop":
self._finish_transcription(
str(message.get("sessionId") or ""),
str(message.get("status") or "completed"),
)
return
room_id = message.get("roomId", message.get("room_id")) room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""): if room_id not in (None, ""):
self.lifecycle.bind_room(room_id) self.lifecycle.bind_room(room_id)
@@ -434,8 +451,6 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if event == "status": if event == "status":
status = str(message.get("status", "unknown"))[:80] status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status) self.status_changed.emit(status)
if status == "idle" and not self.open_im:
self._close_from_companion("remote-idle")
elif event == "hangup": elif event == "hangup":
status = str(message.get("status", "ended"))[:80] status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status) self.call_ended.emit(status)
@@ -509,6 +524,109 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});" f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
) )
def _notify_transcription_completed(
self,
operation: str,
session_id: str,
segment_id: str,
future: Future[bool],
) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "录音文字保存失败。"
else:
message = {
"start": "录音文字存储已准备。",
"segment": "",
"stop": "本次面诊对话文字已保存。",
}.get(operation, "")
with suppress(RuntimeError):
self._transcription_completed.emit(
operation, session_id, segment_id, succeeded, message
)
def _on_transcription_completed(
self,
operation: str,
session_id: str,
segment_id: str,
succeeded: bool,
message: str,
) -> None:
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.transcriptionResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, "
f"{json.dumps(segment_id)}, {state}, {payload});"
)
def _start_transcription(self, session_id: str, language: str) -> None:
try:
future = self.lifecycle.start_transcription(session_id, language=language)
except Exception as error:
self._on_transcription_completed(
"start", session_id, "", False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"start", session_id, "", completed
)
)
def _save_transcript_segment(self, message: Mapping[str, Any]) -> None:
session_id = str(message.get("sessionId") or "").strip()
segment = message.get("segment")
segment_id = (
str(segment.get("segment_id") or segment.get("segmentId") or "").strip()
if isinstance(segment, Mapping)
else ""
)
if not isinstance(segment, Mapping):
self._on_transcription_completed(
"segment", session_id, segment_id, False, "录音文字片段无效。"
)
return
if session_id != str(self.lifecycle.transcription_session_id or ""):
self._on_transcription_completed(
"segment", session_id, segment_id, False, "录音会话标识不匹配。"
)
return
try:
future = self.lifecycle.save_transcript_segment(segment)
except Exception as error:
self._on_transcription_completed(
"segment", session_id, segment_id, False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"segment", session_id, segment_id, completed
)
)
def _finish_transcription(self, session_id: str, status: str) -> None:
if session_id.strip() != str(self.lifecycle.transcription_session_id or ""):
self._on_transcription_completed(
"stop", session_id, "", False, "录音会话标识不匹配。"
)
return
try:
future = self.lifecycle.finish_transcription(status=status)
except Exception as error:
self._on_transcription_completed(
"stop", session_id, "", False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"stop", session_id, "", completed
)
)
def _close_from_companion(self, reason: str) -> None: def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True self._companion_ended = True
self._close_reason = reason self._close_reason = reason
+65
View File
@@ -232,6 +232,69 @@ def test_demo_call_lifecycle_mutates_record(repository: DemoDoctorRepository) ->
assert ended["room_id"] == "room-501" assert ended["room_id"] == "room-501"
def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
repository: DemoDoctorRepository,
) -> None:
"""Demo replay reads expose one finalized segment for a repeated segment ID."""
started = repository.start_call(501, 301)
call_record_id = started["id"]
repository.start_call_transcription(501, call_record_id, "session-1")
repository.upsert_call_transcript_segments(
501,
call_record_id,
"session-1",
[
{
"segment_id": "seg-1",
"speaker_user_id": "patient_301",
"speaker_role": "patient",
"timestamp": 1200,
"text": "draft words",
}
],
)
repository.upsert_call_transcript_segments(
501,
call_record_id,
"session-1",
[
{
"segment_id": "seg-1",
"speaker_user_id": "patient_301",
"speaker_role": "patient",
"timestamp": 1200,
"text": "final words",
}
],
)
repository.finish_call_transcription(
501,
call_record_id,
"session-1",
expected_segment_count=1,
status="completed",
)
repository.end_call(501)
record = next(
row for row in repository.list_call_records(501) if row["id"] == call_record_id
)
assert record["status"] == 2
assert record["transcription_status"] == "completed"
assert record["transcription_segment_count"] == 1
assert record["transcript_segments"] == [
{
"segment_id": "seg-1",
"speaker_role": "patient",
"speaker_user_id": "patient_301",
"timestamp": 1200,
"text": "final words",
}
]
assert "final words" in record["transcript_text"]
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None: def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
"""List parsing handles nullable fields, aliases and non-object rows safely.""" """List parsing handles nullable fields, aliases and non-object rows safely."""
@@ -315,6 +378,8 @@ class _StubApiClient:
"userSig": "short-lived", "userSig": "short-lived",
"patientUserId": "patient_2", "patientUserId": "patient_2",
} }
if endpoint == "tcm.diagnosis/startCall":
return {"call_record_id": 901}
return {"ok": True} return {"ok": True}
+103
View File
@@ -7,6 +7,7 @@ from typing import Any
import pytest import pytest
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
from doctor_workstation.services.mock_repository import DemoDoctorRepository from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import ( from doctor_workstation.services.repository import (
@@ -76,6 +77,8 @@ class RecordingClient:
self.post_calls.append((endpoint, body)) self.post_calls.append((endpoint, body))
if endpoint in {"tcm.prescription/add", "tcm.prescriptionOrder/create"}: if endpoint in {"tcm.prescription/add", "tcm.prescriptionOrder/create"}:
return {"id": 88} return {"id": 88}
if endpoint == "tcm.diagnosis/startCall":
return {"call_record_id": 901}
return {"ok": True} return {"ok": True}
@@ -253,6 +256,106 @@ def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
} <= get_endpoints } <= get_endpoints
def test_remote_transcription_endpoints_use_exact_normalized_dtos() -> None:
"""Realtime transcript persistence stays within the three audited POST DTOs."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.start_call_transcription(501, 901, " session-1 ", language=" zh-CN ")
repository.upsert_call_transcript_segments(
501,
901,
"session-1",
[
{
"segment_id": "seg-1",
"speaker_user_id": "patient_301",
"speaker_role": "patient",
"timestamp": "1200",
"text": " patient words ",
}
],
)
repository.finish_call_transcription(
501,
901,
"session-1",
expected_segment_count=1,
status="completed",
)
assert client.post_calls == [
(
"tcm.diagnosis/startCallTranscription",
{
"diagnosis_id": 501,
"call_record_id": 901,
"transcription_session_id": "session-1",
"language": "zh-CN",
},
),
(
"tcm.diagnosis/upsertCallTranscriptSegments",
{
"diagnosis_id": 501,
"call_record_id": 901,
"transcription_session_id": "session-1",
"segments": [
{
"segment_id": "seg-1",
"speaker_user_id": "patient_301",
"speaker_role": "patient",
"timestamp": 1200,
"text": "patient words",
}
],
},
),
(
"tcm.diagnosis/finishCallTranscription",
{
"diagnosis_id": 501,
"call_record_id": 901,
"transcription_session_id": "session-1",
"expected_segment_count": 1,
"status": "completed",
},
),
]
def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
assert repository.start_call(501, 301) == {"call_record_id": 901}
assert client.post_calls == [
(
"tcm.diagnosis/startCall",
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
)
]
@pytest.mark.parametrize(
"response",
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
)
def test_remote_start_call_rejects_missing_or_invalid_record_id(response: Any) -> None:
class StartCallClient(RecordingClient):
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
if endpoint == "tcm.diagnosis/startCall":
self.post_calls.append((endpoint, dict(payload or {})))
return response
return super().post(endpoint, payload)
repository = RemoteDoctorRepository(StartCallClient()) # type: ignore[arg-type]
with pytest.raises(ApiProtocolError, match="call_record"):
repository.start_call(501, 301)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"unsafe_reference", "unsafe_reference",
[r"C:\records\tongue.jpg", r"\\server\share\report.pdf", "file:///tmp/a.jpg"], [r"C:\records\tongue.jpg", r"\\server\share\report.pdf", "file:///tmp/a.jpg"],
+230 -2
View File
@@ -188,10 +188,13 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
release_start = threading.Event() release_start = threading.Event()
class Repository: class Repository:
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int) -> None: def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
start_entered.set() start_entered.set()
assert release_start.wait(2) assert release_start.wait(2)
events.append(("start", diagnosis_id, patient_id, call_type)) events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 900}
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None: def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
events.append(("bind", diagnosis_id, room_id)) events.append(("bind", diagnosis_id, room_id))
@@ -237,6 +240,208 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
] ]
def test_transcription_lifecycle_uses_start_record_id_and_remains_fifo() -> None:
events: list[tuple[object, ...]] = []
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, object]:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"data": {"callRecordId": 901}}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int,
transcription_session_id: str,
*,
language: str,
) -> None:
events.append(
(
"transcription-start",
diagnosis_id,
call_record_id,
transcription_session_id,
language,
)
)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int,
transcription_session_id: str,
segments: list[dict[str, object]],
) -> None:
events.append(
(
"segment",
diagnosis_id,
call_record_id,
transcription_session_id,
segments,
)
)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str,
) -> None:
events.append(
(
"finish",
diagnosis_id,
call_record_id,
transcription_session_id,
expected_segment_count,
status,
)
)
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
start = lifecycle.start()
transcription_start = lifecycle.start_transcription("session-1")
segment = lifecycle.save_transcript_segment(
{
"segment_id": "seg-1",
"speaker_user_id": "patient_8",
"speaker_role": "patient",
"timestamp": 1200,
"text": "patient words",
}
)
duplicate = lifecycle.save_transcript_segment(
{"segment_id": "seg-1", "text": "must not produce another write"}
)
finish = lifecycle.finish_transcription(status="completed")
end = lifecycle.end("test")
assert duplicate is segment
assert start.result(timeout=2) is True
assert transcription_start.result(timeout=2) is True
assert segment.result(timeout=2) is True
assert finish.result(timeout=2) is True
assert end.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert lifecycle.call_record_id == 901
assert events == [
("start", 123, 8, 2),
("transcription-start", 123, 901, "session-1", "zh-CN"),
(
"segment",
123,
901,
"session-1",
[
{
"segment_id": "seg-1",
"transcription_session_id": "session-1",
"speaker_user_id": "patient_8",
"speaker_role": "patient",
"timestamp": 1200,
"text": "patient words",
}
],
),
("finish", 123, 901, "session-1", 1, "completed"),
("end", 123),
]
def test_end_auto_finishes_active_transcription_as_partial() -> None:
events: list[tuple[object, ...]] = []
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 902}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int,
transcription_session_id: str,
*,
language: str,
) -> None:
events.append(
(
"transcription-start",
diagnosis_id,
call_record_id,
transcription_session_id,
language,
)
)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str,
) -> None:
events.append(
(
"finish",
diagnosis_id,
call_record_id,
transcription_session_id,
expected_segment_count,
status,
)
)
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
lifecycle.start()
lifecycle.start_transcription("session-auto-partial")
ended = lifecycle.end("window-closed")
assert ended.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert events == [
("start", 123, 8, 2),
("transcription-start", 123, 902, "session-auto-partial", "zh-CN"),
("finish", 123, 902, "session-auto-partial", 0, "partial"),
("end", 123),
]
def test_failed_start_prevents_bind_and_end_writes() -> None: def test_failed_start_prevents_bind_and_end_writes() -> None:
events: list[str] = [] events: list[str] = []
@@ -278,8 +483,9 @@ def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() ->
events: list[tuple[object, ...]] = [] events: list[tuple[object, ...]] = []
class Repository: class Repository:
def start_call(self, diagnosis_id: int, *, call_type: int) -> None: def start_call(self, diagnosis_id: int, *, call_type: int) -> dict[str, int]:
events.append(("start", diagnosis_id, call_type)) events.append(("start", diagnosis_id, call_type))
return {"call_record_id": 903}
def upload_material_bytes( def upload_material_bytes(
self, self,
@@ -325,6 +531,28 @@ def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() ->
] ]
def test_start_rejects_missing_record_id_without_using_ticket_fallback() -> None:
class Repository:
def start_call(self, diagnosis_id: int, *, call_type: int) -> dict[str, object]:
del diagnosis_id, call_type
return {}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
call_record_id=77,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(ValueError, match="startCall response did not include"):
lifecycle.start().result(timeout=2)
assert lifecycle.started is False
def test_https_document_policy_is_exact_and_origin_scoped() -> None: def test_https_document_policy_is_exact_and_origin_scoped() -> None:
policy = TrustedDocumentPolicy.from_url( policy = TrustedDocumentPolicy.from_url(
"https://RTC.Example.com/doctor-call/index.html?tenant=a#boot", "https://RTC.Example.com/doctor-call/index.html?tenant=a#boot",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<title>视频面诊</title> <title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-DwSVWep6.js"></script> <script type="module" crossorigin src="./assets/index-R5GzqA8s.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-qOBmgxQV.css"> <link rel="stylesheet" crossorigin href="./assets/index-CED5X2W4.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+23
View File
@@ -23,6 +23,7 @@ const props = defineProps<{
chatBusy: Readonly<Ref<boolean>> chatBusy: Readonly<Ref<boolean>>
notice: Readonly<Ref<string>> notice: Readonly<Ref<string>>
hasMoreMessages: Readonly<Ref<boolean>> hasMoreMessages: Readonly<Ref<boolean>>
transcriptionState: Readonly<Ref<string>>
onSendText: (text: string) => Promise<void> onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void> onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void> onLoadMore: () => Promise<void>
@@ -42,6 +43,15 @@ const isChat = computed(() => props.mode.value === 'chat')
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value)) const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
const videoVisible = computed(() => !isChat.value || isCalling.value) const videoVisible = computed(() => !isChat.value || isCalling.value)
const canCapture = computed(() => props.phase.value === 'connected') const canCapture = computed(() => props.phase.value === 'connected')
const transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
const transcriptionStatusText = computed(() => {
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
return '自动录音已结束'
})
watch( watch(
() => props.messages.value.length, () => props.messages.value.length,
@@ -242,6 +252,19 @@ async function captureScreenshot(): Promise<void> {
</div> </div>
<div v-if="isCalling" class="video-actions"> <div v-if="isCalling" class="video-actions">
<div
v-if="canCapture"
class="recording-status"
:class="{
'recording-status--active': transcriptionActive,
'recording-status--error': transcriptionFailed,
}"
role="status"
aria-live="polite"
>
<span class="recording-indicator" aria-hidden="true" />
{{ transcriptionStatusText }}
</div>
<button <button
class="capture-button" class="capture-button"
type="button" type="button"
+7
View File
@@ -30,6 +30,13 @@ interface DoctorConsultationApi {
hangup(): Promise<void> hangup(): Promise<void>
hostCallReady(ok: boolean, message?: string): void hostCallReady(ok: boolean, message?: string): void
screenshotResult(ok: boolean, message: string): void screenshotResult(ok: boolean, message: string): void
transcriptionResult(
operation: 'start' | 'segment' | 'stop',
sessionId: string,
segmentId: string,
ok: boolean,
message: string,
): void
} }
interface QtVideoBridge { interface QtVideoBridge {
+493 -17
View File
@@ -15,6 +15,7 @@ import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error' type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
type CompanionMode = 'chat' | 'video' type CompanionMode = 'chat' | 'video'
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'error'
interface NormalizedCallConfig { interface NormalizedCallConfig {
SDKAppID: number SDKAppID: number
@@ -38,11 +39,74 @@ interface UiChatMessage {
interface BridgeMessage { interface BridgeMessage {
source: 'doctor-call' source: 'doctor-call'
event: 'ready' | 'call-start-request' | 'status' | 'room' | 'hangup' | 'error' event:
| 'ready'
| 'call-start-request'
| 'status'
| 'room'
| 'hangup'
| 'error'
| 'transcription-start-request'
| 'transcription-segment'
| 'transcription-stop'
diagnosisId?: number | string diagnosisId?: number | string
status?: string status?: string
roomId?: string roomId?: string
message?: string message?: string
sessionId?: string
language?: string
segment?: {
segment_id: string
speaker_user_id: string
speaker_role: 'doctor' | 'patient' | 'unknown'
timestamp: number
text: string
}
}
interface RealtimeTranscriberMessage {
segmentId: string
speakerUserId: string
sourceText: string
timestamp: number
isCompleted: boolean
}
interface RealtimeTranscriberListener {
onReceiveTranscriberMessage: (
roomId: string | number,
message: RealtimeTranscriberMessage,
) => void
onRealtimeTranscriberStarted: (
roomId: string | number,
robotId: string,
sourceLanguage: string,
) => void
onRealtimeTranscriberStopped: (roomId: string | number, robotId: string) => void
onRealtimeTranscriberError: (
roomId: string | number,
robotId: string,
error: number,
errorMessage: string,
) => void
}
interface RealtimeTranscriberManager {
addListener(listener: RealtimeTranscriberListener): void
removeListener(listener: RealtimeTranscriberListener): void
startRealtimeTranscriber(config: { sourceLanguage: string }): Promise<string>
stopRealtimeTranscriber(robotId: string): Promise<void>
}
interface PendingTranscriptionReply {
sessionId: string
resolve: (value: boolean) => void
promise: Promise<boolean>
}
interface PendingSegment {
message: BridgeMessage
attempts: number
} }
const phase = ref<CallPhase>('ready') const phase = ref<CallPhase>('ready')
@@ -54,6 +118,7 @@ const chatReady = ref(false)
const chatBusy = ref(false) const chatBusy = ref(false)
const notice = ref('') const notice = ref('')
const hasMoreMessages = ref(false) const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
let activeConfig: NormalizedCallConfig | null = null let activeConfig: NormalizedCallConfig | null = null
let chat: any = null let chat: any = null
@@ -65,6 +130,23 @@ let endNotified = true
let starting = false let starting = false
let emittedRoomId = '' let emittedRoomId = ''
let resolveHostCallReady: ((value: boolean) => void) | null = null let resolveHostCallReady: ((value: boolean) => void) | null = null
const pendingTranscriptionStarts = new Map<string, PendingTranscriptionReply>()
const pendingTranscriptionStops = new Map<string, PendingTranscriptionReply>()
let transcriptionSessionId = ''
let transcriptionGeneration = 0
let transcriberRunning = false
let transcriberManager: RealtimeTranscriberManager | null = null
let transcriberListener: RealtimeTranscriberListener | null = null
let transcriberRobotId = ''
let transcriptionStartPromise: Promise<void> | null = null
let transcriptionStopPromise: Promise<void> | null = null
let hangupNotification: Promise<void> | null = null
let callCycleGeneration = 0
let autoTranscriptionAttemptedGeneration = -1
let lastTranscriberMessageAt = 0
let transcriberStoppedAt = 0
const acknowledgedSegmentIds = new Set<string>()
const pendingSegments = new Map<string, PendingSegment>()
function initializeQtWebChannel(): void { function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport const transport = window.qt?.webChannelTransport
@@ -457,17 +539,372 @@ async function sendAttachment(file: File): Promise<void> {
} }
} }
function notifyHangup(status = 'ended'): void { function newTranscriptionSessionId(): string {
if (endNotified) return const random = window.crypto?.randomUUID?.()
return random ? `call-${random}` : `call-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function requestTranscriptionStart(sessionId: string): Promise<boolean> {
if (!activeConfig || !window.qtVideoBridge?.notify) return Promise.resolve(false)
const existing = pendingTranscriptionStarts.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingTranscriptionReply
pending.sessionId = sessionId
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingTranscriptionStarts.set(sessionId, pending)
emit({
source: 'doctor-call',
event: 'transcription-start-request',
diagnosisId: activeConfig?.diagnosisId,
sessionId,
language: 'zh',
})
window.setTimeout(() => {
if (pendingTranscriptionStarts.get(sessionId) !== pending) return
pendingTranscriptionStarts.delete(sessionId)
pending.resolve(false)
}, 15000)
return pending.promise
}
function requestTranscriptionStop(
sessionId: string,
status: 'completed' | 'partial' | 'failed',
): Promise<boolean> {
if (!activeConfig || !sessionId || !window.qtVideoBridge?.notify) {
return Promise.resolve(false)
}
const existing = pendingTranscriptionStops.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingTranscriptionReply
pending.sessionId = sessionId
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingTranscriptionStops.set(sessionId, pending)
emit({
source: 'doctor-call',
event: 'transcription-stop',
diagnosisId: activeConfig?.diagnosisId,
sessionId,
status,
})
window.setTimeout(() => {
if (pendingTranscriptionStops.get(sessionId) !== pending) return
pendingTranscriptionStops.delete(sessionId)
pending.resolve(false)
}, 15000)
return pending.promise
}
function getTranscriberManager(): RealtimeTranscriberManager {
const engine = TUICallKitAPI.getTUICallEngineInstance?.()
const cloud = engine?.getTRTCCloudInstance?.()
const manager = cloud?.getAITranscriberManager?.() as Partial<RealtimeTranscriberManager> | null
if (
!manager
|| typeof manager.addListener !== 'function'
|| typeof manager.removeListener !== 'function'
|| typeof manager.startRealtimeTranscriber !== 'function'
|| typeof manager.stopRealtimeTranscriber !== 'function'
) {
throw new Error('当前视频服务未开通实时语音转写')
}
return manager as RealtimeTranscriberManager
}
function handleTranscriberMessage(
_roomId: string | number,
message: RealtimeTranscriberMessage,
): void {
if (
!activeConfig
|| !['recording', 'stopping'].includes(transcriptionState.value)
|| !transcriptionSessionId
|| message.isCompleted !== true
) return
lastTranscriberMessageAt = Date.now()
const segmentId = String(message.segmentId ?? '').trim()
const text = String(message.sourceText ?? '').trim()
if (
!segmentId
|| acknowledgedSegmentIds.has(segmentId)
|| pendingSegments.has(segmentId)
|| !text
) return
const speakerUserId = String(message.speakerUserId ?? '').trim()
const bridgeMessage: BridgeMessage = {
source: 'doctor-call',
event: 'transcription-segment',
diagnosisId: activeConfig.diagnosisId,
sessionId: transcriptionSessionId,
segment: {
segment_id: segmentId.slice(0, 160),
speaker_user_id: speakerUserId.slice(0, 160),
speaker_role: speakerUserId === activeConfig.userID
? 'doctor'
: speakerUserId === activeConfig.targetUserId
? 'patient'
: 'unknown',
timestamp: Math.max(0, Math.trunc(Number(message.timestamp) || 0)),
text: text.slice(0, 4000),
},
}
pendingSegments.set(segmentId, { message: bridgeMessage, attempts: 1 })
emit(bridgeMessage)
}
function subscribeTranscriber(): {
manager: RealtimeTranscriberManager
listener: RealtimeTranscriberListener
} {
if (transcriberManager && transcriberListener) {
return { manager: transcriberManager, listener: transcriberListener }
}
const manager = getTranscriberManager()
const listener: RealtimeTranscriberListener = {
onReceiveTranscriberMessage: handleTranscriberMessage,
onRealtimeTranscriberStarted: () => undefined,
onRealtimeTranscriberStopped: (_roomId, robotId) => {
if (robotId !== transcriberRobotId) return
transcriberRunning = false
transcriberStoppedAt = Date.now()
if (transcriptionState.value === 'recording') {
void stopTranscription('partial', true)
}
},
onRealtimeTranscriberError: (_roomId, robotId, _error, errorMessage) => {
if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return
notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误')
void stopTranscription('partial', true)
},
}
manager.addListener(listener)
transcriberManager = manager
transcriberListener = listener
return { manager, listener }
}
function unsubscribeTranscriber(
manager = transcriberManager,
listener = transcriberListener,
): void {
if (manager && listener) {
try {
manager.removeListener(listener)
} catch {
// A destroyed call engine has already released the listener.
}
}
if (transcriberManager === manager && transcriberListener === listener) {
transcriberManager = null
transcriberListener = null
}
}
function transcriptionTokenIsCurrent(token: number, sessionId: string): boolean {
return (
token === transcriptionGeneration
&& sessionId === transcriptionSessionId
&& transcriptionState.value === 'starting'
&& phase.value === 'connected'
&& !endNotified
)
}
async function waitForPendingSegments(timeoutMs = 3000, quietMs = 300): Promise<boolean> {
const startedAt = Date.now()
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const stoppedOrSettled = transcriberStoppedAt > 0 || Date.now() - startedAt >= 500
const quiet = Date.now() - lastTranscriberMessageAt >= quietMs
if (stoppedOrSettled && quiet && pendingSegments.size === 0) return true
await new Promise((resolve) => window.setTimeout(resolve, 50))
}
return false
}
async function performStartTranscription(): Promise<void> {
if (!activeConfig || phase.value !== 'connected') throw new Error('视频接通后才能开始录音')
if (transcriptionState.value !== 'idle' && transcriptionState.value !== 'error') {
throw new Error('录音任务正在处理中')
}
transcriptionState.value = 'starting'
const sessionId = newTranscriptionSessionId()
const token = ++transcriptionGeneration
transcriptionSessionId = sessionId
acknowledgedSegmentIds.clear()
pendingSegments.clear()
lastTranscriberMessageAt = Date.now()
transcriberStoppedAt = 0
notice.value = '正在准备录音文字存储…'
const storageReady = await requestTranscriptionStart(sessionId)
if (!storageReady) {
if (!transcriptionTokenIsCurrent(token, sessionId)) return
transcriptionState.value = 'error'
transcriptionSessionId = ''
throw new Error(notice.value || '服务端无法保存本次面诊对话文字')
}
if (!transcriptionTokenIsCurrent(token, sessionId)) {
await requestTranscriptionStop(sessionId, 'partial')
return
}
let ownedManager: RealtimeTranscriberManager | null = null
let ownedListener: RealtimeTranscriberListener | null = null
try {
const subscribed = subscribeTranscriber()
const { manager, listener } = subscribed
ownedManager = manager
ownedListener = listener
const robotId = await manager.startRealtimeTranscriber({
sourceLanguage: 'zh',
})
if (!robotId) throw new Error('当前腾讯云项目未开通实时语音转写')
if (!transcriptionTokenIsCurrent(token, sessionId)) {
await manager.stopRealtimeTranscriber(robotId)
unsubscribeTranscriber(manager, listener)
return
}
transcriberRobotId = robotId
transcriberRunning = true
transcriptionState.value = 'recording'
notice.value = '正在录音并实时转换为对话文字'
} catch (error) {
unsubscribeTranscriber(ownedManager, ownedListener)
await requestTranscriptionStop(sessionId, 'failed')
if (sessionId === transcriptionSessionId) transcriptionState.value = 'error'
const message = safeErrorMessage(error, '录音转文字启动失败')
notice.value = message
if (sessionId === transcriptionSessionId) transcriptionSessionId = ''
throw new Error(message)
}
}
function startTranscription(): Promise<void> {
if (transcriptionStartPromise) return transcriptionStartPromise
const operation = performStartTranscription()
const tracked = operation.finally(() => {
if (transcriptionStartPromise === tracked) transcriptionStartPromise = null
})
transcriptionStartPromise = tracked
return transcriptionStartPromise
}
async function stopTranscription(
status: 'completed' | 'partial' | 'failed' = 'completed',
managerAlreadyStopped = false,
): Promise<void> {
if (transcriptionStopPromise) return transcriptionStopPromise
if (transcriptionState.value === 'idle') return
if (!transcriptionSessionId) {
transcriptionState.value = 'idle'
return
}
const sessionId = transcriptionSessionId
const startInFlight = transcriptionStartPromise
transcriptionGeneration += 1
transcriptionState.value = 'stopping'
transcriptionStopPromise = (async () => {
if (startInFlight) {
try {
await startInFlight
} catch {
// Its failure is materialized as a failed/partial transcript below.
}
}
let sdkStopped = managerAlreadyStopped
const manager = transcriberManager
const robotId = transcriberRobotId
if (!managerAlreadyStopped && transcriberRunning && manager && robotId) {
try {
await manager.stopRealtimeTranscriber(robotId)
sdkStopped = true
} catch (error) {
console.warn('[doctor-consultation] 停止实时转写失败', safeErrorMessage(error))
}
}
transcriberRunning = false
transcriberRobotId = ''
const segmentsComplete = await waitForPendingSegments()
unsubscribeTranscriber()
const finalStatus = sdkStopped && segmentsComplete ? status : 'partial'
const persisted = await requestTranscriptionStop(sessionId, finalStatus)
if (sessionId !== transcriptionSessionId) return
transcriptionState.value = persisted ? 'idle' : 'error'
notice.value = persisted
? '本次面诊对话文字已保存到视频记录'
: '对话文字未能完整确认保存,请稍后在面诊记录中检查'
transcriptionSessionId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
})().finally(() => {
transcriptionStopPromise = null
})
return transcriptionStopPromise
}
function transcriptionResult(
operation: 'start' | 'segment' | 'stop',
sessionId: string,
segmentId: string,
ok: boolean,
message: string,
): void {
if (operation === 'start') {
const pending = pendingTranscriptionStarts.get(sessionId)
if (!pending) return
pendingTranscriptionStarts.delete(sessionId)
if (message && sessionId === transcriptionSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (operation === 'stop') {
const pending = pendingTranscriptionStops.get(sessionId)
if (!pending) return
pendingTranscriptionStops.delete(sessionId)
if (message && sessionId === transcriptionSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (operation === 'segment' && sessionId === transcriptionSessionId) {
const pending = pendingSegments.get(segmentId)
if (!pending) return
if (ok) {
pendingSegments.delete(segmentId)
acknowledgedSegmentIds.add(segmentId)
} else if (pending.attempts < 3) {
pending.attempts += 1
window.setTimeout(() => {
if (pendingSegments.get(segmentId) === pending) emit(pending.message)
}, pending.attempts * 250)
} else {
notice.value = message || '部分对话文字保存失败,本次记录将标记为部分保存'
}
}
}
function notifyHangup(status = 'ended'): Promise<void> {
if (hangupNotification) return hangupNotification
if (endNotified) return Promise.resolve()
endNotified = true endNotified = true
phase.value = 'ended' phase.value = 'ended'
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束' statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
emit({ hangupNotification = (async () => {
source: 'doctor-call', try {
event: 'hangup', if (transcriptionState.value !== 'idle') await stopTranscription('completed')
diagnosisId: activeConfig?.diagnosisId, } catch (error) {
status, notice.value = safeErrorMessage(error, '录音文字收尾失败,请稍后检查面诊记录')
}) console.warn('[doctor-consultation] 录音文字收尾失败', notice.value)
} finally {
emit({
source: 'doctor-call',
event: 'hangup',
diagnosisId: activeConfig?.diagnosisId,
status,
})
}
})()
return hangupNotification
} }
function readRoomId(): string { function readRoomId(): string {
@@ -498,21 +935,31 @@ function handleStatusChanged(payload: unknown): void {
: payload : payload
const status = typeof value === 'string' ? value : 'unknown' const status = typeof value === 'string' ? value : 'unknown'
if (status === 'connected' || status.startsWith('calling-')) { if (status === 'connected' || status.startsWith('calling-')) {
if (endNotified || !activeConfig) return
const cycle = callCycleGeneration
phase.value = 'connected' phase.value = 'connected'
statusText.value = '视频问诊进行中' statusText.value = '视频问诊进行中'
void pollRoomId() void pollRoomId()
if (autoTranscriptionAttemptedGeneration !== cycle) {
autoTranscriptionAttemptedGeneration = cycle
void startTranscription().catch((error) => {
if (cycle !== callCycleGeneration || endNotified) return
notice.value = safeErrorMessage(error, '自动录音转文字启动失败')
})
}
} else if (status === 'calling' || status.startsWith('dialing')) { } else if (status === 'calling' || status.startsWith('dialing')) {
if (endNotified || !activeConfig) return
phase.value = 'dialing' phase.value = 'dialing'
statusText.value = '正在等待患者接听' statusText.value = '正在等待患者接听'
} else if (status === 'idle' && activeConfig && !starting) { } else if (status === 'idle' && activeConfig && !starting) {
notifyHangup(status) void notifyHangup(status)
} }
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status }) emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status })
} }
TUICallKitAPI.setCallback({ TUICallKitAPI.setCallback({
statusChanged: handleStatusChanged, statusChanged: handleStatusChanged,
afterCalling: () => notifyHangup('after-calling'), afterCalling: () => { void notifyHangup('after-calling') },
}) })
TUICallKitAPI.setLanguage('zh-cn') TUICallKitAPI.setLanguage('zh-cn')
TUICallKitAPI.enableFloatWindow(false) TUICallKitAPI.enableFloatWindow(false)
@@ -543,11 +990,17 @@ async function startVideo(): Promise<void> {
if (!activeConfig) throw new Error('问诊配置尚未准备好') if (!activeConfig) throw new Error('问诊配置尚未准备好')
if (starting || !endNotified) throw new Error('已有视频通话正在进行') if (starting || !endNotified) throw new Error('已有视频通话正在进行')
starting = true starting = true
endNotified = false
phase.value = 'starting'
statusText.value = '正在创建安全视频通话'
notice.value = ''
try { try {
if (hangupNotification) await hangupNotification
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
hangupNotification = null
callCycleGeneration += 1
endNotified = false
phase.value = 'starting'
statusText.value = '正在创建安全视频通话'
transcriptionState.value = 'idle'
transcriptionSessionId = ''
notice.value = ''
const allowed = await requestHostCallStart() const allowed = await requestHostCallStart()
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录') if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
await TUICallKitAPI.init({ await TUICallKitAPI.init({
@@ -579,10 +1032,14 @@ async function startVideo(): Promise<void> {
} }
async function hangup(): Promise<void> { async function hangup(): Promise<void> {
if (!activeConfig || endNotified) return if (!activeConfig) return
if (endNotified) {
if (hangupNotification) await hangupNotification
return
}
try { try {
await TUICallKitAPI.hangup() await TUICallKitAPI.hangup()
notifyHangup('local-hangup') await notifyHangup('local-hangup')
} catch (error) { } catch (error) {
const message = safeErrorMessage(error, '结束视频通话失败') const message = safeErrorMessage(error, '结束视频通话失败')
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message }) emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
@@ -610,7 +1067,17 @@ async function open(config: DoctorCallConfig): Promise<void> {
nextReqMessageID = '' nextReqMessageID = ''
hasMoreMessages.value = false hasMoreMessages.value = false
endNotified = true endNotified = true
hangupNotification = null
callCycleGeneration += 1
autoTranscriptionAttemptedGeneration = -1
phase.value = 'ready' phase.value = 'ready'
transcriptionState.value = 'idle'
transcriptionSessionId = ''
transcriptionGeneration += 1
transcriberRunning = false
transcriberRobotId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
notice.value = '' notice.value = ''
if (activeConfig.mode === 'chat') { if (activeConfig.mode === 'chat') {
try { try {
@@ -629,6 +1096,13 @@ async function open(config: DoctorCallConfig): Promise<void> {
async function close(): Promise<void> { async function close(): Promise<void> {
if (!endNotified) await hangup() if (!endNotified) await hangup()
if (hangupNotification) await hangupNotification
unsubscribeTranscriber()
transcriptionGeneration += 1
for (const pending of pendingTranscriptionStarts.values()) pending.resolve(false)
for (const pending of pendingTranscriptionStops.values()) pending.resolve(false)
pendingTranscriptionStarts.clear()
pendingTranscriptionStops.clear()
await logoutChat() await logoutChat()
activeConfig = null activeConfig = null
phase.value = 'ended' phase.value = 'ended'
@@ -641,6 +1115,7 @@ window.doctorConsultation = {
hangup, hangup,
hostCallReady, hostCallReady,
screenshotResult, screenshotResult,
transcriptionResult,
} }
window.doctorCall = { start: open, hangup } window.doctorCall = { start: open, hangup }
initializeQtWebChannel() initializeQtWebChannel()
@@ -655,6 +1130,7 @@ createApp(App, {
chatBusy: readonly(chatBusy), chatBusy: readonly(chatBusy),
notice: readonly(notice), notice: readonly(notice),
hasMoreMessages: readonly(hasMoreMessages), hasMoreMessages: readonly(hasMoreMessages),
transcriptionState: readonly(transcriptionState),
onSendText: sendText, onSendText: sendText,
onSendAttachment: sendAttachment, onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true), onLoadMore: () => loadMessages(true),
+25
View File
@@ -294,6 +294,31 @@ button:disabled { cursor: not-allowed; opacity: .55; }
gap: 10px; gap: 10px;
} }
.video-actions button { padding: 10px 15px; border-radius: 10px; font-weight: 600; } .video-actions button { padding: 10px 15px; border-radius: 10px; font-weight: 600; }
.recording-status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 15px;
border: 1px solid rgba(255, 255, 255, .24);
border-radius: 10px;
color: #fff;
background: rgba(22, 29, 39, .88);
font-size: 13px;
font-weight: 600;
}
.recording-status--active { border-color: rgba(244, 99, 115, .64); background: rgba(126, 35, 50, .9); }
.recording-status--error { border-color: rgba(242, 109, 109, .56); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
.recording-indicator {
width: 9px;
height: 9px;
border-radius: 50%;
background: #f46373;
box-shadow: 0 0 0 4px rgba(244, 99, 115, .16);
}
.recording-status--active .recording-indicator { animation: recording-pulse 1.25s ease-in-out infinite; }
@keyframes recording-pulse {
50% { box-shadow: 0 0 0 8px rgba(244, 99, 115, .04); opacity: .72; }
}
.hangup-button { border: 1px solid #b44755; color: #fff; background: rgba(161, 47, 61, .9); } .hangup-button { border: 1px solid #b44755; color: #fff; background: rgba(161, 47, 61, .9); }
.hangup-button:hover { background: #be394d; } .hangup-button:hover { background: #be394d; }
.video-notice { .video-notice {
@@ -361,13 +361,49 @@ class DiagnosisController extends BaseAdminController
// 传递当前管理员ID // 传递当前管理员ID
$params['admin_id'] = $this->adminId; $params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::startCall($params); $result = DiagnosisLogic::startCall($params, $this->adminInfo);
if ($result) { if ($result !== false) {
return $this->success('发起通话成功', [], 1, 1); return $this->data($result);
} }
return $this->fail(DiagnosisLogic::getError()); return $this->fail(DiagnosisLogic::getError());
} }
/** @notes 为当前医生的指定通话记录启动实时录音转写 */
public function startCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::startCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 幂等写入当前通话的已完成转写分段 */
public function upsertCallTranscriptSegments()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::upsertCallTranscriptSegments($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 完成当前通话转写并固化对话文字 */
public function finishCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::finishCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** /**
* @notes 结束通话 * @notes 结束通话
* @return \think\response\Json * @return \think\response\Json
@@ -959,11 +959,12 @@ class ConversionLogic
/** /**
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。 * 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
* *
* 口径(对齐企微客户列表,而非原始回调条数): * 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重; * - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件); * - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。 * - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中 * 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
* *
* @param array<string, mixed>|null $mediaChannel * @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds null means all active/unbound WeCom users * @param int[]|null $adminIds null means all active/unbound WeCom users
@@ -986,7 +987,7 @@ class ConversionLogic
} }
} }
$baseKey = self::requestRowsCacheKey('fans-effective-v2', [ $baseKey = self::requestRowsCacheKey('fans-effective-v3', [
$startTimestamp, $startTimestamp,
$endTimestamp, $endTimestamp,
self::mediaChannelCacheKey($mediaChannel), self::mediaChannelCacheKey($mediaChannel),
@@ -1029,6 +1030,8 @@ class ConversionLogic
->alias('e') ->alias('e')
->where('e.change_type', 'add_external_contact') ->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp]) ->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw( ->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e' 'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
. ' WHERE audit_e.user_id = e.user_id' . ' WHERE audit_e.user_id = e.user_id'
@@ -1047,8 +1050,8 @@ class ConversionLogic
. ' AND del_e.event_time <= ?)', . ' AND del_e.event_time <= ?)',
['del_external_contact', $endTimestamp] ['del_external_contact', $endTimestamp]
) )
->fieldRaw('e.user_id, COUNT(DISTINCT e.external_userid) AS add_fans_count') ->field(['e.user_id', 'e.external_userid'])
->group('e.user_id'); ->group('e.user_id, e.external_userid');
if ($workWechatUserIds !== null) { if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds); $query->whereIn('e.user_id', $workWechatUserIds);
} }
@@ -1056,11 +1059,114 @@ class ConversionLogic
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel); MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
} }
self::$requestRowsCache[$cacheKey] = $query->select()->toArray(); $pairs = $query->select()->toArray();
$pairs = self::excludeInheritedFanPairs($pairs);
$countsByUser = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
if ($userId === '') {
continue;
}
$countsByUser[$userId] = ($countsByUser[$userId] ?? 0) + 1;
}
$rows = [];
foreach ($countsByUser as $userId => $count) {
$rows[] = [
'user_id' => $userId,
'add_fans_count' => $count,
];
}
self::$requestRowsCache[$cacheKey] = $rows;
return self::$requestRowsCache[$cacheKey]; return self::$requestRowsCache[$cacheKey];
} }
/**
* 剔除企微「继承/分配」客户:跟进人 add_way 201(内部成员共享)或 202(管理员/负责人分配,含在职/离职继承)。
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
*
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeInheritedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($extId !== '') {
$externalUserIds[$extId] = true;
}
}
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
}
/** @var array<string, true> $inheritedKeys user_id\0external_userid */
$inheritedKeys = [];
foreach (array_chunk($externalIdList, 500) as $chunk) {
$contactRows = Db::name('qywx_external_contact')
->whereIn('external_userid', $chunk)
->field(['external_userid', 'follow_users'])
->select()
->toArray();
foreach ($contactRows as $contact) {
$extId = trim((string) ($contact['external_userid'] ?? ''));
if ($extId === '') {
continue;
}
$followUsers = $contact['follow_users'] ?? null;
if (\is_string($followUsers) && $followUsers !== '') {
$decoded = json_decode($followUsers, true);
$followUsers = \is_array($decoded) ? $decoded : [];
}
if (!\is_array($followUsers)) {
continue;
}
foreach ($followUsers as $fu) {
if (!\is_array($fu)) {
continue;
}
$addWay = (int) ($fu['add_way'] ?? $fu['AddWay'] ?? 0);
if ($addWay !== 201 && $addWay !== 202) {
continue;
}
$followUserId = trim((string) ($fu['userid'] ?? $fu['UserId'] ?? ''));
if ($followUserId === '') {
continue;
}
$inheritedKeys[$followUserId . "\0" . $extId] = true;
}
}
}
if ($inheritedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $extId === '') {
continue;
}
if (isset($inheritedKeys[$userId . "\0" . $extId])) {
continue;
}
$kept[] = $pair;
}
return $kept;
}
/** /**
* @param string[] $userIds * @param string[] $userIds
* @return array<string, array{id: int|string, name: string}> * @return array<string, array{id: int|string, name: string}>
@@ -1625,10 +1731,13 @@ class ConversionLogic
$entity['account_cost'] = $effectiveAccountCost; $entity['account_cost'] = $effectiveAccountCost;
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
// 预约率:面诊 / 预约(看预约后未到面)
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
// 接诊率:接诊诊单 / 总进线(加粉);医生维度仍用面诊作分母
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension); $entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
// 面诊接诊率:接诊诊单 / 面诊
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
// 面诊接诊率:面诊 / 挂号 // 面诊率:面诊 / 挂号(看挂号后流失)
$entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount); $entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -2711,6 +2820,10 @@ class ConversionLogic
return $charts; return $charts;
} }
/**
* 接诊率:接诊诊单 ÷ 总进线(加粉)。
* 医生维度无加粉口径时,退化为接诊诊单 ÷ 面诊。
*/
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
{ {
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount; $denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
+354 -12
View File
@@ -1572,32 +1572,67 @@ class DiagnosisLogic extends BaseLogic
/** /**
* @notes 发起通话 * @notes 发起通话
* @param array $params * @param array $params
* @return bool * @return array{call_record_id:int}|false
*/ */
public static function startCall(array $params): bool public static function startCall(array $params, array $adminInfo = [])
{ {
try { try {
// 获取当前管理员ID(从参数中获取) $adminId = (int)($params['admin_id'] ?? 0);
$adminId = $params['admin_id'] ?? 0; $diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$patientId = (int)($params['patient_id'] ?? 0);
if (!$adminId) { $callType = (int)($params['call_type'] ?? 2);
self::setError('获取管理员信息失败'); if ($adminId <= 0 || $diagnosisId <= 0 || $patientId <= 0) {
self::setError('通话身份参数无效');
return false;
}
if (!in_array($callType, [1, 2], true)) {
self::setError('通话类型无效');
return false;
}
$diagnosisQuery = Diagnosis::where('id', $diagnosisId)
->where('patient_id', $patientId);
$roleIds = array_map(
'intval',
AdminRole::where('admin_id', $adminId)->column('role_id')
);
if (in_array(2, $roleIds, true)) {
$diagnosisQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return false;
}
if (is_array($visibleIds)) {
$diagnosisQuery->whereIn('assistant_id', $visibleIds);
}
}
$diagnosis = $diagnosisQuery->find();
if (!$diagnosis) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false; return false;
} }
// 创建通话记录 // 创建通话记录
\app\common\model\tcm\CallRecord::create([ $record = \app\common\model\tcm\CallRecord::create([
'diagnosis_id' => $params['diagnosis_id'], 'diagnosis_id' => $diagnosisId,
'caller_id' => $adminId, 'caller_id' => $adminId,
'caller_type' => 'doctor', 'caller_type' => 'doctor',
'callee_id' => $params['patient_id'] ?? 0, 'callee_id' => $patientId,
'callee_type' => 'patient', 'callee_type' => 'patient',
'call_type' => $params['call_type'] ?? 2, // 1-语音 2-视频 'call_type' => $callType,
'status' => 1, // 1-进行中 'status' => 1, // 1-进行中
'start_time' => time() 'start_time' => time()
]); ]);
return true; $callRecordId = (int)($record['id'] ?? 0);
if ($callRecordId <= 0) {
self::setError('创建通话记录后未取得记录ID');
return false;
}
return ['call_record_id' => $callRecordId];
} catch (\Exception $e) { } catch (\Exception $e) {
self::setError($e->getMessage()); self::setError($e->getMessage());
return false; return false;
@@ -1828,6 +1863,9 @@ class DiagnosisLogic extends BaseLogic
} }
$record['recording_urls_list'] = $urls; $record['recording_urls_list'] = $urls;
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0)); $record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
$record['transcription_status_text'] = self::transcriptionStatusText(
(string)($record['transcription_status'] ?? '')
);
} }
return $records; return $records;
@@ -1837,6 +1875,310 @@ class DiagnosisLogic extends BaseLogic
} }
} }
/**
* @notes 为指定通话记录创建幂等的实时转写会话
* @return array|false
*/
public static function startCallTranscription(array $params)
{
try {
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
$language = trim((string)($params['language'] ?? 'zh-CN'));
if ($sessionId === '' || mb_strlen($sessionId) > 128) {
self::setError('转写会话ID无效');
return false;
}
if ($language === '' || mb_strlen($language) > 32) {
self::setError('转写语言无效');
return false;
}
return \think\facade\Db::transaction(function () use ($params, $sessionId, $language) {
$record = self::resolveCallRecordForTranscription($params, true, true);
if (!$record) {
return false;
}
$existingSession = trim((string)($record['transcription_session_id'] ?? ''));
if ($existingSession !== '' && $existingSession !== $sessionId) {
self::setError('该通话记录已绑定其他转写会话');
return false;
}
if ($existingSession === '') {
$now = time();
$record->save([
'transcription_session_id' => $sessionId,
'transcription_language' => $language,
'transcription_status' => 'running',
'transcription_segment_count' => 0,
'transcript_text' => '',
'transcription_started_at' => $now,
'transcription_finished_at' => 0,
'update_time' => $now,
]);
}
return [
'call_record_id' => (int)$record['id'],
'transcription_session_id' => $sessionId,
'status' => (string)($record['transcription_status'] ?? 'running'),
];
});
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 幂等写入已完成的实时转写分段,并刷新通话记录文字字段
* @return array|false
*/
public static function upsertCallTranscriptSegments(array $params)
{
try {
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
$segments = $params['segments'] ?? null;
if ($sessionId === '' || mb_strlen($sessionId) > 128) {
self::setError('转写会话ID无效');
return false;
}
if (!is_array($segments) || count($segments) < 1 || count($segments) > 50) {
self::setError('转写分段数量必须为1到50条');
return false;
}
$normalized = [];
foreach ($segments as $segment) {
if (!is_array($segment)) {
self::setError('转写分段格式无效');
return false;
}
$segmentId = trim((string)($segment['segment_id'] ?? ''));
$text = trim((string)($segment['text'] ?? ''));
$speakerUserId = trim((string)($segment['speaker_user_id'] ?? ''));
$speakerRole = trim((string)($segment['speaker_role'] ?? 'unknown'));
if ($segmentId === '' || mb_strlen($segmentId) > 160) {
self::setError('转写分段ID无效');
return false;
}
if ($text === '' || mb_strlen($text) > 4000) {
self::setError('转写文字长度无效');
return false;
}
if (mb_strlen($speakerUserId) > 160) {
self::setError('说话人ID过长');
return false;
}
if (!in_array($speakerRole, ['doctor', 'patient', 'unknown'], true)) {
$speakerRole = 'unknown';
}
$normalized[] = [
'segment_id' => $segmentId,
'speaker_user_id' => $speakerUserId,
'speaker_role' => $speakerRole,
'timestamp_ms' => max(0, (int)($segment['timestamp'] ?? 0)),
'text' => $text,
];
}
$result = \think\facade\Db::transaction(function () use (
$params,
$sessionId,
$normalized
) {
$record = self::resolveCallRecordForTranscription($params, false, true);
if (!$record) {
return false;
}
if ((string)($record['transcription_session_id'] ?? '') !== $sessionId) {
self::setError('转写会话与通话记录不匹配');
return false;
}
if ((string)($record['transcription_status'] ?? '') !== 'running') {
self::setError('该通话转写已经结束');
return false;
}
$callRecordId = (int)$record['id'];
$now = time();
foreach ($normalized as $segment) {
$values = array_merge($segment, [
'call_record_id' => $callRecordId,
'transcription_session_id' => $sessionId,
'create_time' => $now,
'update_time' => $now,
]);
\think\facade\Db::name('tcm_call_transcript_segment')
->duplicate([
'speaker_user_id',
'speaker_role',
'timestamp_ms',
'text',
'update_time',
])
->insert($values);
}
$snapshot = self::buildCallTranscriptSnapshot($callRecordId, $sessionId);
$record->save([
'transcription_segment_count' => $snapshot['segment_count'],
'transcript_text' => $snapshot['transcript_text'],
'update_time' => $now,
]);
return [
'call_record_id' => $callRecordId,
'segment_count' => (int)$snapshot['segment_count'],
];
});
if ($result === false) {
return false;
}
return [
'call_record_id' => (int)$result['call_record_id'],
'transcription_session_id' => $sessionId,
'stored_segment_count' => (int)$result['segment_count'],
];
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 完成实时转写并将最终文字固化在通话记录上
* @return array|false
*/
public static function finishCallTranscription(array $params)
{
try {
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
$requestedStatus = strtolower(trim((string)($params['status'] ?? 'completed')));
$expectedCount = (int)($params['expected_segment_count'] ?? 0);
if ($sessionId === '' || mb_strlen($sessionId) > 128 || $expectedCount < 0) {
self::setError('转写完成参数无效');
return false;
}
if (!in_array($requestedStatus, ['completed', 'partial', 'failed'], true)) {
self::setError('转写完成状态无效');
return false;
}
return \think\facade\Db::transaction(function () use (
$params,
$sessionId,
$requestedStatus,
$expectedCount
) {
$record = self::resolveCallRecordForTranscription($params, false, true);
if (!$record) {
return false;
}
if ((string)($record['transcription_session_id'] ?? '') !== $sessionId) {
self::setError('转写会话与通话记录不匹配');
return false;
}
$existingStatus = (string)($record['transcription_status'] ?? '');
if ($existingStatus !== '' && $existingStatus !== 'running') {
return [
'call_record_id' => (int)$record['id'],
'transcription_session_id' => $sessionId,
'status' => $existingStatus,
'segment_count' => (int)($record['transcription_segment_count'] ?? 0),
];
}
$callRecordId = (int)$record['id'];
$snapshot = self::buildCallTranscriptSnapshot($callRecordId, $sessionId);
$actualCount = (int)$snapshot['segment_count'];
$finalStatus = $requestedStatus;
if ($requestedStatus === 'completed' && $actualCount < $expectedCount) {
$finalStatus = 'partial';
}
$now = time();
$record->save([
'transcription_status' => $finalStatus,
'transcription_segment_count' => $actualCount,
'transcript_text' => $snapshot['transcript_text'],
'transcription_finished_at' => $now,
'update_time' => $now,
]);
return [
'call_record_id' => $callRecordId,
'transcription_session_id' => $sessionId,
'status' => $finalStatus,
'segment_count' => $actualCount,
];
});
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
private static function resolveCallRecordForTranscription(
array $params,
bool $requireRunning,
bool $lock = false
)
{
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $callRecordId <= 0 || $adminId <= 0) {
self::setError('通话转写身份参数无效');
return null;
}
$query = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor');
if ($lock) {
$query->lock(true);
}
$record = $query->find();
if (!$record) {
self::setError('通话记录不存在或无权操作');
return null;
}
if ($requireRunning && (int)($record['status'] ?? 0) !== 1) {
self::setError('通话记录已经结束');
return null;
}
return $record;
}
/** @return array{segment_count:int,transcript_text:string} */
private static function buildCallTranscriptSnapshot(int $callRecordId, string $sessionId): array
{
$rows = \think\facade\Db::name('tcm_call_transcript_segment')
->where('call_record_id', $callRecordId)
->where('transcription_session_id', $sessionId)
->order('timestamp_ms asc, id asc')
->select()
->toArray();
$labels = ['doctor' => '医生', 'patient' => '患者', 'unknown' => '未知说话人'];
$lines = [];
foreach ($rows as $row) {
$text = trim((string)($row['text'] ?? ''));
if ($text === '') {
continue;
}
$role = (string)($row['speaker_role'] ?? 'unknown');
$lines[] = ($labels[$role] ?? $labels['unknown']) . '' . $text;
}
return [
'segment_count' => count($rows),
'transcript_text' => implode("\n", $lines),
];
}
private static function transcriptionStatusText(string $status): string
{
return [
'running' => '录音转写中',
'completed' => '文字已生成',
'partial' => '文字部分保存',
'failed' => '文字生成失败',
][$status] ?? '未生成文字';
}
/** /**
* @notes TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制 * @notes TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制
* @return array{cloud_recording?:array}|false 成功返回 data 数组(供接口带给前端);失败 false * @return array{cloud_recording?:array}|false 成功返回 data 数组(供接口带给前端);失败 false
@@ -0,0 +1,27 @@
-- 医生工作站视频接通后的实时录音转写。
-- 转写摘要固化到每条通话记录,分段表用唯一键保证 WebChannel 重试幂等。
ALTER TABLE `zyt_tcm_call_record`
ADD COLUMN `transcription_session_id` varchar(128) NOT NULL DEFAULT '' COMMENT '实时转写会话ID',
ADD COLUMN `transcription_language` varchar(32) NOT NULL DEFAULT '' COMMENT '转写源语言',
ADD COLUMN `transcription_status` varchar(16) NOT NULL DEFAULT '' COMMENT 'running/completed/partial/failed',
ADD COLUMN `transcription_segment_count` int unsigned NOT NULL DEFAULT 0 COMMENT '已持久化完成分段数',
ADD COLUMN `transcript_text` longtext NULL COMMENT '按时间和说话人生成的面诊对话文字',
ADD COLUMN `transcription_started_at` int unsigned NOT NULL DEFAULT 0 COMMENT '转写开始Unix时间',
ADD COLUMN `transcription_finished_at` int unsigned NOT NULL DEFAULT 0 COMMENT '转写完成Unix时间';
CREATE TABLE IF NOT EXISTS `zyt_tcm_call_transcript_segment` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '分段ID',
`call_record_id` int unsigned NOT NULL COMMENT '通话记录ID',
`transcription_session_id` varchar(128) NOT NULL COMMENT '实时转写会话ID',
`segment_id` varchar(160) NOT NULL COMMENT '腾讯云完成分段ID',
`speaker_user_id` varchar(160) NOT NULL DEFAULT '' COMMENT '说话人TRTC用户ID',
`speaker_role` varchar(20) NOT NULL DEFAULT 'unknown' COMMENT 'doctor/patient/unknown',
`timestamp_ms` bigint unsigned NOT NULL DEFAULT 0 COMMENT '分段时间戳(毫秒)',
`text` text NOT NULL COMMENT '完成分段文字,最多4000字符',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_call_session_segment` (`call_record_id`, `transcription_session_id`, `segment_id`),
KEY `idx_call_session_time` (`call_record_id`, `transcription_session_id`, `timestamp_ms`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频面诊实时转写完成分段';
@@ -0,0 +1,89 @@
-- 实时转写接口继承现有“1v1通话”权限,避免未注册 action 被默认放行。
START TRANSACTION;
SET @video_call_menu_id := (
SELECT `id`
FROM `zyt_system_menu`
WHERE `perms` = 'tcm.diagnosis/video-call'
ORDER BY `id`
LIMIT 1
);
INSERT INTO `zyt_system_menu` (
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
)
SELECT @video_call_menu_id, 'A', '启动通话转写', '', 1,
'tcm.diagnosis/startCallTranscription', '', '', '', '', 0, 1, 0,
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @video_call_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `perms` = 'tcm.diagnosis/startCallTranscription'
);
INSERT INTO `zyt_system_menu` (
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
)
SELECT @video_call_menu_id, 'A', '保存通话转写分段', '', 2,
'tcm.diagnosis/upsertCallTranscriptSegments', '', '', '', '', 0, 1, 0,
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @video_call_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `perms` = 'tcm.diagnosis/upsertCallTranscriptSegments'
);
INSERT INTO `zyt_system_menu` (
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
)
SELECT @video_call_menu_id, 'A', '完成通话转写', '', 3,
'tcm.diagnosis/finishCallTranscription', '', '', '', '', 0, 1, 0,
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @video_call_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `perms` = 'tcm.diagnosis/finishCallTranscription'
);
SET @transcription_start_menu_id := (
SELECT `id` FROM `zyt_system_menu`
WHERE `perms` = 'tcm.diagnosis/startCallTranscription'
ORDER BY `id` LIMIT 1
);
SET @transcription_segment_menu_id := (
SELECT `id` FROM `zyt_system_menu`
WHERE `perms` = 'tcm.diagnosis/upsertCallTranscriptSegments'
ORDER BY `id` LIMIT 1
);
SET @transcription_finish_menu_id := (
SELECT `id` FROM `zyt_system_menu`
WHERE `perms` = 'tcm.diagnosis/finishCallTranscription'
ORDER BY `id` LIMIT 1
);
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
SELECT `role_id`, @transcription_start_menu_id
FROM `zyt_system_role_menu`
WHERE `menu_id` = @video_call_menu_id
AND @transcription_start_menu_id IS NOT NULL;
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
SELECT `role_id`, @transcription_segment_menu_id
FROM `zyt_system_role_menu`
WHERE `menu_id` = @video_call_menu_id
AND @transcription_segment_menu_id IS NOT NULL;
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
SELECT `role_id`, @transcription_finish_menu_id
FROM `zyt_system_role_menu`
WHERE `menu_id` = @video_call_menu_id
AND @transcription_finish_menu_id IS NOT NULL;
COMMIT;