新增功能

This commit is contained in:
Your Name
2026-04-01 09:46:25 +08:00
parent 099bc1dd22
commit a780356908
332 changed files with 7845 additions and 866 deletions
+172 -14
View File
@@ -1,20 +1,178 @@
/** 视频通话区域截取当前最大的一路 video 帧(WebRTC 画面 */
export async function captureVideoFrameFromElement(
container: HTMLElement
): Promise<Blob | null> {
const videos = Array.from(container.querySelectorAll('video')) as HTMLVideoElement[]
let best: HTMLVideoElement | null = null
let bestArea = 0
/** 视频 track 是否在播(排除已结束轨道 */
function hasLiveVideoTrack(video: HTMLVideoElement): boolean {
const so = video.srcObject
if (!(so instanceof MediaStream)) return false
return so.getVideoTracks().some((t) => t.readyState === 'live')
}
/** 页面上实际占位面积(主画面远大于本地小窗) */
function layoutArea(v: HTMLVideoElement): number {
const r = v.getBoundingClientRect()
return Math.max(0, r.width) * Math.max(0, r.height)
}
function videoPixelArea(v: HTMLVideoElement): number {
const vw = v.videoWidth || v.clientWidth
const vh = v.videoHeight || v.clientHeight
return vw * vh
}
/** 同一路 MediaStream 可能挂多个 video,只保留占位最大的一路,避免重复逻辑与歧义 */
function dedupeVideosByStream(videos: HTMLVideoElement[]): HTMLVideoElement[] {
const byKey = new Map<string, HTMLVideoElement>()
const areaByKey = new Map<string, number>()
for (const v of videos) {
if (v.readyState < 2) continue
const vw = v.videoWidth || v.clientWidth
const vh = v.videoHeight || v.clientHeight
const area = vw * vh
if (area > bestArea) {
bestArea = area
best = v
const so = v.srcObject
if (!(so instanceof MediaStream)) continue
const tracks = so.getVideoTracks()
const key =
(so.id && String(so.id)) ||
tracks
.map((t) => t.id)
.filter(Boolean)
.join('|') ||
`${tracks.length}`
const la = layoutArea(v)
const prev = areaByKey.get(key) ?? 0
if (la >= prev) {
byKey.set(key, v)
areaByKey.set(key, la)
}
}
if (byKey.size === 0) return videos
return Array.from(byKey.values())
}
/**
* 1v1 常见:远端全屏 + 本地小窗。去掉占位明显偏小的一路(画中画),只留主画面候选。
*/
function dropObviousPip(videos: HTMLVideoElement[]): HTMLVideoElement[] {
if (videos.length < 2) return videos
let maxL = 0
for (const v of videos) maxL = Math.max(maxL, layoutArea(v))
if (maxL <= 1) return videos
const minL = Math.min(...videos.map((v) => layoutArea(v)))
if (minL / maxL >= 0.25) return videos
return videos.filter((v) => layoutArea(v) >= maxL * 0.25)
}
/** 本地预览常见:镜像(scaleX(-1) */
function hasMirrorTransform(el: HTMLElement): boolean {
let p: HTMLElement | null = el
for (let i = 0; i < 12 && p; i++) {
const t = getComputedStyle(p).transform
if (t && t !== 'none') {
if (/matrix\(\s*-1\s*,/.test(t) || /scaleX\(\s*-1/.test(t)) return true
}
p = p.parentElement
}
return false
}
/**
* 根据 DOM 判断是否为「本方/本地」预览(腾讯云 TUICall / TRTC 常见 class 及通用命名)
* 注意:不要用泛化的 "player",否则本地/远端容器类名都会命中,导致无法区分。
*/
function isLikelyLocalPreviewVideo(video: HTMLVideoElement): boolean {
const localHints =
/local|self|pusher|mini[_-]?stream|preview[_-]?self|own[_-]?camera|tui-local|tencent-local|stream-local|publisher|send|pip|picture[_-]?in[_-]?picture/i
const remoteHints =
/remote|subscriber|peer|other|tui-remote|stream-remote|big-stream|play[_-]?stream|receiver|pull|substream/i
let el: HTMLElement | null = video
for (let depth = 0; depth < 20 && el; depth++) {
const s = `${el.className || ''} ${el.id || ''} ${el.getAttribute('data-type') || ''}`
if (remoteHints.test(s)) return false
if (localHints.test(s)) return true
el = el.parentElement
}
return false
}
/**
* 从通话区域截取一帧 JPEG。
* 默认优先截取「对方/远端」画面:去重 MediaStream、去掉画中画小窗、排除本地预览与镜像节点。
*/
export async function captureVideoFrameFromElement(
searchRoot: HTMLElement | null,
options?: { preferRemote?: boolean; extraRoots?: (HTMLElement | null)[] }
): Promise<Blob | null> {
const preferRemote = options?.preferRemote !== false
const roots = [searchRoot, ...(options?.extraRoots || [])].filter((r): r is HTMLElement => !!r)
const videoSet = new Set<HTMLVideoElement>()
for (const r of roots) {
for (const v of r.querySelectorAll('video')) {
if (v instanceof HTMLVideoElement) videoSet.add(v)
}
}
if (videoSet.size === 0) {
const wrap = document.querySelector('.chat-dialog-call-kit-wrapper')
if (wrap) {
for (const v of wrap.querySelectorAll('video')) {
if (v instanceof HTMLVideoElement) videoSet.add(v)
}
}
}
if (videoSet.size === 0) {
document.body.querySelectorAll('video').forEach((v) => {
if (v instanceof HTMLVideoElement && hasLiveVideoTrack(v)) videoSet.add(v)
})
}
let candidates = Array.from(videoSet).filter(
(v) => v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && hasLiveVideoTrack(v)
)
if (candidates.length === 0) {
candidates = Array.from(videoSet).filter((v) => v.readyState >= 2)
}
if (candidates.length === 0) return null
candidates = dedupeVideosByStream(candidates)
let best: HTMLVideoElement | null = null
let bestLayout = 0
let bestPixel = 0
if (preferRemote) {
const scored = candidates.map((v) => ({
v,
layout: layoutArea(v),
pixel: videoPixelArea(v),
localClass: isLikelyLocalPreviewVideo(v),
mirror: hasMirrorTransform(v)
}))
let pool = scored.filter((x) => !x.localClass && !x.mirror)
if (pool.length === 0) pool = scored.filter((x) => !x.localClass)
if (pool.length === 0) pool = scored.filter((x) => !x.mirror)
if (pool.length === 0) pool = scored
let videos = pool.map((x) => x.v)
videos = dropObviousPip(videos)
pool = pool.filter((x) => videos.includes(x.v))
for (const x of pool) {
if (x.layout > bestLayout || (x.layout === bestLayout && x.pixel > bestPixel)) {
bestLayout = x.layout
bestPixel = x.pixel
best = x.v
}
}
} else {
for (const v of candidates) {
const la = layoutArea(v)
const pa = videoPixelArea(v)
if (la > bestLayout || (la === bestLayout && pa > bestPixel)) {
bestLayout = la
bestPixel = pa
best = v
}
}
}
if (!best) return null
const vw = best.videoWidth || best.clientWidth
const vh = best.videoHeight || best.clientHeight
+9
View File
@@ -0,0 +1,9 @@
/**
* 跨组件同步诊单数据(如视频截屏写入舌苔照后刷新编辑抽屉)
*/
export const DIAGNOSIS_TONGUE_IMAGES_UPDATED = 'zyt:diagnosis-tongue-images-updated'
export type DiagnosisTongueImagesUpdatedDetail = {
diagnosisId: number
tongue_images: string[]
}
@@ -0,0 +1,106 @@
import dayjs from 'dayjs'
export type FriendlyParse = { main: string; sub?: string; tag?: string }
/** 业务时间:支持秒级时间戳或毫秒(字符串数字) */
export function formatBizTime(t: unknown): string | undefined {
if (t == null || t === '') return undefined
const n =
typeof t === 'string' && /^\d+$/.test(t.trim())
? parseInt(t.trim(), 10)
: Number(t)
if (!Number.isFinite(n) || n <= 0) return undefined
return n > 1e12
? dayjs(n).format('YYYY-MM-DD HH:mm:ss')
: dayjs.unix(n).format('YYYY-MM-DD HH:mm:ss')
}
export function parseRtcInner(inner: Record<string, unknown>): FriendlyParse {
const callType = inner.call_type
const callTypeLabel =
callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话'
const cmd = String(inner.cmd || '')
const cmdMap: Record<string, string> = {
hangup: '已结束',
invite: '发起通话',
linebusy: '对方忙线',
cancel: '已取消',
reject: '已拒绝',
accept: '已接听',
timeout: '无人接听'
}
const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '')
const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel
const parts: string[] = []
if (inner.inviter) parts.push(`发起方 ${inner.inviter}`)
if (inner.invitee) parts.push(`对方 ${inner.invitee}`)
if (inner.groupID) parts.push(`群组 ${inner.groupID}`)
return {
main,
sub: parts.length ? parts.join('') : undefined,
tag: '通话'
}
}
/**
* 将 IM 自定义 / 信令 JSON 解析为可读文案(医生进诊室、音视频通话等)。
* 与后台 IM 漫游、小程序 TUIKit 约定字段一致。
*/
export function parseImBusinessPayload(raw: string): FriendlyParse | null {
const s = raw.trim()
if (!s.startsWith('{')) return null
let o: Record<string, unknown>
try {
o = JSON.parse(s) as Record<string, unknown>
} catch {
return null
}
if (!('businessID' in o) && !('cmd' in o)) {
return null
}
if (o.businessID === 'doctor_entered_consult_room') {
const t = formatBizTime(o.time)
return { main: '医生已进入诊室', sub: t, tag: '诊室' }
}
if (o.businessID === 'patient_opened_chat') {
const parts: string[] = []
if (o.patientName) parts.push(`患者:${String(o.patientName)}`)
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID${String(o.patientId)}`)
if (o.doctorId != null && o.doctorId !== '') parts.push(`关联医生:${String(o.doctorId)}`)
const t = formatBizTime(o.time)
if (t) parts.push(t)
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
}
if (o.businessID === 'user_typing_status') {
return { main: '对方正在输入…', tag: '状态' }
}
if (o.businessID === 'consultation_complete') {
return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' }
}
if (o.businessID === 1 || o.businessID === '1') {
let inner: unknown = o.data
if (typeof inner === 'string') {
try {
inner = JSON.parse(inner)
} catch {
return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' }
}
}
if (inner && typeof inner === 'object') {
return parseRtcInner(inner as Record<string, unknown>)
}
return null
}
if (o.businessID === 'rtc_call' || o.cmd) {
return parseRtcInner(o as Record<string, unknown>)
}
return null
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 判断 TIM 自定义消息 payload.data 是否表示视频通话结束/挂断/未接通,
* 用于 PC 管理端在 IM 收信后立即 endCall → DeleteCloudRecording,不依赖小程序上报。
*/
function parseInnerData(data: unknown): Record<string, unknown> | null {
if (data == null) return null
if (typeof data === 'object' && !Array.isArray(data)) return data as Record<string, unknown>
if (typeof data !== 'string') return null
const t = data.trim()
if (!t.startsWith('{')) return null
try {
return JSON.parse(t) as Record<string, unknown>
} catch {
return null
}
}
function rtcCmdIndicatesHangup(cmd: string): boolean {
const c = cmd.toLowerCase()
return ['hangup', 'cancel', 'reject', 'timeout', 'linebusy', 'end'].includes(c)
}
/**
* @param raw TIM 自定义消息外层 JSON 字符串(payload.data
*/
export function imCustomDataIndicatesVideoHangup(raw: string): boolean {
const s = raw.trim()
if (!s) return false
// 部分信令不落标准 businessID,整段字符串匹配(如含 call_end / call_engine
if (/\bcall_end\b/i.test(s) && (/call_engine|call_record|rtc/i.test(s) || /"reason"\s*:\s*\d/.test(s))) {
return true
}
if (/call_engine_srv\.[a-z_]*call[a-z_]*/i.test(s) && /hangup|end|leave|reject|cancel/i.test(s)) {
return true
}
if (!s.startsWith('{')) return false
let o: Record<string, unknown>
try {
o = JSON.parse(s) as Record<string, unknown>
} catch {
return false
}
// 标准音视频 businessID: 1
if (o.businessID === 1 || o.businessID === '1') {
const inner = parseInnerData(o.data)
if (inner) {
const cmd = String(inner.cmd ?? '')
if (rtcCmdIndicatesHangup(cmd)) return true
}
}
if (o.businessID === 'rtc_call' || typeof o.cmd === 'string') {
if (rtcCmdIndicatesHangup(String(o.cmd ?? ''))) return true
}
const nested = JSON.stringify(o)
if (nested.includes('call_engine_srv') && /hangup|call_end|CALL_END|reject|cancel/i.test(nested)) {
return true
}
return false
}