diff --git a/admin/src/App.vue b/admin/src/App.vue index 2dee8388e..a91aa8df7 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app' import useSettingStore from './stores/modules/setting' import useUserStore from './stores/modules/user' import ChatNotifyToast from './components/chat-notify-toast/index.vue' +import RevisitDueToast from './components/revisit-due-toast/index.vue' const appStore = useAppStore() const settingStore = useSettingStore() @@ -58,6 +59,8 @@ watch( + + diff --git a/admin/src/api/app.ts b/admin/src/api/app.ts index fec3423be..a2a103a46 100644 --- a/admin/src/api/app.ts +++ b/admin/src/api/app.ts @@ -5,6 +5,14 @@ export function getConfig() { return request.get({ url: '/config/getConfig' }) } +// 运行时读取医生网页面诊切换时间,与服务端拦截规则共用配置。 +export function getDoctorWebConsultationPolicy() { + return request.get( + { url: '/config/doctorWebConsultation', params: { _t: Date.now() } }, + { withToken: false, isOpenRetry: false } + ) +} + // 工作台主页 export function getWorkbench() { return request.get({ url: '/workbench/index' }) diff --git a/admin/src/api/iam-v2.ts b/admin/src/api/iam-v2.ts new file mode 100644 index 000000000..b4bf71a57 --- /dev/null +++ b/admin/src/api/iam-v2.ts @@ -0,0 +1,37 @@ +import request from '@/utils/request' +export type IdentityOperation = { + kind: 'bind' | 'create_and_bind' | 'unbind' | 'rebind' | 'reconcile' + user_id: number + employee_id?: string + expected_version?: number + new_employee?: { username: string; display_name: string; email?: string } + reason: string +} +const prefix = '/iam/v2' +const opts = { isOpenRetry: false, ignoreCancelToken: true } +export const iamGet = (path: string, params?: Record): Promise => + request.get({ url: prefix + path, params, withCredentials: true }, opts) +export const iamPost = ( + path: string, + params: Record = {}, + key = crypto.randomUUID() +): Promise => + request.post( + { url: prefix + path, params, headers: { 'Idempotency-Key': key }, withCredentials: true }, + opts + ) +export const identityError = (e: any): string => + ({ + REAUTH_REQUIRED: '请先验证当前账号密码,或通过企业微信重新登录。', + ACCOUNT_MISMATCH: '两端账号不一致,请确认身份后重新发起。', + NOT_LINKED: '当前账号还未关联 IAM。', + NOT_AUTHORIZED: 'IAM 尚未授予目标应用访问权限。', + BINDING_CONFLICT: '关联已变更,请刷新后重新确认。', + PROTECTED_ACCOUNT_REVIEW_REQUIRED: '敏感账号需要双侧近期管理员核验,请由统一身份平台审核。', + SERVICE_UNAVAILABLE: '统一身份服务暂时不可用,状态未确认,请稍后重试。', + FEATURE_DISABLED: 'IAM 关联管理尚未启用。', + FORBIDDEN: '没有当前操作权限或目标超出数据范围。', + TRANSACTION_EXPIRED: '验证已过期或已经使用,请重新发起。' + })[e?.error?.code as string] || + e?.error?.code || + '操作未完成,请刷新状态后重试。' diff --git a/admin/src/api/revisitDue.ts b/admin/src/api/revisitDue.ts new file mode 100644 index 000000000..2ed0663a9 --- /dev/null +++ b/admin/src/api/revisitDue.ts @@ -0,0 +1,195 @@ +import request from '@/utils/request' + +/** + * 复诊到期弹窗(需求 4)接口:/adminapi/tcm.revisitDue/ + * 契约见 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md §3.3。 + * 写接口后端 show=1,成功提示由 utils/request 统一弹出,调用方不要重复 ElMessage。 + */ + +/** 弹窗条目(RevisitDueLogic::formatItem) */ +export interface RevisitDueToastItem { + id: number + diagnosis_id: number + assistant_id: number + patient_name: string + current_slot: number + next_slot: number + medication_days: number + /** 'YYYY-MM-DD' 或 '' */ + due_date: string + overdue_days: number | null + /** overdue | missed(弹窗只含这两态) */ + state: string + state_text: string + severe: 0 | 1 + missed_count: number + snooze_count: number + /** 0~3,3 = 居中模态 */ + escalation_level: number + contact_count: number + promised_date: string + next_remind_at: number + next_remind_at_text: string + /** 下一次点「延迟」会顺延的秒数(按钮文案用) */ + next_snooze_seconds: number + appointment_date: string + closed_state: number +} + +/** 汇总计数(RevisitDueLogic::buildSummary) */ +export interface RevisitDueToastSummary { + enabled: boolean + due_total: number + max_overdue_days: number + warning_total: number + promise_max_days: number + contact_max_per_cycle: number + work_hours: [string, string] +} + +export interface RevisitDuePendingResult { + items: RevisitDueToastItem[] + summary: RevisitDueToastSummary +} + +/** 写接口返回处理后的当前行(幂等:被他人处理时也返回当前行) */ +export type RevisitDueWriteResult = RevisitDueToastItem + +export interface RevisitDueSnoozeAllResult { + items: RevisitDueToastItem[] + snoozed: number + skipped: number + message: string +} + +/** 轮询接口不走统一 code 处理:权限不足 / 开关关闭时不要每分钟弹一次错误提示 */ +const POLL_OPTIONS = { + isTransformResponse: false, + ignoreCancelToken: true, + isOpenRetry: false, + retryCount: 0 +} + +/** + * 弹窗待办(60s 轮询):本人到期未挂号 / 过号且已到提醒时刻的项,每次返回全集。 + * 非 code=1 一律 reject(调用方静默处理),避免轮询刷错误提示。 + */ +export async function revisitDuePending(): Promise { + const res = await request.get({ url: '/tcm.revisitDue/pending' }, POLL_OPTIONS) + if (res && typeof res === 'object' && Number(res.code) === 1) { + return res.data as RevisitDuePendingResult + } + return Promise.reject(res?.msg ?? 'revisit due pending failed') +} + +/** 仅计数,不更新弹出时间 */ +export function revisitDueSummary(): Promise { + return request.get({ url: '/tcm.revisitDue/summary' }) +} + +/** 延迟一次(幂等) */ +export function revisitDueSnooze(params: { id: number }): Promise { + return request.post({ url: '/tcm.revisitDue/snooze', params }) +} + +/** 批量延迟(仅 escalation_level<=1 的项) */ +export function revisitDueSnoozeAll(params: { + ids: number[] +}): Promise { + return request.post({ url: '/tcm.revisitDue/snoozeAll', params }) +} + +/** 已联系:记录患者承诺复诊日 */ +export function revisitDueContact(params: { + id: number + promised_date: string + remark?: string +}): Promise { + return request.post({ url: '/tcm.revisitDue/contact', params }) +} + +/** 人工关闭(reason lost|other,remark 必填 ≤200) */ +export function revisitDueClose(params: { + id: number + reason: 'lost' | 'other' + remark: string +}): Promise { + return request.post({ url: '/tcm.revisitDue/close', params }) +} + +/** 主管视图列表行(RevisitDueLists,按角色 data_scope 收窄) */ +export interface RevisitDueListRow { + id: number + diagnosis_id: number + patient_name: string + assistant_id: number + assistant_name: string + dept_name: string + current_slot: number + next_slot: number + medication_days: number + /** 14/21/28/30 或 0=其它 */ + rule_days: number + anchor_date: string + due_date: string + overdue_days: number | null + days_left: number | null + state: string + state_text: string + severe: 0 | 1 + appointment_state: number + appointment_date: string + snooze_count: number + escalation_level: number + contact_count: number + promised_date: string + missed_count: number + closed_state: number + closed_state_text: string + handle_reason: string + handle_remark: string + first_shown_at_text: string + last_snoozed_at_text: string + computed_at_text: string +} + +/** 主管视图筛选参数(state:due|warning|booked|visited|contacted|no_days|pending|closed;days_bucket:14|21|28|30|other) */ +export interface RevisitDueListsParams { + assistant_id?: number | '' + dept_id?: number | '' + state?: string + days_bucket?: string + min_snooze?: number | '' + keyword?: string + page_no?: number + page_size?: number + /** 1 = 导出(export-data 组件追加) */ + export?: number + [key: string]: any +} + +/** 主管视图:复诊到期跟进分页列表(lists/count/page_no/page_size/extend 包络,供 usePaging / export-data 消费) */ +export function revisitDueLists(params: RevisitDueListsParams) { + return request.get({ url: '/tcm.revisitDue/lists', params }) +} + +/** 事件流水行(RevisitDueLogic::logs) */ +export interface RevisitDueLogRow { + id: number + /** 1 延迟 2 已联系 3 人工关闭 4 自动关闭-已挂号 5 自动关闭-已面诊 6 周期重置 7 重新打开 8 企微推送 9 医助变更 10 形态升级 11 首次弹出 */ + event: number + event_text: string + snooze_count_after: number + level_after: number + overdue_days: number + /** payload JSON 解析后的对象(next_remind_at / promised_date / appointment_id / order_id / remark / from_assistant 等) */ + payload_arr: Record + operator_id: number + operator_name: string + create_time_text: string +} + +/** 主管视图:某条提醒的事件流水(按 due_id,时间倒序由后端决定) */ +export function revisitDueLogs(params: { due_id: number }) { + return request.get({ url: '/tcm.revisitDue/logs', params }) +} diff --git a/admin/src/api/setting/desktop_workstation.ts b/admin/src/api/setting/desktop_workstation.ts index 5077a7bde..b129a8a28 100644 --- a/admin/src/api/setting/desktop_workstation.ts +++ b/admin/src/api/setting/desktop_workstation.ts @@ -24,6 +24,24 @@ export type DesktopWorkstationConfig = { } } +export type DesktopWorkstationDownload = DesktopPackage & { + platform: 'windows_x64' | 'macos_arm64' | 'macos_x64' + label: string +} + +export type DesktopWorkstationDownloads = { + version: string + packages: DesktopWorkstationDownload[] +} + +// 仅返回已发布的安装包,不要求医生拥有系统设置权限。 +export function getDesktopWorkstationDownloads() { + return request.get( + { url: '/setting.desktop_workstation/downloads' }, + { withToken: false } + ) as Promise +} + export function getDesktopWorkstationConfig() { return request.get({ url: '/setting.desktop_workstation/getConfig' diff --git a/admin/src/api/setting/prescription_ai_visibility.ts b/admin/src/api/setting/prescription_ai_visibility.ts new file mode 100644 index 000000000..0ed04c953 --- /dev/null +++ b/admin/src/api/setting/prescription_ai_visibility.ts @@ -0,0 +1,49 @@ +import request from '@/utils/request' + +export interface PrescriptionAiVisibilityConfig { + role_ids: number[] + admin_ids: number[] +} + +export interface PrescriptionAiVisibilityOptions { + roles: { id: number; name: string }[] + admins: { id: number; name: string; account: string }[] +} + +export interface PrescriptionAiCapabilities { + agreement_visible: boolean + can_manage_visibility: boolean +} + +// A failed or malformed read must never become an empty policy that can be saved. +function readVisibilityConfig(value: unknown): PrescriptionAiVisibilityConfig { + const config = value as Partial | null + const validIds = (ids: unknown): ids is number[] => + Array.isArray(ids) && ids.every((id) => Number.isSafeInteger(id) && id > 0) + if (!config || !validIds(config.role_ids) || !validIds(config.admin_ids)) { + throw new Error('可见范围配置响应格式不正确') + } + return { role_ids: [...config.role_ids], admin_ids: [...config.admin_ids] } +} + +export async function getPrescriptionAiVisibilityConfig() { + const data = await request.get({ url: '/tcm.prescriptionAi/getVisibilityConfig' }) + return readVisibilityConfig(data) +} + +export async function setPrescriptionAiVisibilityConfig(params: PrescriptionAiVisibilityConfig) { + const data = await request.post({ url: '/tcm.prescriptionAi/setVisibilityConfig', params }) + return readVisibilityConfig(data) +} + +export function getPrescriptionAiVisibilityOptions() { + return request.get({ + url: '/tcm.prescriptionAi/visibilityOptions' + }) as Promise +} + +export function getPrescriptionAiCapabilities() { + return request.get({ + url: '/tcm.prescriptionAi/capabilities' + }) as Promise +} diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts index 858666e48..3fa9dc584 100644 --- a/admin/src/api/stats.ts +++ b/admin/src/api/stats.ts @@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record) { return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params }) } -/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */ -export function revisitRateOverview(params: { month?: string; dept_ids?: string }) { +/** + * 复诊接诊率口径: + * - slot(默认,后端 config project.revisit_rate.default_inherit_mode):继承按继承时诊次计入每列独立分母(visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数) + * - exclude:继承不计分母(旧口径,assigned_count / visit{N}_count / visit{N}_rate 与 2026-10 前一致) + */ +export type RevisitRateInheritMode = 'exclude' | 'slot' + +/** 被指派明细行类型:new 新指派 / inherit 继承 / all 两者(默认) */ +export type RevisitRateAssignKind = 'new' | 'inherit' | 'all' + +/** + * 复诊接诊率(按月):部门 → 医助分组 + 合计。 + * 返回 inherit_mode、slots,行含 assigned_count(新指派)、inherit_count、assigned_total_count、 + * visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(旧口径二诊率) + */ +export function revisitRateOverview(params: { + month?: string + dept_ids?: string + inherit_mode?: RevisitRateInheritMode +}) { return request.get({ url: '/stats.revisitRate/overview', params }) } @@ -214,23 +232,29 @@ export function revisitRateDeptOptions() { return request.get({ url: '/stats.revisitRate/deptOptions' }) } -/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */ +/** + * 复诊接诊率:「新指派数 / 继承数 / 被指派合计」点击下钻(按诊单聚合明细)。 + * 行含 assign_type(new|inherit)、inherit_visit_slot(继承时诊次 k)、entry_slot(起算诊次) + */ export function revisitRateAssignLines(params: { month?: string dept_ids?: string assistant_id?: number dept_id?: number + inherit_mode?: RevisitRateInheritMode + kind?: RevisitRateAssignKind }) { return request.get({ url: '/stats.revisitRate/assignLines', params }) } -/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */ +/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */ export function revisitRateVisitOrderLines(params: { month?: string slot: number dept_ids?: string assistant_id?: number dept_id?: number + inherit_mode?: RevisitRateInheritMode }) { return request.get({ url: '/stats.revisitRate/visitOrderLines', params }) } @@ -253,3 +277,63 @@ export function assistantPerformanceOverview(params: { }) { return request.get({ url: '/stats.assistantPerformance/overview', params }) } + +/** 「我的复诊接诊率」卡片返回(RevisitRateLogic::assistantOverview,仅本人行) */ +export interface AssistantRevisitRateResult { + month: string + inherit_mode: RevisitRateInheritMode + /** 诊次列范围(2~N) */ + slots: number[] + /** false = 本人不属于二中心子树(复诊接诊率仅统计二中心医助) */ + in_scope: boolean + /** 本人行:assigned_count / inherit_count / assigned_total_count / visit{N}_count|_den|_rate / visit_all_*;无数据为 null */ + row: Record | null +} + +/** + * 医助个人业绩:我的复诊接诊率(按月,仅本人行;口径与复诊接诊率页一致, + * 继承患者按继承时诊次从第 k+1 诊起计入分母) + */ +export function assistantPerformanceRevisitRate(params: { + month?: string +}): Promise { + return request.get({ url: '/stats.assistantPerformance/revisitRate', params }) +} + + +/** 独立指派周期统计,与原 revisitRate 按月报表分别调用。 */ +export type RevisitRateCohortType = 'new' | 'inherit' + +export interface RevisitRateQuery { + start_date: string + end_date: string + dept_ids?: string + assistant_id?: number +} + +export interface RevisitRateDetailQuery extends RevisitRateQuery { + scope_assistant_id?: number + dept_id?: number +} + +const revisitCohortPrefix = (cohortType: RevisitRateCohortType) => + cohortType === 'inherit' ? '/stats.revisitInheritRate' : '/stats.revisitAssignRate' + +export function revisitCohortOverview(params: RevisitRateQuery, cohortType: RevisitRateCohortType) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/overview`, params }) +} + +export function revisitCohortDeptOptions(cohortType: RevisitRateCohortType) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/deptOptions` }) +} + +export function revisitCohortAssignLines(params: RevisitRateDetailQuery, cohortType: RevisitRateCohortType) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/assignLines`, params }) +} + +export function revisitCohortVisitOrderLines( + params: RevisitRateDetailQuery & { slot: number }, + cohortType: RevisitRateCohortType +) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/visitOrderLines`, params }) +} diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index 67c836b24..5a5e35668 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -227,6 +227,29 @@ export function getCallRecords(params: any) { return request.get({ url: '/tcm.diagnosis/getCallRecords', params }) } +export interface ConsultationCallRecord { + id: number + diagnosis_id: number + call_type?: number + status?: number + start_time?: number + start_time_text?: string + end_time_text?: string + duration_text?: string + transcript_text?: string | null + transcription_status?: string + transcription_status_text?: string + transcription_segment_count?: number +} + +/** 按诊单读取每次面诊已保存的文字;不触发语音识别或转写写入。 */ +export function getConsultationTranscripts(params: { diagnosis_id: number }, signal?: AbortSignal) { + return request.get( + { url: '/tcm.diagnosis/getCallRecords', params, signal }, + { ignoreCancelToken: true } + ) +} + // 将 TRTC 房间号写入通话记录(与云端录制回调 room 关联) export function bindCallRoom(params: { diagnosis_id: number; room_id: string }) { return request.post({ url: '/tcm.diagnosis/bindCallRoom', params }) @@ -490,8 +513,10 @@ export function prescriptionOrderPatchPrescriptionUsage(params: { times_per_day: number usage_days: number medication_days: number + dosage_bag_count?: number aux_times_per_day?: number aux_usage_days?: number + aux_dosage_bag_count?: number }) { return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params }) } diff --git a/admin/src/components/chat-dialog/index.vue b/admin/src/components/chat-dialog/index.vue index 3ba575ff0..92a815e49 100644 --- a/admin/src/components/chat-dialog/index.vue +++ b/admin/src/components/chat-dialog/index.vue @@ -34,6 +34,7 @@ +
@@ -131,6 +132,8 @@ + + diff --git a/admin/src/components/iam-v2/launch.vue b/admin/src/components/iam-v2/launch.vue new file mode 100644 index 000000000..4fc0b1262 --- /dev/null +++ b/admin/src/components/iam-v2/launch.vue @@ -0,0 +1,32 @@ + + diff --git a/admin/src/components/iam-v2/personal.vue b/admin/src/components/iam-v2/personal.vue new file mode 100644 index 000000000..495fab025 --- /dev/null +++ b/admin/src/components/iam-v2/personal.vue @@ -0,0 +1,137 @@ + + diff --git a/admin/src/components/revisit-due-toast/index.vue b/admin/src/components/revisit-due-toast/index.vue new file mode 100644 index 000000000..1e923743f --- /dev/null +++ b/admin/src/components/revisit-due-toast/index.vue @@ -0,0 +1,1115 @@ + + + + + + + diff --git a/admin/src/hooks/useConsultationTranscripts.ts b/admin/src/hooks/useConsultationTranscripts.ts new file mode 100644 index 000000000..451972ea9 --- /dev/null +++ b/admin/src/hooks/useConsultationTranscripts.ts @@ -0,0 +1,47 @@ +import { onBeforeUnmount, ref, watch } from 'vue' +import { getConsultationTranscripts, type ConsultationCallRecord } from '@/api/tcm' +import { normalizeConsultationRecords } from '@/utils/consultation-transcripts' + +export function useConsultationTranscripts(diagnosisId: () => number) { + const records = ref([]) + const loading = ref(false) + const error = ref('') + let sequence = 0 + let controller: AbortController | undefined + + const refresh = async () => { + const id = Number(diagnosisId()) + const request = ++sequence + controller?.abort() + error.value = '' + if (!Number.isSafeInteger(id) || id <= 0) { + records.value = [] + loading.value = false + return + } + controller = new AbortController() + loading.value = true + try { + const result = await getConsultationTranscripts({ diagnosis_id: id }, controller.signal) + if (request !== sequence || Number(diagnosisId()) !== id) return + records.value = normalizeConsultationRecords(result, id) + } catch (reason: any) { + if (request !== sequence || Number(diagnosisId()) !== id) return + error.value = String(reason?.msg || reason?.message || '面诊文字对话加载失败,请重试') + } finally { + if (request === sequence) loading.value = false + } + } + + watch(diagnosisId, () => { + records.value = [] + void refresh() + }, { immediate: true, flush: 'sync' }) + + onBeforeUnmount(() => { + sequence++ + controller?.abort() + records.value = [] + }) + return { records, loading, error, refresh } +} diff --git a/admin/src/hooks/useDesktopWorkstationDownloads.ts b/admin/src/hooks/useDesktopWorkstationDownloads.ts new file mode 100644 index 000000000..9b84339fb --- /dev/null +++ b/admin/src/hooks/useDesktopWorkstationDownloads.ts @@ -0,0 +1,59 @@ +import { onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { + getDesktopWorkstationDownloads, + type DesktopWorkstationDownload, + type DesktopWorkstationDownloads +} from '@/api/setting/desktop_workstation' + +// 接诊台、列表和聊天窗口共用一个请求与短缓存。 +const downloads = shallowRef({ version: '', packages: [] }) +const downloadsLoading = ref(false) +const downloadsFailed = ref(false) +let pending: Promise | undefined +let loadedAt = 0 +const CACHE_MS = 5 * 60 * 1000 + +export function normalizeDesktopDownloads(value: DesktopWorkstationDownloads): DesktopWorkstationDownloads { + const platforms = ['windows_x64', 'macos_arm64', 'macos_x64'] + const packages: DesktopWorkstationDownload[] = [] + const seen = new Set() + for (const item of Array.isArray(value?.packages) ? value.packages : []) { + if (!item || typeof item !== 'object') continue + if (!platforms.includes(item.platform) || seen.has(item.platform)) continue + try { + const url = new URL(item.url) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) continue + if (!/^[a-f\d]{64}$/i.test(item.sha256)) continue + packages.push({ ...item, url: url.href }) + seen.add(item.platform) + } catch { /* 配置异常时不生成无效下载链接。 */ } + } + return { version: String(value?.version || ''), packages } +} + +export function useDesktopWorkstationDownloads() { + const loadDownloads = (force = false): Promise => { + if (pending) return pending + if (!force && loadedAt > 0 && Date.now() - loadedAt < CACHE_MS) return Promise.resolve() + downloadsLoading.value = true + downloadsFailed.value = false + pending = getDesktopWorkstationDownloads().then(data => { + downloads.value = normalizeDesktopDownloads(data) + loadedAt = Date.now() + }).catch(() => { + downloadsFailed.value = true + }).finally(() => { + downloadsLoading.value = false + pending = undefined + }) + return pending + } + const refreshOnFocus = () => { void loadDownloads() } + onMounted(() => { + void loadDownloads() + window.addEventListener('focus', refreshOnFocus) + }) + onUnmounted(() => window.removeEventListener('focus', refreshOnFocus)) + + return { downloads, downloadsLoading, downloadsFailed, loadDownloads } +} diff --git a/admin/src/hooks/useDoctorWebConsultation.ts b/admin/src/hooks/useDoctorWebConsultation.ts new file mode 100644 index 000000000..8fcc984bd --- /dev/null +++ b/admin/src/hooks/useDoctorWebConsultation.ts @@ -0,0 +1,88 @@ +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { getDoctorWebConsultationPolicy } from '@/api/app' +import useUserStore from '@/stores/modules/user' +import feedback from '@/utils/feedback' +import { + doctorWebConsultationMessage, + doctorWebConsultationReminder, + hasDoctorWebConsultationExpired, + isConsultationDoctor, + isDoctorWebConsultationBlocked, + normalizeDoctorWebConsultationPolicy +} from '@/utils/doctor-web-consultation' + +// 多个列表、公告及聊天窗口共用请求;配置更新无需重新构建前端。 +let pending: Promise | undefined +let lastAttemptAt = 0 +const REFRESH_MS = 60 * 1000 +const policyLoadFailed = ref(false) + +export function useDoctorWebConsultation() { + const userStore = useUserStore() + const now = ref(Date.now()) + let timer: ReturnType | undefined + const updateClock = () => { now.value = Date.now() + (userStore.serverTimeOffsetMs || 0) } + const policy = computed(() => normalizeDoctorWebConsultationPolicy(userStore.userInfo.web_consultation_policy)) + + const loadPolicy = (force = false): Promise => { + if (pending) return pending + if (!force && lastAttemptAt > 0 && Date.now() - lastAttemptAt < REFRESH_MS) return Promise.resolve() + lastAttemptAt = Date.now() + pending = getDoctorWebConsultationPolicy().then(data => { + const next = normalizeDoctorWebConsultationPolicy(data) + if (!next) throw new Error('网页面诊时间配置无效') + userStore.userInfo.web_consultation_policy = next + userStore.serverTimeOffsetMs = next.server_time * 1000 - Date.now() + policyLoadFailed.value = false + updateClock() + }).catch(() => { + // 保留最近一次有效配置,后端仍逐次校验实际截止时间。 + policyLoadFailed.value = true + }).finally(() => { pending = undefined }) + return pending + } + const refreshConsultationPolicy = () => loadPolicy(true) + const refreshOnFocus = () => { + updateClock() + void refreshConsultationPolicy() + } + const refreshOnVisible = () => { + if (document.visibilityState !== 'hidden') refreshOnFocus() + } + + // 登录响应和配置刷新响应均提供服务器时刻,避免本机时区/时钟影响规则。 + watch(() => userStore.serverTimeOffsetMs, updateClock, { immediate: true }) + + onMounted(() => { + updateClock() + void refreshConsultationPolicy() + timer = setInterval(() => { + updateClock() + void loadPolicy() + }, 1000) + window.addEventListener('focus', refreshOnFocus) + document.addEventListener('visibilitychange', refreshOnVisible) + }) + onUnmounted(() => { + clearInterval(timer) + window.removeEventListener('focus', refreshOnFocus) + document.removeEventListener('visibilitychange', refreshOnVisible) + }) + + const isDoctor = computed(() => isConsultationDoctor(userStore.userInfo)) + const webConsultationExpired = computed(() => hasDoctorWebConsultationExpired(policy.value, now.value)) + const consultationReminder = computed(() => doctorWebConsultationReminder(policy.value, now.value)) + const webConsultationMessage = computed(() => doctorWebConsultationMessage(policy.value)) + const webConsultationBlocked = computed(() => isDoctorWebConsultationBlocked(userStore.userInfo, policy.value, now.value)) + const ensureWebConsultationAllowed = () => { + updateClock() + if (!webConsultationBlocked.value) return true + feedback.msgWarning(webConsultationMessage.value) + return false + } + + return { + isDoctor, consultationReminder, webConsultationExpired, webConsultationBlocked, + webConsultationMessage, ensureWebConsultationAllowed, policyLoadFailed, refreshConsultationPolicy + } +} diff --git a/admin/src/layout/default/components/header/index.vue b/admin/src/layout/default/components/header/index.vue index d73d38ce8..807d92d9e 100644 --- a/admin/src/layout/default/components/header/index.vue +++ b/admin/src/layout/default/components/header/index.vue @@ -22,6 +22,7 @@
+