build(iam): preserve actual deployed frontend before IAM integration

This commit is contained in:
2026-10-29 19:07:30 +08:00
parent bcd42a5c39
commit d34fe2e67e
48 changed files with 6940 additions and 143 deletions
+3
View File
@@ -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(
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
<router-view />
<ChatNotifyToast v-if="showChatNotifyToast" />
<!-- 复诊到期弹窗(右下角):与会话通知并列挂载,复用同一条件避免未绑企微时无意义轮询 -->
<RevisitDueToast v-if="showChatNotifyToast" />
</el-config-provider>
</template>
+8
View File
@@ -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' })
+37
View File
@@ -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<string, unknown>): Promise<any> =>
request.get({ url: prefix + path, params, withCredentials: true }, opts)
export const iamPost = (
path: string,
params: Record<string, unknown> = {},
key = crypto.randomUUID()
): Promise<any> =>
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 ||
'操作未完成,请刷新状态后重试。'
+195
View File
@@ -0,0 +1,195 @@
import request from '@/utils/request'
/**
* 复诊到期弹窗(需求 4)接口:/adminapi/tcm.revisitDue/<action>
* 契约见 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<RevisitDuePendingResult> {
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<RevisitDueToastSummary> {
return request.get({ url: '/tcm.revisitDue/summary' })
}
/** 延迟一次(幂等) */
export function revisitDueSnooze(params: { id: number }): Promise<RevisitDueWriteResult> {
return request.post({ url: '/tcm.revisitDue/snooze', params })
}
/** 批量延迟(仅 escalation_level<=1 的项) */
export function revisitDueSnoozeAll(params: {
ids: number[]
}): Promise<RevisitDueSnoozeAllResult> {
return request.post({ url: '/tcm.revisitDue/snoozeAll', params })
}
/** 已联系:记录患者承诺复诊日 */
export function revisitDueContact(params: {
id: number
promised_date: string
remark?: string
}): Promise<RevisitDueWriteResult> {
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<RevisitDueWriteResult> {
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<string, any>
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 })
}
@@ -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<DesktopWorkstationDownloads>
}
export function getDesktopWorkstationConfig() {
return request.get({
url: '/setting.desktop_workstation/getConfig'
@@ -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<PrescriptionAiVisibilityConfig> | 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<PrescriptionAiVisibilityOptions>
}
export function getPrescriptionAiCapabilities() {
return request.get({
url: '/tcm.prescriptionAi/capabilities'
}) as Promise<PrescriptionAiCapabilities>
}
+88 -4
View File
@@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
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<string, any> | null
}
/**
* 医助个人业绩:我的复诊接诊率(按月,仅本人行;口径与复诊接诊率页一致,
* 继承患者按继承时诊次从第 k+1 诊起计入分母)
*/
export function assistantPerformanceRevisitRate(params: {
month?: string
}): Promise<AssistantRevisitRateResult> {
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 })
}
+25
View File
@@ -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<ConsultationCallRecord[]>(
{ 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 })
}
+18 -2
View File
@@ -34,6 +34,7 @@
</div>
</div>
<DoctorWebConsultationNotice v-if="!isMinimized" />
<div v-show="!isMinimized" class="chat-container">
<div v-if="!isReady" class="loading-container">
<el-icon class="is-loading"><Loading /></el-icon>
@@ -131,6 +132,8 @@
<script setup lang="ts">
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
import { Loading, Close, Rank, Camera, Fold, Expand } from '@element-plus/icons-vue'
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
import { addDoctorNote } from '@/api/patient'
@@ -195,8 +198,9 @@ const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointm
const serverAllowsVideo = ref(false)
const serverAllowsAudio = ref(false)
const callDisabledReason = ref('正在确认挂号类型')
const canVideoCall = computed(() => serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
const canAudioCall = computed(() => serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
const { webConsultationBlocked, webConsultationMessage, ensureWebConsultationAllowed } = useDoctorWebConsultation()
const canVideoCall = computed(() => !webConsultationBlocked.value && serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
const canAudioCall = computed(() => !webConsultationBlocked.value && serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
let releaseAppointmentCallGuard: (() => void) | undefined
let chatContextVersion = 0
let outgoingCallType = 2
@@ -253,6 +257,7 @@ function syncAppointmentCallPolicy(res: {
}
async function prepareAppointmentCall(callType: number) {
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
const contextVersion = chatContextVersion
const allowed = () => callType === 1 ? canAudioCall.value : canVideoCall.value
if (!visible.value || !allowed()) {
@@ -266,6 +271,7 @@ async function prepareAppointmentCall(callType: number) {
})
if (!visible.value || chatContextVersion !== contextVersion) throw new Error('当前问诊已切换,请重新发起通话')
syncAppointmentCallPolicy(res)
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
if (!allowed()) {
feedback.msgWarning(callDisabledReason.value)
throw new Error(callDisabledReason.value)
@@ -713,6 +719,14 @@ function installTUICallKitRoomHooks() {
wrap('calls')
wrap('call')
wrap('groupCall')
// 已登录 SDK 的旧聊天窗口也不能在截止后接起新面诊。
const originalAccept = server.accept
if (typeof originalAccept === 'function') {
server.accept = async function patchedAccept(...args: unknown[]) {
if (!ensureWebConsultationAllowed()) throw new Error(webConsultationMessage.value)
return originalAccept.apply(server, args)
}
}
}
async function doBindCallRoom(rid: string) {
@@ -1099,6 +1113,7 @@ watch(() => activeConversation.value, async (newConversation) => {
})
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentId?: number; appointmentType?: string | null }) => {
if (!ensureWebConsultationAllowed()) return
stopChatArchiveWatch(true)
const contextVersion = ++chatContextVersion
releaseAppointmentCallGuard?.()
@@ -1260,6 +1275,7 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
const startGroupVideoCall = async () => {
if (!ensureWebConsultationAllowed()) return
if (!canVideoCall.value) {
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
return
@@ -0,0 +1,102 @@
<template>
<el-alert
class="doctor-web-consultation-notice"
:type="consultationReminder.type"
:closable="false"
show-icon
:title="consultationReminder.title"
>
<p>{{ consultationReminder.description }}</p>
<p v-if="policyLoadFailed">
面诊切换时间更新失败,请重新获取最新设置。
<el-button link type="primary" @click="refreshConsultationPolicy()">刷新切换时间</el-button>
</p>
<p v-if="consultationReminder.countdown" class="notice-countdown">
距网页端面诊停用还有 {{ consultationReminder.countdown }}(北京时间)
</p>
<div class="notice-downloads">
<a
v-for="item in downloads.packages"
:key="item.platform"
class="installer-link"
:href="item.url"
:download="item.filename || undefined"
target="_blank"
rel="noopener noreferrer"
>
<el-icon><Download /></el-icon>
下载 {{ item.label }}安装包
</a>
<span v-if="downloadsLoading && !downloads.packages.length">正在获取安装包…</span>
<template v-else-if="!downloads.packages.length">
<span>{{ downloadsFailed ? '安装包加载失败,请重试。' : '安装包暂未发布,请联系管理员获取。' }}</span>
<el-button link type="primary" @click="loadDownloads(true)">重新获取</el-button>
</template>
<el-button v-else-if="downloadsFailed" link type="primary" @click="loadDownloads(true)">刷新安装包</el-button>
</div>
<p v-if="downloads.packages.length" class="install-instructions">
<span v-if="downloads.version">当前版本 v{{ downloads.version }} · </span>
安装完成后,使用当前医生账号登录桌面应用。
</p>
<p v-if="hasWindowsInstaller" class="install-instructions">
Windows 安装包适用于 Windows 10 1809 及以上版本、Windows 11(64位)。
</p>
</el-alert>
</template>
<script setup lang="ts">
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
import { useDesktopWorkstationDownloads } from '@/hooks/useDesktopWorkstationDownloads'
import { Download } from '@element-plus/icons-vue'
import { computed, watch } from 'vue'
const { consultationReminder, policyLoadFailed, refreshConsultationPolicy } = useDoctorWebConsultation()
const { downloads, downloadsLoading, downloadsFailed, loadDownloads } = useDesktopWorkstationDownloads()
const hasWindowsInstaller = computed(() => downloads.value.packages.some(item => item.platform === 'windows_x64'))
watch(() => consultationReminder.value.phase, () => { void loadDownloads(true) })
</script>
<style scoped>
.doctor-web-consultation-notice {
flex-shrink: 0;
margin-bottom: 16px;
}
.doctor-web-consultation-notice p {
margin: 4px 0 0;
font-size: 14px;
line-height: 1.7;
}
.notice-countdown {
font-weight: 600;
}
.notice-downloads {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-top: 12px;
}
.installer-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 6px;
color: #fff;
background: #2563eb;
font-size: 14px;
line-height: 1.5;
text-decoration: none;
}
.installer-link:hover {
background: #1d4ed8;
}
.installer-link:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 3px;
}
.doctor-web-consultation-notice .install-instructions {
margin-top: 8px;
font-size: 13px;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<el-button v-if="enabled" text :loading="busy" @click="launch">进入甄美 ERP ↗</el-button>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { iamGet, iamPost, identityError } from '@/api/iam-v2'
const enabled = ref(false),
busy = ref(false)
onMounted(async () => {
try {
enabled.value = !!(await iamGet('/status')).enabled
} catch {
enabled.value = false
}
})
async function launch() {
const tab = window.open('about:blank', '_blank')
if (tab) tab.opener = null
busy.value = true
try {
const r = await iamPost('/launch/start')
if (tab) tab.location.href = r.url
else ElMessage.warning('请允许新标签页后再试。')
} catch (e) {
tab?.close()
ElMessage.warning(identityError(e))
} finally {
busy.value = false
}
}
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<el-card v-if="status?.enabled || error" class="mt-4" shadow="never">
<template #header
><strong>统一身份 IAM</strong
><el-button class="float-right" text @click="refresh">刷新状态</el-button></template
>
<el-alert v-if="error" :title="error" type="warning" :closable="false" class="mb-4" />
<p>
关联状态:<el-tag :type="linked ? 'success' : 'info'">{{
linked ? '已关联' : status.binding?.state === 'revoked' ? '关联已撤销' : '未关联'
}}</el-tag>
</p>
<p v-if="status.employee" class="my-3">
IAM 员工:{{ status.employee.displayName }}({{ status.employee.username }})
</p>
<p class="my-3 text-gray-500">
关联只确认同一人的身份,不会复制业务角色或自动授予其他平台权限。
</p>
<el-alert
v-if="!linked && status.protected_account"
class="mb-4"
type="warning"
:closable="false"
title="此账号属于敏感账号,请由本地管理员在 IAM 关联管理发起单人中央复核,不使用个人自助绑定。"
/>
<div class="flex flex-wrap gap-3">
<el-button @click="reauthVisible = true">验证当前身份</el-button>
<el-button @click="wecomRelogin">企业微信重新登录</el-button>
<el-button
v-if="!linked && !status.protected_account"
type="primary"
:loading="busy"
@click="navigate('/self-link/start')"
>关联已有 IAM 账号</el-button
>
<el-button
v-if="linked && status.employee?.passwordSetupPending"
type="primary"
:loading="busy"
@click="navigate('/password-setup')"
>设置 IAM 登录密码</el-button
>
<el-button v-if="linked" :loading="busy" @click="launch">进入甄美 ERP ↗</el-button>
</div>
<el-dialog
v-model="reauthVisible"
title="验证当前 ZYT 身份"
width="420px"
:close-on-click-modal="false"
>
<el-form @submit.prevent="reauth"
><el-form-item label="当前密码"
><el-input
v-model="password"
type="password"
show-password
autocomplete="current-password"
@keyup.enter="reauth" /></el-form-item
></el-form>
<p class="text-gray-500">
仅验证当前账号,验证有效期 5 分钟。密码不会被保存或传给 IAM。
</p>
<template #footer
><el-button @click="cancelReauth">取消</el-button
><el-button type="primary" :loading="busy" @click="reauth"
>验证</el-button
></template
>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { iamGet, iamPost, identityError } from '@/api/iam-v2'
import useUserStore from '@/stores/modules/user'
const status = ref<any>({ enabled: false }),
error = ref(''),
busy = ref(false),
reauthVisible = ref(false),
password = ref('')
const linked = computed(() => status.value?.binding?.state === 'verified')
async function refresh() {
try {
status.value = await iamGet('/status')
error.value = ''
} catch (e) {
error.value = identityError(e)
}
}
function cancelReauth() {
reauthVisible.value = false
password.value = ''
}
async function reauth() {
busy.value = true
try {
await iamPost('/reauth', { password: password.value })
reauthVisible.value = false
await refresh()
} catch (e) {
error.value = identityError(e)
} finally {
password.value = ''
busy.value = false
}
}
async function navigate(path: string) {
busy.value = true
try {
const r = await iamPost(path)
window.location.assign(r.url)
} catch (e) {
error.value = identityError(e)
} finally {
busy.value = false
}
}
async function launch() {
const tab = window.open('about:blank', '_blank')
if (tab) tab.opener = null
busy.value = true
try {
const r = await iamPost('/launch/start')
if (tab) tab.location.href = r.url
else error.value = '请允许浏览器打开新标签页后再试。'
} catch (e) {
tab?.close()
error.value = identityError(e)
} finally {
busy.value = false
}
}
async function wecomRelogin() {
await useUserStore().logout()
}
onMounted(refresh)
</script>
File diff suppressed because it is too large Load Diff
@@ -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<ConsultationCallRecord[]>([])
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 }
}
@@ -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<DesktopWorkstationDownloads>({ version: '', packages: [] })
const downloadsLoading = ref(false)
const downloadsFailed = ref(false)
let pending: Promise<void> | 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<string>()
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<void> => {
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 }
}
@@ -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<void> | 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<typeof setInterval> | undefined
const updateClock = () => { now.value = Date.now() + (userStore.serverTimeOffsetMs || 0) }
const policy = computed(() => normalizeDoctorWebConsultationPolicy(userStore.userInfo.web_consultation_policy))
const loadPolicy = (force = false): Promise<void> => {
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
}
}
@@ -22,6 +22,7 @@
</div>
</div>
<div class="flex">
<iam-launch />
<div class="navbar-item" v-if="!isMobile">
<el-tooltip
class="box-item"
@@ -52,6 +53,7 @@
</template>
<script setup lang="ts">
import IamLaunch from '@/components/iam-v2/launch.vue'
import { useFullscreen } from '@vueuse/core'
import useAppStore from '@/stores/modules/app'
+1 -1
View File
@@ -81,7 +81,7 @@ const defaultPath = PageEnum.INDEX
const changePasswordPath = '/change-password'
const bindWorkWechatPath = '/bind-work-wechat'
// 免登录白名单
const whiteList: string[] = [PageEnum.LOGIN, PageEnum.ERROR_403]
const whiteList: string[] = [PageEnum.LOGIN, PageEnum.ERROR_403, '/iam/launch']
router.beforeEach(async (to, from, next) => {
document.title = to.meta.title ?? config.title
const userStore = useUserStore()
+5
View File
@@ -23,6 +23,11 @@ export const LAYOUT = () => Promise.resolve(Layout)
export const INDEX_ROUTE_NAME = Symbol()
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/iam/launch',
component: () => import('@/views/account/iam-launch.vue'),
meta: { title: '统一身份进入甄养堂', hideTab: true }
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/error/404.vue')
+15 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import type { RouteRecordRaw } from 'vue-router'
import { getUserInfo, iamLogin, login, logout, workWechatLogin } from '@/api/user'
import { iamPost } from '@/api/iam-v2'
import { TOKEN_KEY } from '@/enums/cacheEnums'
import { PageEnum } from '@/enums/pageEnum'
import router, { filterAsyncRoutes } from '@/router'
@@ -14,6 +15,7 @@ export interface UserState {
routes: RouteRecordRaw[]
perms: string[]
isPaw: number // 0=需要修改密码,1=正常
serverTimeOffsetMs: number
}
const useUserStore = defineStore({
@@ -27,7 +29,8 @@ const useUserStore = defineStore({
// 权限
perms: [],
// 是否需要修改密码
isPaw: 1
isPaw: 1,
serverTimeOffsetMs: 0
}),
getters: {},
actions: {
@@ -36,6 +39,7 @@ const useUserStore = defineStore({
this.userInfo = {}
this.perms = []
this.isPaw = 1
this.serverTimeOffsetMs = 0
},
login(playload: any) {
const { account, password } = playload
@@ -83,6 +87,14 @@ const useUserStore = defineStore({
})
})
},
async iamLaunchLogin(ticket: string) {
const data = await iamPost('/launch/exchange', { ticket })
this.resetState()
this.token = data.token
this.isPaw = data.is_paw ?? 1
cache.set(TOKEN_KEY, data.token)
return data
},
async iamLogin(ticket: string) {
const data = await iamLogin(ticket)
this.token = data.token
@@ -95,6 +107,8 @@ const useUserStore = defineStore({
getUserInfo()
.then((data) => {
this.userInfo = data.user
const serverTime = Number(data.user?.web_consultation_policy?.server_time)
this.serverTimeOffsetMs = serverTime > 0 ? serverTime * 1000 - Date.now() : 0
this.perms = data.permissions
this.routes = filterAsyncRoutes(data.menu)
// 更新 isPaw 状态
@@ -0,0 +1,45 @@
import type { ConsultationCallRecord } from '@/api/tcm'
export function normalizeConsultationRecords(value: unknown, diagnosisId: number): ConsultationCallRecord[] {
if (!Array.isArray(value)) throw new Error('面诊记录返回格式异常,请重试')
const seen = new Set<number>()
return value.filter(row => {
if (!row || !Number.isSafeInteger(Number(row.id)) || Number(row.id) <= 0 || seen.has(Number(row.id))) return false
if (row.diagnosis_id != null && Number(row.diagnosis_id) !== diagnosisId) return false
seen.add(Number(row.id))
return true
}).map(row => ({
...row,
id: Number(row.id),
diagnosis_id: diagnosisId,
transcript_text: typeof row.transcript_text === 'string' ? row.transcript_text.replace(/\r\n/g, '\n').trim() : ''
})).sort((a, b) => b.id - a.id)
}
export function consultationTranscriptText(row?: ConsultationCallRecord | null): string {
return typeof row?.transcript_text === 'string' ? row.transcript_text.trim() : ''
}
export function consultationTranscriptStatus(row: ConsultationCallRecord) {
switch (row.transcription_status) {
case 'running': return { label: '录音转写中', type: 'info' as const }
case 'completed': return { label: '文字已生成', type: 'success' as const }
case 'partial': return { label: '文字部分保存', type: 'warning' as const }
case 'failed': return { label: '文字生成失败', type: 'danger' as const }
default: return consultationTranscriptText(row)
? { label: '文字已保存', type: 'success' as const }
: { label: '未生成文字', type: 'info' as const }
}
}
export function consultationTranscriptEmptyText(row: ConsultationCallRecord): string {
if (row.transcription_status === 'running') return '正在转写,可点击刷新查看最新保存的文字。'
if (row.transcription_status === 'failed') return '本次面诊文字生成失败,暂无已保存的对话。'
if (Number(row.status) === 3) return '本次通话未接听,暂无文字对话。'
if (Number(row.status) === 4) return '本次通话已取消,暂无文字对话。'
return '本次面诊暂无已保存的文字对话。'
}
export function consultationCallLabel(row: ConsultationCallRecord): string {
return Number(row.call_type) === 1 ? '语音面诊' : Number(row.call_type) === 2 ? '视频面诊' : '面诊记录'
}
@@ -0,0 +1,97 @@
// 所有时间由服务端配置提供,浏览器不保留独立的截止日期。
export type DoctorWebConsultationPolicy = {
cutoff_at: string
approaching_at: string
urgent_at: string
server_time: number
}
export type DoctorWebConsultationPhase = 'prepare' | 'approaching' | 'last_day' | 'expired'
export function normalizeDoctorWebConsultationPolicy(value: unknown): DoctorWebConsultationPolicy | undefined {
if (!value || typeof value !== 'object') return undefined
const policy = value as DoctorWebConsultationPolicy
const timestamps = [policy.approaching_at, policy.urgent_at, policy.cutoff_at].map(date =>
typeof date === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(date) ? Date.parse(date) : NaN
)
if (timestamps.some(time => !Number.isFinite(time)) || timestamps[0] > timestamps[1] || timestamps[1] > timestamps[2]) return undefined
if (!Number.isFinite(policy.server_time) || policy.server_time <= 0) return undefined
return { cutoff_at: policy.cutoff_at, approaching_at: policy.approaching_at, urgent_at: policy.urgent_at, server_time: policy.server_time }
}
export function doctorWebConsultationDate(policy: DoctorWebConsultationPolicy): string {
// 无论医生电脑所在时区为何,公告始终显示北京时间。
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
}).formatToParts(new Date(policy.cutoff_at))
const part = (type: string) => parts.find(item => item.type === type)?.value || ''
const seconds = part('second') === '00' ? '' : `:${part('second')}`
return `${part('year')}年${part('month')}月${part('day')}日${part('hour')}:${part('minute')}${seconds}(北京时间)`
}
export function doctorWebConsultationMessage(policy?: DoctorWebConsultationPolicy): string {
return `网页端面诊已${policy ? `于${doctorWebConsultationDate(policy)}` : ''}停用,请医生使用桌面应用进行面诊。`
}
export function doctorWebConsultationReminder(policy?: DoctorWebConsultationPolicy, now = Date.now()) {
if (!policy) return {
phase: 'prepare' as DoctorWebConsultationPhase,
type: 'info' as const,
title: '系统公告:医生桌面应用安装指引',
description: '请各位医生提前下载并安装桌面应用,使用当前医生账号登录。',
countdown: ''
}
const cutoffDate = doctorWebConsultationDate(policy)
const remaining = Date.parse(policy.cutoff_at) - now
const days = Math.floor(remaining / 86400000)
const hours = Math.floor(remaining / 3600000) % 24
const minutes = Math.floor(remaining / 60000) % 60
const countdown = remaining <= 0 ? '' : remaining < 60000 ? '不足1分钟' :
`${days > 0 ? `${days}天` : ''}${hours > 0 ? `${hours}小时` : ''}${minutes > 0 ? `${minutes}分钟` : ''}`
if (remaining <= 0) return {
phase: 'expired' as DoctorWebConsultationPhase,
type: 'error' as const,
title: '系统公告:医生网页端面诊已停用',
description: doctorWebConsultationMessage(policy),
countdown: ''
}
if (now >= Date.parse(policy.urgent_at)) return {
phase: 'last_day' as DoctorWebConsultationPhase,
type: 'error' as const,
title: '系统公告:医生网页端面诊停用最后提醒',
description: `请各位医生在${cutoffDate}前完成桌面应用的安装与登录。届时接诊台、问诊列表的网页端面诊功能将同步停用,请使用桌面应用进行面诊。`,
countdown
}
if (now >= Date.parse(policy.approaching_at)) return {
phase: 'approaching' as DoctorWebConsultationPhase,
type: 'warning' as const,
title: '系统公告:医生网页端面诊即将停用',
description: `网页端面诊将于${cutoffDate}停用。请各位医生尽快下载桌面应用,完成安装并使用当前医生账号登录,接诊台、问诊列表将同步执行此规则。`,
countdown
}
return {
phase: 'prepare' as DoctorWebConsultationPhase,
type: 'info' as const,
title: '系统公告:请提前安装医生桌面应用',
description: `请各位医生提前下载并安装桌面应用,使用当前医生账号登录试用。自${cutoffDate}起,接诊台、问诊列表的网页端面诊功能将停用,请提前做好准备。`,
countdown: ''
}
}
export function isConsultationDoctor(user: Record<string, any>): boolean {
return Number(user.root) !== 1 && Array.isArray(user.role_ids) && user.role_ids.some(role => Number(role) === 1)
}
export function hasDoctorWebConsultationExpired(policy?: DoctorWebConsultationPolicy, now = Date.now()): boolean {
return !!policy && now >= Date.parse(policy.cutoff_at)
}
export function isDoctorWebConsultationBlocked(
user: Record<string, any>,
policy?: DoctorWebConsultationPolicy,
now = Date.now()
): boolean {
return isConsultationDoctor(user) && hasDoctorWebConsultationExpired(policy, now)
}
+403
View File
@@ -0,0 +1,403 @@
/**
* 复诊节点(revisit_due)前端纯函数集合
*
* 数据契约与后端 `RevisitDueService::buildListPayload` 输出一致(方案 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md 契约 C):
* 所有日期为 'Y-m-d' 字符串或 '';无快照且无法计算时后端返回 state='none' 的对象,前端显示「—」。
*
* 本文件只做 state → 文案 / 颜色 class / el-tag type 的映射与日期、天数文案拼装,不含任何请求或响应式逻辑,
* 供 PC 列表、H5 卡片、只读页三处共用(RevisitNodeCell.vue + readonly.vue)。
*/
export type RevisitDueState =
| 'visited'
| 'booked'
| 'missed'
| 'contacted'
| 'overdue'
| 'warning'
| 'pending'
| 'no_days'
| 'none'
/** 列表行 `revisit_due` 对象(契约 C) */
export interface RevisitDue {
current_slot: number
next_slot: number
valid_order_count: number
medication_days: number
/** 0=缺失 1=订单 2=处方 */
days_src: 0 | 1 | 2
/** 14/21/28/30 或 0=其它 */
rule_days: number
/** 0=无 1=签收 2=发货 3=下单 */
anchor_src: 0 | 1 | 2 | 3
anchor_date: string
order_date: string
due_date: string
days_left: number | null
overdue_days: number | null
state: RevisitDueState | string
state_text: string
severe: 0 | 1
/** 0=无 1=已约 3=已面诊 4=过号 */
appointment_state: number
appointment_date: string
snooze_count: number
escalation_level: number
promised_date: string
missed_count: number
first_touch_date: string
in_care_days: number | null
total_medication_days: number
closed_state: number
}
/** 「复诊状态」筛选下拉选项(请求参数 revisit_state;due = overdue ∪ missed) */
export const REVISIT_STATE_FILTER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: '到期未挂号 / 过号', value: 'due' },
{ label: '即将到期', value: 'warning' },
{ label: '已约待面诊', value: 'booked' },
{ label: '已面诊', value: 'visited' },
{ label: '已联系', value: 'contacted' },
{ label: '缺开药天数', value: 'no_days' },
{ label: '未到期', value: 'pending' }
]
/** 「开药天数档」筛选下拉选项(请求参数 revisit_days_bucket) */
export const REVISIT_DAYS_BUCKET_OPTIONS: Array<{ label: string; value: string }> = [
{ label: '14 天', value: '14' },
{ label: '21 天', value: '21' },
{ label: '28 天', value: '28' },
{ label: '30 天', value: '30' },
{ label: '其它天数', value: 'other' }
]
/** state → 默认文案(后端 state_text 缺失时兜底) */
const STATE_LABEL: Record<string, string> = {
visited: '已面诊',
booked: '已约',
missed: '过号',
contacted: '已联系',
overdue: '超期',
warning: '即将到期',
pending: '未到期',
no_days: '缺开药天数',
none: '—'
}
const toInt = (v: unknown, fallback = 0): number => {
if (v === null || v === undefined || v === '') return fallback
const n = Number(v)
return Number.isFinite(n) ? Math.trunc(n) : fallback
}
const toNullableInt = (v: unknown): number | null => {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) ? Math.trunc(n) : null
}
const toDateStr = (v: unknown): string => {
if (v === null || v === undefined) return ''
const s = String(v).trim()
if (s === '' || s === '0000-00-00') return ''
return s
}
/**
* 把列表行 / 详情里的 `revisit_due` 原始值规整为 RevisitDue;
* 字段缺失(旧接口)或非对象时返回 null,调用方据此不渲染。
*/
export function normalizeRevisitDue(raw: unknown): RevisitDue | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const r = raw as Record<string, unknown>
const state = String(r.state ?? 'none').trim() || 'none'
const daysSrc = toInt(r.days_src)
const anchorSrc = toInt(r.anchor_src)
return {
current_slot: toInt(r.current_slot),
next_slot: toInt(r.next_slot),
valid_order_count: toInt(r.valid_order_count),
medication_days: toInt(r.medication_days),
days_src: (daysSrc === 1 || daysSrc === 2 ? daysSrc : 0) as 0 | 1 | 2,
rule_days: toInt(r.rule_days),
anchor_src: (anchorSrc >= 1 && anchorSrc <= 3 ? anchorSrc : 0) as 0 | 1 | 2 | 3,
anchor_date: toDateStr(r.anchor_date),
order_date: toDateStr(r.order_date),
due_date: toDateStr(r.due_date),
days_left: toNullableInt(r.days_left),
overdue_days: toNullableInt(r.overdue_days),
state,
state_text: String(r.state_text ?? '').trim(),
severe: toInt(r.severe) === 1 ? 1 : 0,
appointment_state: toInt(r.appointment_state),
appointment_date: toDateStr(r.appointment_date),
snooze_count: toInt(r.snooze_count),
escalation_level: toInt(r.escalation_level),
promised_date: toDateStr(r.promised_date),
missed_count: toInt(r.missed_count),
first_touch_date: toDateStr(r.first_touch_date),
in_care_days: toNullableInt(r.in_care_days),
total_medication_days: toInt(r.total_medication_days),
closed_state: toInt(r.closed_state)
}
}
/** 是否有可展示的复诊节点(state !== 'none') */
export function hasRevisitDue(due: RevisitDue | null | undefined): due is RevisitDue {
return !!due && due.state !== 'none' && due.state !== ''
}
/** 行级标红:到期未挂号 / 过号未面诊;人工关闭或已自动关闭(closed_state!==0)的行不标红,与「复诊到期」chip / 筛选口径一致 */
export function isRevisitDueHighlight(due: RevisitDue | null | undefined): boolean {
return (
hasRevisitDue(due) &&
toInt(due.closed_state) === 0 &&
(due.state === 'overdue' || due.state === 'missed')
)
}
/** 深红:超期达到 severe_overdue_days(后端已算好 severe=1) */
export function isRevisitDueSevere(due: RevisitDue | null | undefined): boolean {
return isRevisitDueHighlight(due) && Number(due?.severe) === 1
}
/**
* el-table :row-class-name 片段:
* '' | 'row-revisit-overdue' | 'row-revisit-overdue row-revisit-overdue-deep'
*/
export function revisitRowClassName(due: RevisitDue | null | undefined): string {
if (!isRevisitDueHighlight(due)) return ''
return isRevisitDueSevere(due) ? 'row-revisit-overdue row-revisit-overdue-deep' : 'row-revisit-overdue'
}
/** 'YYYY-MM-DD' → 'MM-DD';非法/空值返回 '' */
export function formatRevisitMonthDay(date: string | null | undefined): string {
const s = toDateStr(date)
if (!s) return ''
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (m) return `${m[2]}-${m[3]}`
return s
}
/** 用药起算锚点来源文案(行 2 末尾) */
export function revisitAnchorSrcText(src: number | null | undefined): string {
const n = toInt(src)
if (n === 1) return '签收起'
if (n === 2) return '发货起'
if (n === 3) return '下单起'
return ''
}
/** 开药天数来源文案(tooltip 用) */
export function revisitDaysSrcText(src: number | null | undefined): string {
const n = toInt(src)
if (n === 1) return '订单'
if (n === 2) return '处方'
return '缺失'
}
/** 是否缺开药天数(days_src=0 或 medication_days<=0) */
export function isRevisitDaysMissing(due: RevisitDue | null | undefined): boolean {
if (!hasRevisitDue(due)) return false
return toInt(due.days_src) === 0 || toInt(due.medication_days) <= 0
}
/** 「第 N 诊」文案 */
export function revisitSlotText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
const slot = toInt(due.current_slot)
return slot > 0 ? `第 ${slot} 诊` : '未成交'
}
/** 「开药 X 天」或「缺开药天数」 */
export function revisitMedicationDaysText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
if (isRevisitDaysMissing(due)) return '缺开药天数'
return `开药 ${toInt(due.medication_days)} 天`
}
/** 「药尽 MM-DD」;缺天数无 due_date 时返回 '' */
export function revisitDueDateText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
const md = formatRevisitMonthDay(due.due_date)
return md ? `药尽 ${md}` : ''
}
/** state 兜底文案(后端 state_text 为空时使用) */
export function revisitStateLabel(state: string | null | undefined): string {
const key = String(state ?? '').trim()
return STATE_LABEL[key] ?? (key || '—')
}
/**
* 状态 chip 文案:优先后端 state_text;为空时按 state + 数字自行拼装,
* 保证旧接口 / 字段缺失时也可读。
*/
export function revisitStateChipText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
if (due.state_text) return due.state_text
const state = String(due.state)
switch (state) {
case 'overdue': {
const n = toInt(due.overdue_days)
return n <= 0 ? '今日应挂号' : `超期 ${n} 天`
}
case 'missed': {
const md = formatRevisitMonthDay(due.appointment_date)
return md ? `过号 ${md}` : '过号'
}
case 'booked': {
const md = formatRevisitMonthDay(due.appointment_date)
return md ? `已约 ${md}` : '已约'
}
case 'visited': {
const md = formatRevisitMonthDay(due.appointment_date)
return md ? `已面诊 ${md}` : '已面诊'
}
case 'contacted': {
const md = formatRevisitMonthDay(due.promised_date)
return md ? `已联系·约 ${md}` : '已联系'
}
case 'warning':
case 'pending': {
const n = due.days_left
return n === null ? revisitStateLabel(state) : `剩 ${n} 天`
}
default:
return revisitStateLabel(state)
}
}
/** 状态 chip 的 el-tag type */
export function revisitStateTagType(
state: string | null | undefined
): 'danger' | 'warning' | 'primary' | 'success' | 'info' {
switch (String(state ?? '')) {
case 'overdue':
case 'missed':
return 'danger'
case 'warning':
case 'no_days':
return 'warning'
case 'booked':
case 'contacted':
return 'primary'
case 'visited':
return 'success'
default:
return 'info'
}
}
/** 状态 chip 的 el-tag effect:红为 dark;描边态(已联系 / 缺天数)为 plain;其余 light */
export function revisitStateTagEffect(state: string | null | undefined): 'dark' | 'light' | 'plain' {
switch (String(state ?? '')) {
case 'overdue':
case 'missed':
return 'dark'
case 'contacted':
case 'no_days':
return 'plain'
default:
return 'light'
}
}
/**
* 状态 → 徽标颜色 class(复用列表 / 只读页既有的 unserved-* 色板):
* overdue/missed → unserved-red;warning/no_days → unserved-orange;
* visited → unserved-green;booked/contacted → unserved-blue;其余 → unserved-muted
*/
export function revisitStateColorClass(
state: string | null | undefined
): 'unserved-red' | 'unserved-orange' | 'unserved-green' | 'unserved-blue' | 'unserved-muted' {
switch (String(state ?? '')) {
case 'overdue':
case 'missed':
return 'unserved-red'
case 'warning':
case 'no_days':
return 'unserved-orange'
case 'visited':
return 'unserved-green'
case 'booked':
case 'contacted':
return 'unserved-blue'
default:
return 'unserved-muted'
}
}
/** 状态 chip 附加 class(组件内样式钩子):rv-chip rv-chip--<state> [rv-chip--severe] */
export function revisitStateChipClass(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return 'rv-chip'
const cls = ['rv-chip', `rv-chip--${String(due.state)}`]
if (isRevisitDueSevere(due)) cls.push('rv-chip--severe')
return cls.join(' ')
}
/** 行 4:「已延迟 N 次」 */
export function revisitSnoozeText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
const n = toInt(due.snooze_count)
return n > 0 ? `已延迟 ${n} 次` : ''
}
/** 行 4:「已联系 约 MM-DD」 */
export function revisitContactText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
const md = formatRevisitMonthDay(due.promised_date)
return md ? `已联系 约 ${md}` : ''
}
/** 行 4 合并文案:仅 snooze_count>0 或 promised_date 有值时非空 */
export function revisitFollowLineText(due: RevisitDue | null | undefined): string {
const parts = [revisitSnoozeText(due), revisitContactText(due)].filter((s) => s !== '')
return parts.join(' · ')
}
/** 行 2:「MM-DD 开药 · 开药 X 天 · 签收起」 */
export function revisitOrderLineText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
const parts: string[] = []
const md = formatRevisitMonthDay(due.order_date)
if (md) parts.push(`${md} 开药`)
parts.push(revisitMedicationDaysText(due))
if (!isRevisitDaysMissing(due)) {
const anchor = revisitAnchorSrcText(due.anchor_src)
if (anchor) parts.push(anchor)
}
return parts.filter((s) => s !== '').join(' · ')
}
/** 诊次 tag tooltip 行 */
export function revisitTooltipLines(due: RevisitDue | null | undefined): string[] {
if (!hasRevisitDue(due)) return []
const lines: string[] = []
const offset = Math.max(0, toInt(due.current_slot) - toInt(due.valid_order_count))
lines.push(`有效订单 ${toInt(due.valid_order_count)} 笔 · 诊次偏移 +${offset}`)
if (toInt(due.next_slot) > 0) lines.push(`待复诊:第 ${toInt(due.next_slot)} 诊`)
const first = formatRevisitMonthDay(due.first_touch_date)
if (first) {
const care = due.in_care_days
lines.push(care === null ? `首次触达 ${first}` : `首次触达 ${first} · 在管 ${care} 天`)
}
if (toInt(due.total_medication_days) > 0) lines.push(`累计开药 ${toInt(due.total_medication_days)} 天`)
const anchorMd = formatRevisitMonthDay(due.anchor_date)
const anchorSrc = revisitAnchorSrcText(due.anchor_src)
if (anchorMd && anchorSrc) lines.push(`用药起算 ${anchorMd}(${anchorSrc.replace(/起$/, '')})`)
if (!isRevisitDaysMissing(due)) lines.push(`开药天数来源:${revisitDaysSrcText(due.days_src)}`)
if (toInt(due.missed_count) > 0) lines.push(`曾过号 ${toInt(due.missed_count)} 次`)
return lines
}
/**
* 只读页徽标文案:「第 N 诊 · 药尽 MM-DD · 超期 N 天」
* 无 due_date 时省略中段;尾段为状态 chip 文案。
*/
export function revisitBadgeText(due: RevisitDue | null | undefined): string {
if (!hasRevisitDue(due)) return ''
const parts = [revisitSlotText(due), revisitDueDateText(due), revisitStateChipText(due)]
return parts.filter((s) => s !== '').join(' · ')
}
+93
View File
@@ -0,0 +1,93 @@
<template>
<main class="min-h-screen flex items-center justify-center bg-page p-6">
<el-card class="w-full max-w-xl" shadow="never">
<h1 class="text-xl font-semibold mb-4">统一身份进入甄养堂</h1>
<p v-if="busy" class="text-gray-500">正在核验同一身份,请稍候…</p>
<el-alert v-if="error" :title="error" type="warning" :closable="false" show-icon />
<p class="text-gray-500 my-4">
已有 ZYT 账号将进行一致性检查。系统不会自动替换当前已登录的人。
</p>
<div v-if="error" class="flex flex-wrap gap-3">
<el-button v-if="restartUrl" type="primary" @click="back"
>返回 IAM 重新发起</el-button
>
<el-button v-if="restartUrl" :loading="busy" @click="switchAccount"
>退出当前 ZYT 身份并返回 IAM</el-button
>
<router-link to="/login"><el-button>使用原登录方式</el-button></router-link>
</div>
</el-card>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { iamGet, iamPost, identityError } from '@/api/iam-v2'
import useUserStore from '@/stores/modules/user'
import { clearAuthInfo } from '@/utils/auth'
import feedback from '@/utils/feedback'
const route = useRoute(),
router = useRouter(),
store = useUserStore()
const busy = ref(true),
error = ref(''),
restartUrl = ref('')
let handled = false
const text = (v: unknown) => (typeof v === 'string' ? v : '')
function back() {
if (restartUrl.value) window.location.assign(restartUrl.value)
}
async function switchAccount() {
await feedback.confirm(
'确认退出当前浏览器的 ZYT 身份?其他平台和其他浏览器不受影响。请回 IAM 重新发起。'
)
busy.value = true
try {
await iamPost('/launch/reset')
clearAuthInfo()
back()
} catch (e) {
error.value = identityError(e)
} finally {
busy.value = false
}
}
onMounted(async () => {
if (handled) return
handled = true
const ticket = text(route.query.ticket),
source = text(route.query.source),
intent = text(route.query.intent),
issue = text(route.query.iam_error)
// Strip one-use material before any network/async work; never retain it in history.
window.history.replaceState(window.history.state, '', window.location.pathname)
try {
const config = await iamGet('/launch/config', { source })
restartUrl.value = config.restart_url || ''
if (!config.enabled) throw { error: { code: 'FEATURE_DISABLED' } }
if (issue) throw { error: { code: issue } }
if (ticket) {
const result = await store.iamLaunchLogin(ticket)
await router.replace(
result.is_paw === 0
? '/change-password'
: result.need_bind_work_wechat
? '/bind-work-wechat'
: '/'
)
} else {
if (
!['iam-admin', 'iam-portal'].includes(source) ||
!/^[A-Za-z0-9_-]{32,256}$/.test(intent)
)
throw { error: { code: 'INVALID_REQUEST' } }
const result = await iamPost('/launch/prepare', { source, intent })
window.location.assign(result.url)
}
} catch (e) {
error.value = identityError(e)
} finally {
busy.value = false
}
})
</script>
@@ -312,6 +312,17 @@
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
<el-button
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
@@ -325,6 +336,17 @@
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
<el-button
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
@@ -943,6 +965,15 @@
label-width="108px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="dosage_bag_count"
>
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
@@ -968,6 +999,15 @@
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="aux_dosage_bag_count"
>
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
@@ -1432,10 +1472,12 @@ const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
dosage_bag_count: 1 as number | undefined,
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
aux_dosage_bag_count: 1 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
@@ -1444,9 +1486,15 @@ const patchUsageRules = computed<FormRules>(() => {
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
}
}
return rules
})
@@ -1459,6 +1507,7 @@ function openPatchUsageDialog() {
return
}
const aux = detailAuxUsage.value
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
@@ -1468,16 +1517,20 @@ function openPatchUsageDialog() {
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.aux_dosage_bag_count =
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.dosage_bag_count = 1
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.aux_dosage_bag_count = 1
patchUsageForm.medication_days = undefined
}
@@ -1498,17 +1551,25 @@ async function submitPatchUsage() {
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
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
}
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
@@ -1763,7 +1824,7 @@ async function refresh() {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
void fetchLogisticsTrace()
}
if (props.loadRelatedData) void fetchLogs(id)
if (props.loadRelatedData) await fetchLogs(id)
const diagId = d.diagnosis_id
if (props.loadRelatedData && diagId) {
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
@@ -883,6 +883,17 @@
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
<el-button
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
@@ -896,6 +907,17 @@
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
<el-button
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
@@ -2261,6 +2283,15 @@
label-width="96px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="dosage_bag_count"
>
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
@@ -2286,6 +2317,15 @@
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="aux_dosage_bag_count"
>
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
@@ -3954,10 +3994,12 @@ const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
dosage_bag_count: 1 as number | undefined,
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
aux_dosage_bag_count: 1 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
@@ -3966,9 +4008,15 @@ const patchUsageRules = computed<FormRules>(() => {
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
}
}
return rules
})
@@ -3981,6 +4029,7 @@ function openPatchUsageDialog() {
return
}
const aux = detailAuxUsage.value
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
@@ -3990,16 +4039,20 @@ function openPatchUsageDialog() {
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.aux_dosage_bag_count =
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.dosage_bag_count = 1
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.aux_dosage_bag_count = 1
patchUsageForm.medication_days = undefined
}
@@ -4020,17 +4073,25 @@ async function submitPatchUsage() {
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
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
}
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
+19
View File
@@ -144,6 +144,17 @@
>
<el-table-column type="selection" width="48" />
<el-table-column label="订单号" prop="order_no" min-width="150" />
<el-table-column label="处方业务订单号" min-width="210">
<template #default="{ row }">
<div v-if="row.prescription_orders?.length" class="flex flex-col gap-1">
<span v-for="order in row.prescription_orders" :key="order.id" class="break-all">
{{ order.order_no }}
</span>
</div>
<span v-else class="text-gray-400">未关联</span>
</template>
</el-table-column>
<el-table-column label="患者信息" min-width="150">
<template #default="{ row }">
@@ -302,6 +313,14 @@
<el-tag :type="getStatusTag(detailData.status)">
{{ getStatusText(detailData.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处方业务订单号" :span="2">
<div v-if="detailData.prescription_orders?.length" class="flex flex-col gap-1">
<span v-for="order in detailData.prescription_orders" :key="order.id" class="break-all">
{{ order.order_no }}
</span>
</div>
<span v-else class="text-gray-400">未关联</span>
</el-descriptions-item>
<el-descriptions-item label="患者姓名">
{{ detailData.patient?.patient_name || '待关联' }}
+30 -7
View File
@@ -1,5 +1,6 @@
<template>
<div class="reception-page">
<DoctorWebConsultationNotice />
<!-- 页头 -->
<div class="reception-header">
<div class="header-left">
@@ -83,10 +84,18 @@
<el-icon v-else><ChatDotRound /></el-icon>
</button>
</el-tooltip>
<el-tooltip content="发起通话" placement="top">
<button class="row-icon-btn call" @click="handleCall(row)">
<el-icon><Phone /></el-icon>
</button>
<el-tooltip :content="webConsultationBlocked ? webConsultationMessage : '发起通话'" placement="top">
<span class="row-call-action">
<button
class="row-icon-btn call"
:disabled="webConsultationBlocked"
:aria-label="webConsultationBlocked ? webConsultationMessage : '发起通话'"
@click="handleCall(row)"
>
<el-icon><Phone /></el-icon>
<span v-if="webConsultationBlocked">已停用</span>
</button>
</span>
</el-tooltip>
</div>
</div>
@@ -189,6 +198,8 @@
<script setup lang="ts">
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { useDoctorWebConsultation } from '@/hooks/useDoctorWebConsultation'
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
import NoteTimeline from './components/NoteTimeline.vue'
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
@@ -211,6 +222,7 @@ const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/e
const POLL_MS = 5_000
const PAGE_SIZE = 15
const { webConsultationBlocked, webConsultationMessage, ensureWebConsultationAllowed } = useDoctorWebConsultation()
interface QueueRow {
id: number
@@ -438,6 +450,7 @@ const handleSearch = async () => {
}
const handleCall = async (row: QueueRow) => {
if (!ensureWebConsultationAllowed()) return
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
@@ -454,6 +467,7 @@ const handleCall = async (row: QueueRow) => {
appointment_id: Number(row.id),
diagnosis_id: diagnosisId
})
if (!ensureWebConsultationAllowed()) return
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
@@ -1004,16 +1018,25 @@ onUnmounted(() => {
font-size: 15px;
padding: 0;
}
.row-icon-btn:hover {
.row-call-action {
display: inline-flex;
}
.row-icon-btn.call:disabled {
width: auto;
gap: 4px;
padding: 0 6px;
font-size: 12px;
}
.row-icon-btn:hover:not(:disabled) {
background: var(--rx-accent-soft);
color: var(--rx-accent-strong);
border-color: var(--rx-accent);
transform: translateY(-1px);
}
.row-icon-btn:active {
.row-icon-btn:active:not(:disabled) {
transform: scale(0.92);
}
.row-icon-btn.notify:hover {
.row-icon-btn.notify:hover:not(:disabled) {
background: #fff7e6;
color: var(--rx-warning);
border-color: var(--rx-warning);
@@ -0,0 +1,634 @@
<template>
<div class="identity-management">
<el-card shadow="never">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold">IAM 关联管理</h2>
<p class="text-gray-500 mt-2">
保留历史本地账号、业务角色与数据;只管理统一身份关联。
</p>
</div>
<el-button @click="reauthVisible = true">验证管理员身份</el-button>
</div>
<el-alert
v-if="error"
:title="error"
type="warning"
show-icon
:closable="false"
class="mb-4"
/>
<el-alert
v-if="!enabled"
title="IAM v2 尚未启用,原有业务与登录方式保持不变。"
type="info"
:closable="false"
/>
<el-form v-if="enabled" inline class="mt-4" @submit.prevent="reload">
<el-form-item label="本地账号"
><el-input
v-model="query.q"
placeholder="账号或姓名"
clearable
@keyup.enter="reload"
/></el-form-item>
<el-form-item label="关联状态"
><el-select v-model="query.state" clearable class="!w-40"
><el-option
v-for="(label, key) in states"
:key="key"
:label="label"
:value="key" /></el-select
></el-form-item>
<el-form-item
><el-button type="primary" @click="reload">查询</el-button
><el-button :disabled="!can('batch')" @click="batchVisible = true"
>批量预检</el-button
></el-form-item
>
</el-form>
<el-table v-if="enabled" v-loading="loading" :data="rows" row-key="id">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="account" label="本地账号" min-width="140" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column label="本地状态" width="110"
><template #default="{ row }"
><el-tag :type="row.disable ? 'danger' : 'success'">{{
row.disable ? '停用' : row.root ? '敏感账号' : '正常'
}}</el-tag></template
></el-table-column
>
<el-table-column label="IAM 员工" min-width="180"
><template #default="{ row }"
>{{ row.iam.employee?.displayName || '—' }}
<div class="text-xs text-gray-500">
{{ row.iam.employee?.username }}
</div></template
></el-table-column
>
<el-table-column label="关联状态" width="130"
><template #default="{ row }"
><el-tag :type="row.iam.state === 'verified' ? 'success' : 'info'">{{
states[row.iam.state] || row.iam.state
}}</el-tag>
<div class="text-xs">
版本 {{ row.iam.binding?.version ?? '—' }}
</div></template
></el-table-column
>
<el-table-column label="操作" min-width="210"
><template #default="{ row }">
<el-button
link
type="primary"
:disabled="
(!!row.root && !reviewAllowed) ||
row.iam.state === 'verified' ||
!can(row.iam.state === 'revoked' ? 'rebind' : 'bind')
"
@click="open(row, 'bind')"
>关联</el-button
>
<el-button
link
type="primary"
:disabled="
(!!row.root && !reviewAllowed) ||
row.iam.state === 'verified' ||
!can('create') ||
(row.iam.state === 'revoked' && !can('rebind'))
"
@click="open(row, 'create_and_bind')"
>创建并关联</el-button
>
<el-dropdown @command="(kind: string) => more(row, kind)"
><el-button link>更多</el-button
><template #dropdown
><el-dropdown-menu>
<el-dropdown-item
command="rebind"
:disabled="(!!row.root && !reviewAllowed) || !can('rebind')"
>换绑</el-dropdown-item
>
<el-dropdown-item
command="unbind"
:disabled="(!!row.root && !reviewAllowed) || !can('unbind')"
>解绑</el-dropdown-item
>
<el-dropdown-item command="audit">审计记录</el-dropdown-item>
</el-dropdown-menu></template
></el-dropdown
>
</template></el-table-column
>
</el-table>
<el-pagination
v-if="enabled"
class="mt-4 justify-end"
layout="total, prev, pager, next"
:total="total"
v-model:current-page="query.page"
:page-size="20"
@current-change="load"
/>
</el-card>
<el-drawer
v-model="drawer"
:title="titles[operation.kind]"
size="min(680px, 94vw)"
:close-on-click-modal="false"
>
<el-steps :active="step" finish-status="success" align-center
><el-step title="核对本地账号" /><el-step title="核对 IAM 身份" /><el-step
title="确认变更"
/></el-steps>
<section class="mt-8">
<template v-if="step === 0"
><h3 class="text-lg mb-4">{{ selected?.name }} · {{ selected?.account }}</h3>
<p>本地 ID:{{ selected?.id }}</p>
<p class="my-3">
当前关联:{{ selected?.iam.employee?.displayName || '未关联' }}(版本
{{ selected?.iam.binding?.version ?? '无' }})
</p>
<el-alert
title="本次操作不改变本地密码、角色、部门或历史数据;人员同名不代表同一身份。"
type="info"
:closable="false"
/></template>
<template v-if="step === 1">
<el-form v-if="operation.kind === 'create_and_bind'" label-position="top"
><el-form-item label="IAM 登录名"
><el-input
v-model="newEmployee.username"
maxlength="64" /></el-form-item
><el-form-item label="姓名"
><el-input
v-model="newEmployee.display_name"
maxlength="100" /></el-form-item
><el-form-item label="邮箱(可选)"
><el-input v-model="newEmployee.email" /></el-form-item
><el-alert
title="使用正常员工开户流程,默认应用授权遵循 IAM 当前策略。不会生成共享初始密码;本人首次使用需设置 IAM 密码。"
type="info"
:closable="false"
/></el-form>
<template v-else-if="operation.kind !== 'unbind'"
><el-input
v-model="identityQuery"
placeholder="搜索 IAM 登录名或姓名"
@keyup.enter="searchIdentities"
><template #append
><el-button @click="searchIdentities">搜索</el-button></template
></el-input
><el-table
class="mt-4"
:data="identities"
highlight-current-row
@current-change="choose"
><el-table-column label="选择" width="70"
><template #default="{ row }"
><el-radio
:model-value="operation.employee_id"
:value="row.id"
@change="choose(row)"
><span /></el-radio></template></el-table-column
><el-table-column prop="username" label="IAM 登录名" /><el-table-column
prop="displayName"
label="姓名" /><el-table-column
prop="status"
label="状态" /></el-table
></template>
<el-alert
v-else
title="解绑后跨平台快捷登录停止,相关来源/业务会话撤销,历史数据和本地账号保留。自动开户不会恢复已撤销关联。"
type="warning"
:closable="false"
/>
</template>
<template v-if="step === 2"
><el-descriptions :column="1" border
><el-descriptions-item label="本地账号"
>{{ selected?.account }} #{{ selected?.id }}</el-descriptions-item
><el-descriptions-item label="变更类型">{{
titles[operation.kind]
}}</el-descriptions-item
><el-descriptions-item label="原 IAM 员工">{{
selected?.iam.employee?.username || '无'
}}</el-descriptions-item
><el-descriptions-item label="目标 IAM 员工">{{
operation.kind === 'unbind'
? '解除关联'
: operation.kind === 'create_and_bind'
? newEmployee.username
: chosenIdentity?.username
}}</el-descriptions-item
><el-descriptions-item label="预期版本">{{
operation.expected_version ?? '首次关联'
}}</el-descriptions-item></el-descriptions
><el-input
class="mt-4"
v-model="operation.reason"
:disabled="!!submittedPayload"
type="textarea"
:rows="3"
maxlength="500"
placeholder="必填:核对方式和变更原因,将写入审计记录"
/><el-checkbox class="mt-4" v-model="confirmed"
>我已核对本地账号与 IAM 员工为同一人,理解会话撤销影响</el-checkbox
></template
>
<el-alert
v-if="drawerError"
class="mt-4"
:title="drawerError"
type="error"
:closable="false"
/>
<el-alert
v-if="result"
class="mt-4"
:title="`操作 ${result.id}:${result.status}${result.error?.code ? ' · ' + identityError(result) : ''}`"
:type="result.status === 'succeeded' ? 'success' : 'warning'"
:closable="false"
/>
<div v-if="result?.reviewUrl" class="mt-4">
<a
:href="result.reviewUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>中央复核 ↗</a
>
<p class="text-gray-500 mt-2">
由中央管理员完成近期验证并批准后,重试原操作;原目标、版本和原因保持不变。
</p>
</div>
<el-button
v-if="result && result.status !== 'succeeded'"
class="mt-4"
@click="refreshOperation"
>查询同一操作状态</el-button
>
</section>
<template #footer
><el-button @click="drawer = false">关闭</el-button
><el-button v-if="step > 0" :disabled="!!submittedPayload" @click="step--"
>上一步</el-button
><el-button
v-if="step < 2"
type="primary"
:disabled="step === 1 && !validIdentity"
@click="step++"
>下一步</el-button
><el-button
v-else
type="primary"
:loading="busy"
:disabled="
!confirmed || !operation.reason.trim() || result?.status === 'succeeded'
"
@click="submit"
>{{
result?.reviewUrl
? '完成中央复核后重试原操作'
: '确认' + titles[operation.kind]
}}</el-button
></template
>
</el-drawer>
<el-dialog v-model="reauthVisible" title="近期管理员身份验证" width="420px"
><el-input
v-model="password"
type="password"
show-password
placeholder="当前 ZYT 账号密码"
autocomplete="current-password"
@keyup.enter="reauth"
/>
<p class="mt-4 text-gray-500">
有效期 5 分钟。敏感目标还需中央管理员审核,此处不会越过审核。
</p>
<template #footer
><el-button type="primary" :loading="busy" @click="reauth"
>验证</el-button
></template
></el-dialog
>
<el-drawer v-model="auditVisible" title="关联审计" size="min(760px, 94vw)"
><el-table :data="audits"
><el-table-column prop="occurredAt" label="时间" min-width="170" /><el-table-column
prop="action"
label="操作" /><el-table-column label="操作人"
><template #default="{ row }">{{
typeof row.actor === 'string' ? row.actor : JSON.stringify(row.actor)
}}</template></el-table-column
><el-table-column prop="reason" label="原因" /></el-table
></el-drawer>
<el-dialog
v-model="batchVisible"
title="批量预检与确认"
width="min(920px, 94vw)"
:close-on-click-modal="false"
>
<el-alert
title="先预检,不会创建账号或修改绑定。仅选择 ready 项后显式确认;敏感账号与冲突项跳过。每批最多 100 条。"
type="info"
:closable="false"
/>
<el-input
v-model="batchText"
class="mt-4"
type="textarea"
:rows="7"
placeholder='[{"kind":"bind","user_id":42,"employee_id":"员工ID","reason":"核对后历史关联"}]'
/>
<el-button class="mt-4" :loading="busy" @click="preflight">只读预检</el-button>
<el-table
v-if="preview"
class="mt-4"
:data="preview.items"
@selection-change="(items: any[]) => (selectedIndexes = items.map((x) => x.index))"
><el-table-column
type="selection"
:selectable="(row: any) => row.status === 'ready'" /><el-table-column
prop="externalAccountId"
label="本地 ID" /><el-table-column prop="status" label="状态" /><el-table-column
prop="message"
label="结果"
/></el-table>
<el-checkbox v-if="preview" v-model="batchConfirmed" class="mt-4"
>确认仅执行已选择项目({{ selectedIndexes.length }} 条)</el-checkbox
>
<section v-if="job" class="mt-4">
<h3>任务 {{ job.id }} · {{ job.status }}</h3>
<el-table :data="job.items"
><el-table-column prop="externalAccountId" label="本地账号" /><el-table-column
prop="status"
label="状态"
/><el-table-column label="结果"
><template #default="{ row }">{{
row.error?.message || row.error?.code || '—'
}}</template></el-table-column
></el-table
><el-button @click="refreshJob">刷新任务</el-button
><el-button
:disabled="
!job.items?.some((x: any) => ['failed', 'pending'].includes(x.status))
"
@click="retryJob"
>只重试失败/待处理项</el-button
>
</section>
<template #footer
><el-button @click="batchVisible = false">关闭</el-button
><el-button
type="primary"
:loading="busy"
:disabled="
!preview?.previewId || !batchConfirmed || !selectedIndexes.length || !!job
"
@click="submitBatch"
>确认执行</el-button
></template
>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { iamGet, iamPost, identityError, type IdentityOperation } from '@/api/iam-v2'
const states: Record<string, string> = {
verified: '已关联',
unlinked: '未关联',
revoked: '已撤销',
pending: '待确认',
conflict: '冲突',
unknown: '待核验'
}
const titles: Record<string, string> = {
bind: '关联',
create_and_bind: '创建并关联',
rebind: '换绑',
unbind: '解绑',
reconcile: '对账'
}
const enabled = ref(false),
reviewAllowed = ref(false),
caps = ref<string[]>([]),
rows = ref<any[]>([]),
total = ref(0),
loading = ref(false),
busy = ref(false),
error = ref('')
const query = reactive({ q: '', state: '', page: 1, pageSize: 20 })
const drawer = ref(false),
step = ref(0),
selected = ref<any>(null),
identities = ref<any[]>([]),
identityQuery = ref(''),
chosenIdentity = ref<any>(null),
confirmed = ref(false),
drawerError = ref(''),
result = ref<any>(null)
const submittedPayload = ref<Record<string, unknown> | null>(null)
const operation = reactive<IdentityOperation>({ kind: 'bind', user_id: 0, reason: '' }),
newEmployee = reactive({ username: '', display_name: '', email: '' })
let operationKey = crypto.randomUUID(),
batchKey = crypto.randomUUID()
const reauthVisible = ref(false),
password = ref(''),
auditVisible = ref(false),
audits = ref<any[]>([])
const batchVisible = ref(false),
batchText = ref(''),
preview = ref<any>(null),
selectedIndexes = ref<number[]>([]),
batchConfirmed = ref(false),
job = ref<any>(null)
const can = (c: string) => caps.value.includes(c)
const validIdentity = computed(
() =>
operation.kind === 'unbind' ||
(operation.kind === 'create_and_bind'
? !!newEmployee.username.trim() && !!newEmployee.display_name.trim()
: !!operation.employee_id)
)
async function load() {
loading.value = true
try {
const r = await iamGet('/accounts', query)
rows.value = r.items
total.value = r.total
error.value = ''
} catch (e) {
error.value = identityError(e)
} finally {
loading.value = false
}
}
function reload() {
query.page = 1
void load()
}
function open(row: any, kind: IdentityOperation['kind']) {
if (kind === 'bind' && row.iam.state === 'revoked') kind = 'rebind'
selected.value = row
Object.assign(operation, {
kind,
user_id: row.id,
employee_id: undefined,
expected_version: row.iam.binding?.version,
reason: ''
})
Object.assign(newEmployee, { username: '', display_name: row.name, email: '' })
drawer.value = true
step.value = 0
confirmed.value = false
drawerError.value = ''
result.value = null
submittedPayload.value = null
chosenIdentity.value = null
identities.value = []
operationKey = crypto.randomUUID()
}
async function more(row: any, kind: string) {
if (kind === 'audit') {
try {
audits.value = (await iamGet(`/accounts/${row.id}/audit`)).items
auditVisible.value = true
} catch (e) {
error.value = identityError(e)
}
} else open(row, kind as IdentityOperation['kind'])
}
async function searchIdentities() {
try {
identities.value = (
await iamGet('/identities', { q: identityQuery.value, page: 1, pageSize: 20 })
).items
} catch (e) {
drawerError.value = identityError(e)
}
}
function choose(row: any) {
if (row?.status === 'active') {
chosenIdentity.value = row
operation.employee_id = row.id
}
}
async function submit() {
busy.value = true
try {
if (!submittedPayload.value) {
submittedPayload.value = JSON.parse(
JSON.stringify({
...operation,
...(operation.kind === 'create_and_bind'
? { new_employee: { ...newEmployee } }
: {})
})
)
}
result.value = await iamPost('/operations', submittedPayload.value!, operationKey)
drawerError.value = ''
await load()
} catch (e) {
drawerError.value = identityError(e)
} finally {
busy.value = false
}
}
async function refreshOperation() {
try {
result.value = await iamGet(`/operations/${result.value.id}`)
await load()
} catch (e) {
drawerError.value = identityError(e)
}
}
async function reauth() {
busy.value = true
try {
await iamPost('/reauth', { password: password.value })
reauthVisible.value = false
ElMessage.success('身份已验证,有效期 5 分钟')
await load()
} catch (e) {
error.value = identityError(e)
} finally {
password.value = ''
busy.value = false
}
}
async function preflight() {
busy.value = true
preview.value = null
job.value = null
batchConfirmed.value = false
selectedIndexes.value = []
try {
const items = JSON.parse(batchText.value)
if (!Array.isArray(items) || items.length < 1 || items.length > 100) throw Error('input')
preview.value = await iamPost('/batch/preflight', { items })
batchKey = crypto.randomUUID()
} catch (e) {
error.value = e instanceof SyntaxError ? '批量输入必须是 JSON 数组。' : identityError(e)
} finally {
busy.value = false
}
}
async function submitBatch() {
busy.value = true
try {
job.value = await iamPost(
'/batch',
{ preview_id: preview.value.previewId, indexes: selectedIndexes.value },
batchKey
)
await load()
} catch (e) {
error.value = identityError(e)
} finally {
busy.value = false
}
}
async function refreshJob() {
try {
job.value = await iamGet(`/jobs/${job.value.id}`)
await load()
} catch (e) {
error.value = identityError(e)
}
}
async function retryJob() {
busy.value = true
try {
job.value = await iamPost(`/jobs/${job.value.id}/retry`)
await load()
} catch (e) {
error.value = identityError(e)
} finally {
busy.value = false
}
}
onMounted(async () => {
try {
const s = await iamGet('/status')
enabled.value = s.enabled
caps.value = s.management_capabilities || []
reviewAllowed.value = s.protected_review_operator === true
if (enabled.value && can('read')) await load()
} catch (e) {
error.value = identityError(e)
}
})
</script>
<style scoped>
.identity-management p {
line-height: 1.7;
}
.identity-management :deep(.el-steps) {
margin-bottom: 24px;
}
</style>
@@ -0,0 +1,227 @@
<template>
<div class="prescription-ai-visibility">
<el-card v-loading="loading" shadow="never" class="!border-none">
<div class="text-xl font-medium mb-[20px]">处方 AI 一致度可见范围</div>
<el-alert type="info" :closable="false" show-icon class="mb-5">
<template #title>指定用户组或用户可查看一致度</template>
<div class="leading-6">
用户命中任一指定系统角色,或被单独指定,即可查看桌面端“与 AI
一致度”、逐味对照和一致度统计。AI
报告继续按原有权限开放,已有报告不受影响,患者与处方的数据范围保持原有规则。
</div>
</el-alert>
<el-alert
v-if="loadError"
:title="loadError"
type="error"
:closable="false"
show-icon
class="mb-5"
/>
<div v-if="loading" role="status" class="mb-5">正在读取当前可见范围…</div>
<el-form
v-if="hasLoaded"
class="ls-form"
:model="formData"
:disabled="!ready || loading || saving"
label-width="140px"
>
<el-form-item label="指定用户组" prop="role_ids">
<div class="w-full max-w-[640px]">
<el-select
v-model="formData.role_ids"
class="w-full"
multiple
filterable
clearable
placeholder="请选择系统角色,可搜索角色名称"
>
<el-option
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
/>
</el-select>
<div class="form-tips">账号拥有任一指定角色即可命中。</div>
</div>
</el-form-item>
<el-form-item label="指定用户" prop="admin_ids">
<div class="w-full max-w-[640px]">
<el-select
v-model="formData.admin_ids"
class="w-full"
multiple
filterable
clearable
placeholder="请选择用户,可搜索姓名或账号"
>
<el-option
v-for="admin in adminOptions"
:key="admin.id"
:label="admin.label"
:value="admin.id"
/>
</el-select>
<div class="form-tips">
单独指定的用户无需同时命中用户组。当前不在可选列表中的已配置 ID
会保留,移除后保存才会取消该项授权。
</div>
</div>
</el-form-item>
<el-form-item label="生效规则">
<div>
<div class="leading-6">指定用户组与指定用户按“或”生效。</div>
<div class="form-tips">
两项均为空时,所有账号(包括超级管理员)均隐藏一致度、对照与统计。
拥有本页管理权限不自动获得一致度查看权,超级管理员也需命中上述名单。
</div>
</div>
</el-form-item>
</el-form>
<el-alert
v-if="ready && !formData.role_ids.length && !formData.admin_ids.length"
title="当前选择为空:保存后所有账号将隐藏一致度、对照与统计,AI 报告仍保留。"
type="warning"
:closable="false"
show-icon
class="mb-5"
/>
<el-alert
v-if="saveError"
:title="saveError"
type="error"
:closable="false"
show-icon
class="mb-5"
/>
<el-button :disabled="loading || saving" @click="loadConfig">
{{ loadError ? '重试加载' : '重新加载' }}
</el-button>
</el-card>
<footer-btns v-if="canManage">
<el-button
type="primary"
:loading="saving"
:disabled="!ready || loading || saving"
@click="handleSubmit"
>
{{ saving ? '正在保存' : '保存' }}
</el-button>
</footer-btns>
</div>
</template>
<script setup lang="ts" name="prescriptionAiVisibility">
import { computed, reactive, ref } from 'vue'
import type {
PrescriptionAiVisibilityConfig,
PrescriptionAiVisibilityOptions
} from '@/api/setting/prescription_ai_visibility'
import {
getPrescriptionAiCapabilities,
getPrescriptionAiVisibilityConfig,
getPrescriptionAiVisibilityOptions,
setPrescriptionAiVisibilityConfig
} from '@/api/setting/prescription_ai_visibility'
import feedback from '@/utils/feedback'
const loading = ref(false)
const saving = ref(false)
const ready = ref(false)
const hasLoaded = ref(false)
const canManage = ref(false)
const loadError = ref('')
const saveError = ref('')
const formData = reactive<PrescriptionAiVisibilityConfig>({ role_ids: [], admin_ids: [] })
const optionsData = reactive<PrescriptionAiVisibilityOptions>({ roles: [], admins: [] })
const roleOptions = computed(() => {
const options = new Map(optionsData.roles.map((role) => [role.id, role]))
for (const id of formData.role_ids) {
if (!options.has(id)) {
options.set(id, { id, name: `已配置角色 #${id}(当前不在可选列表)` })
}
}
return [...options.values()]
})
const adminOptions = computed(() => {
const options = new Map(
optionsData.admins.map((admin) => [
admin.id,
{ id: admin.id, label: `${admin.name || `用户 #${admin.id}`}(${admin.account})` }
])
)
for (const id of formData.admin_ids) {
if (!options.has(id)) {
options.set(id, { id, label: `已配置用户 #${id}(当前不在可选列表)` })
}
}
return [...options.values()]
})
const assignConfig = (config: PrescriptionAiVisibilityConfig) => {
formData.role_ids = [...config.role_ids]
formData.admin_ids = [...config.admin_ids]
}
const loadConfig = async () => {
if (loading.value || saving.value) return
loading.value = true
ready.value = false
canManage.value = false
loadError.value = ''
saveError.value = ''
try {
const capabilities = await getPrescriptionAiCapabilities()
if (capabilities.can_manage_visibility !== true) {
loadError.value = '当前账号无权管理处方 AI 一致度可见范围。'
return
}
canManage.value = true
const [config, options] = await Promise.all([
getPrescriptionAiVisibilityConfig(),
getPrescriptionAiVisibilityOptions()
])
if (!Array.isArray(options?.roles) || !Array.isArray(options?.admins)) {
throw new Error('可选用户组或用户响应格式不正确')
}
// Apply both responses together; an incomplete load cannot overwrite the current policy.
assignConfig(config)
optionsData.roles = options.roles
optionsData.admins = options.admins
hasLoaded.value = true
ready.value = true
} catch {
loadError.value = '可见范围加载失败,尚未读取完整配置。请重试加载后再保存。'
} finally {
loading.value = false
}
}
const handleSubmit = async () => {
if (!ready.value || !canManage.value || loading.value || saving.value) return
saving.value = true
saveError.value = ''
try {
const config = await setPrescriptionAiVisibilityConfig({
role_ids: [...formData.role_ids],
admin_ids: [...formData.admin_ids]
})
assignConfig(config)
feedback.msgSuccess('可见范围已保存')
} catch {
saveError.value = '保存结果未确认,当前选择已保留。请重试保存,或重新加载以确认服务端策略。'
} finally {
saving.value = false
}
}
void loadConfig()
</script>
@@ -56,6 +56,70 @@
/>
<el-empty v-else description="暂无数据" />
</el-card>
<!-- 我的复诊接诊率(方案 §10 D1-h):仅本人行,口径与「复诊接诊率」报表一致 -->
<el-card v-if="canViewRevisitRate" class="!border-none mt-4" shadow="never">
<template #header>
<div class="card-header">
<div class="card-header-left">
<span class="card-title">我的复诊接诊率</span>
<el-date-picker
v-model="revisitMonth"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
:disabled-date="disableFutureMonth"
size="small"
style="width: 130px"
@change="fetchRevisitRate"
/>
</div>
<span class="card-hint">{{ revisitMonthText }} · 与「复诊接诊率」报表同口径</span>
</div>
</template>
<div v-loading="revisitLoading" class="revisit-rate-body">
<template v-if="revisitRow">
<div class="revisit-kpi-grid">
<div class="revisit-kpi-card">
<div class="stats-kpi-label">新指派数</div>
<div class="revisit-kpi-value">
{{ Number(revisitRow.assigned_count ?? 0) }}
</div>
<div class="revisit-kpi-sub">当月新指派给我的患者</div>
</div>
<div class="revisit-kpi-card">
<div class="stats-kpi-label">继承数</div>
<div class="revisit-kpi-value">
{{ Number(revisitRow.inherit_count ?? 0) }}
</div>
<div class="revisit-kpi-sub">当月继承给我的患者</div>
</div>
<div class="revisit-kpi-card is-primary">
<div class="stats-kpi-label">复诊合计接诊率</div>
<div class="revisit-kpi-value">
{{ formatRate(revisitRow.visit_all_rate) }}
</div>
<div class="revisit-kpi-sub">{{ visitAllFractionText }}</div>
</div>
<div v-for="slot in revisitSlots" :key="slot" class="revisit-kpi-card">
<div class="stats-kpi-label">{{ slotLabel(slot) }}接诊率</div>
<div class="revisit-kpi-value">
{{ formatRate(slotMetric(slot, 'rate')) }}
</div>
<div class="revisit-kpi-sub">{{ slotFractionText(slot) }}</div>
</div>
</div>
</template>
<el-empty v-else :description="revisitEmptyText" :image-size="72" />
<div class="revisit-caliber">
口径:N 诊接诊率 = 当月 N 诊成交单数 ÷ N 诊分母(新指派数 + 起算诊次 ≤ N 的继承数),分母为 0 显示「—」;
<template v-if="revisitInheritMode === 'exclude'">当前为旧口径,继承患者不计入分母;</template>
<template v-else>继承患者按继承时诊次从第 k+1 诊起计入;</template>
复诊合计 = 各诊次成交单数之和 ÷ 被指派合计(新指派 ∪ 继承),可超过 100%。
</div>
</div>
</el-card>
</div>
</template>
@@ -63,7 +127,12 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import vCharts from 'vue-echarts'
import { assistantPerformanceOverview } from '@/api/stats'
import {
assistantPerformanceOverview,
assistantPerformanceRevisitRate,
type AssistantRevisitRateResult
} from '@/api/stats'
import { hasPermission } from '@/utils/perm'
const loading = ref(false)
const dateRange = ref<string[]>([])
@@ -191,8 +260,78 @@ const handleReset = () => {
fetchData()
}
// ── 我的复诊接诊率(方案 §10 D1-h)────────────────────────
/** 权限点由 server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql 登记并自动授予已有个人业绩页的角色 */
const canViewRevisitRate = hasPermission(['stats.assistantPerformance/revisitRate'])
const initNow = new Date()
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
const revisitMonth = ref<string>(currentMonth)
const revisitLoading = ref(false)
const revisitResult = ref<AssistantRevisitRateResult | null>(null)
const revisitRow = computed(() => revisitResult.value?.row ?? null)
const revisitInheritMode = computed(() => revisitResult.value?.inherit_mode ?? 'slot')
const revisitSlots = computed(() =>
(revisitResult.value?.slots ?? []).map((s) => Number(s)).filter((s) => s >= 2)
)
const revisitMonthText = computed(() => {
const [y, m] = (revisitResult.value?.month || revisitMonth.value).split('-')
return y && m ? `${y} 年 ${Number(m)} 月` : revisitMonth.value
})
/** row=null:非二中心医助(in_scope=false)与「当月无被指派患者」文案区分 */
const revisitEmptyText = computed(() => {
if (revisitResult.value && revisitResult.value.in_scope === false) {
return '暂无数据(仅二中心医助统计)'
}
return revisitMonth.value === currentMonth ? '本月暂无被指派患者' : '该月暂无被指派患者'
})
const disableFutureMonth = (date: Date) => {
const now = new Date()
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
}
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const slotLabel = (slot: number) => {
if (slot >= 2 && slot <= 10) return `${cnDigits[slot]}诊`
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
return `${slot}诊`
}
const formatRate = (rate: unknown) =>
rate === null || rate === undefined || rate === '' ? '—' : `${Number(rate)}%`
/** 读取本人行的 visit{N}_count / visit{N}_den / visit{N}_rate */
const slotMetric = (slot: number, kind: 'count' | 'den' | 'rate') =>
revisitRow.value?.[`visit${slot}_${kind}`]
/** 「成交 N 单 / 分母 M」 */
const fractionText = (count: unknown, den: unknown) =>
`成交 ${Number(count ?? 0)} 单 / 分母 ${Number(den ?? 0)}`
const slotFractionText = (slot: number) =>
fractionText(slotMetric(slot, 'count'), slotMetric(slot, 'den'))
const visitAllFractionText = computed(() =>
fractionText(revisitRow.value?.visit_all_count, revisitRow.value?.visit_all_den)
)
const fetchRevisitRate = async () => {
if (!canViewRevisitRate) return
revisitLoading.value = true
try {
revisitResult.value = await assistantPerformanceRevisitRate({ month: revisitMonth.value })
} catch {
// 错误提示由 utils/request 统一弹出;失败时保留上一次结果
} finally {
revisitLoading.value = false
}
}
onMounted(() => {
fetchData()
fetchRevisitRate()
})
</script>
@@ -270,4 +409,57 @@ onMounted(() => {
.stats-chart {
height: 360px;
}
.card-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.revisit-rate-body {
min-height: 120px;
}
.revisit-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.revisit-kpi-card {
background: linear-gradient(145deg, #ffffff, #f7f9ff);
border: 1px solid #ebf1ff;
border-radius: 12px;
padding: 14px 16px;
}
.revisit-kpi-card.is-primary {
background: linear-gradient(145deg, #ffffff, #eef3ff);
border-color: #d9e4ff;
}
.revisit-kpi-value {
margin-top: 8px;
font-size: 24px;
line-height: 1.1;
font-weight: 700;
color: #1f2937;
}
.revisit-kpi-card.is-primary .revisit-kpi-value {
color: #4a78ff;
}
.revisit-kpi-sub {
margin-top: 6px;
font-size: 12px;
color: #909399;
}
.revisit-caliber {
margin-top: 12px;
font-size: 12px;
line-height: 1.6;
color: #909399;
}
</style>
@@ -67,7 +67,10 @@
</el-form>
<div class="log-hint">
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(&gt;70% 每人3条/日,60%~70%
每人2条/日,50%~60% 每人1条/日,&lt;50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
每人2条/日,50%~60% 每人1条/日,&lt;50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。
<br />
口径版本:<b>v1 旧口径</b> = 分母仅新指派(visit2_rate_new_assign);<b>v2 新口径</b> = 每列独立分母,继承按继承时诊次计入(visit2_rate)。
每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
</div>
</el-card>
@@ -137,7 +140,7 @@
<template #default="{ row }">
<template v-if="Number(row.assistant_id) > 0">
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
<div class="cell-sub">上月二诊接诊率 {{ formatRate(tierRate(row)) }}</div>
</template>
<span v-else>—</span>
</template>
@@ -145,6 +148,29 @@
<el-table-column label="档位" width="100" align="center">
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
</el-table-column>
<el-table-column label="分档口径" min-width="150">
<template #header>
<span>分档口径</span>
<el-tooltip
placement="top"
effect="dark"
content="本批次分档所用口径;下方为两套口径的上月二诊接诊率(新口径 = 每列独立分母 / 旧口径 = 分母仅新指派)"
>
<el-icon class="col-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<template v-if="Number(row.action) === 1">
<el-tag :type="row.rate_version === 'v2' ? 'primary' : 'info'" size="small">
{{ row.rate_version_text || row.rate_version || 'v1' }}
</el-tag>
<div class="cell-sub">
新口径 {{ formatRate(row.visit2_rate) }} · 旧口径 {{ formatRate(row.visit2_rate_new_assign) }}
</div>
</template>
<span v-else class="cell-sub">—</span>
</template>
</el-table-column>
<el-table-column label="轮次" width="70" align="center">
<template #default="{ row }">
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
@@ -186,6 +212,7 @@
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import { InfoFilled } from '@element-plus/icons-vue'
import { onMounted, reactive, ref, watch } from 'vue'
const dateRange = ref<[string, string] | null>(null)
@@ -241,6 +268,14 @@ const handleReset = () => {
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
/** 「医助」列显示的接诊率 = 本批次分档所用口径的率(v1 旧口径取 visit2_rate_new_assign,历史行无该列时回落 visit2_rate) */
const tierRate = (row: any): number | null | undefined => {
if (row?.rate_version === 'v1' && row?.visit2_rate_new_assign !== null && row?.visit2_rate_new_assign !== undefined) {
return row.visit2_rate_new_assign
}
return row?.visit2_rate
}
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
const onSelectionChange = (rows: any[]) => {
@@ -315,5 +350,12 @@ onMounted(() => getLists())
font-size: 12px;
color: var(--el-text-color-secondary);
}
.col-hint {
margin-left: 2px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
}
</style>
@@ -0,0 +1,901 @@
<template>
<div class="revisit-rate-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="rate-filter-form">
<el-form-item :label="dateLabel">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator="至"
:clearable="false"
style="width: 280px"
@change="loadData"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="deptId"
:data="deptTreeOptions"
placeholder="二中心(全部)"
clearable
filterable
check-strictly
node-key="id"
:default-expand-all="true"
:props="deptTreeProps"
style="width: 210px"
@change="handleDeptChange"
/>
</el-form-item>
<el-form-item label="医助">
<el-select
v-model="assistantId"
placeholder="全部医助"
filterable
clearable
:loading="optionsLoading"
style="width: 180px"
@change="loadData"
>
<el-option
v-for="assistant in assistantOptions"
:key="assistant.id"
:label="assistant.name"
:value="assistant.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
<el-button :disabled="loading" @click="handleReset">重置</el-button>
</el-form-item>
<el-form-item>
<el-popover placement="bottom-start" :width="540" trigger="hover">
<template #reference>
<span class="rate-caliber-trigger"
><el-icon><InfoFilled /></el-icon>统计口径</span
>
</template>
<div class="rate-caliber">
<p>
<b>{{ cohortLabel }}</b
>:按所选{{
dateLabel
}}范围确定患者,订单从对应指派后累计至查询当天;结束日期允许选择未来日期,但未来订单不会提前计入。
</p>
<p v-if="isInherit">
<b>继承诊次重新起算</b>:继承后的第 1 笔有效订单算二诊,第 2
笔算三诊,依次递增;不使用继承前的全局诊次,也不叠加诊次偏移。只统计继承患者,不混入新指派患者。
</p>
<p v-else>
只统计新指派患者,不混入继承患者。继承患者在独立的「继承复诊接诊率」页面统计。
</p>
<p>
<b>N 诊接诊率</b> = N 诊单数 ÷ 本页{{ cohortLabel }};分母为 0
显示「—」。<b>复诊合计接诊率</b> = 复诊合计单数 ÷ 本页{{
cohortLabel
}},表示人均复诊成交单数,可超过 100%。
</p>
<p>
订单仅计入有效业务订单,剔除已取消 / 拒收 /
退款;归属按服务端的患者持有记录计算。医助按人事部门归组;仅统计「二中心」及其组织下级,选定部门时包含其下级。
</p>
<p>
部门行和合计行按诊单去重,不能直接将医助行相加。灰字为「单数 /
分母」。
</p>
</div>
</el-popover>
</el-form-item>
</el-form>
<el-alert class="mt-3" type="info" :closable="false" show-icon :title="reportHint" />
<el-alert
v-if="optionsError"
class="mt-3"
type="warning"
:title="optionsError"
:closable="false"
/>
<el-alert
v-if="queryError"
class="mt-3"
type="error"
:title="queryError"
:closable="false"
/>
</el-card>
<div class="rate-kpi-grid">
<div
v-for="card in summaryCards"
:key="card.key"
class="rate-kpi-card"
:class="{ 'is-clickable': card.clickable }"
@click="card.clickable && card.onClick()"
>
<div class="rate-kpi-label">{{ card.label }}</div>
<div class="rate-kpi-value">{{ card.value }}</div>
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
</div>
</div>
<el-card class="!border-none mt-4" shadow="never">
<template #header>
<div class="rate-card-header">
<span>{{ reportTitle }} · 部门 / 医助明细</span>
<span class="rate-card-hint">{{ dateLabel }}:{{ rangeText }}</span>
<span v-if="observedUntil" class="rate-card-hint"
>订单统计截至:{{ observedUntil }}</span
>
</div>
<div class="rate-card-hint mt-2">
点击{{ cohortLabel }}、各诊次单数可查看患者与订单;底栏合计按诊单去重。
</div>
</template>
<el-table
v-loading="loading"
:data="rows"
size="small"
border
row-key="row_key"
:tree-props="{ children: 'children' }"
:default-expand-all="true"
show-summary
:summary-method="getSummaries"
>
<el-table-column label="部门 / 医助" min-width="200" fixed>
<template #default="{ row }">
<span v-if="row.is_dept" class="rate-dept-cell"
>{{ row.dept_name
}}<span class="rate-dept-count"
>{{ row.assistant_count }} 人</span
></span
>
<span v-else>{{ row.assistant_name }}</span>
</template>
</el-table-column>
<el-table-column
:prop="cohortCountKey"
:label="cohortLabel"
min-width="100"
align="right"
>
<template #header>
<span>{{ cohortLabel }}</span>
<el-tooltip
effect="dark"
placement="top"
:content="`所选${dateLabel}范围内的${cohortLabel},部门和合计按诊单去重`"
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
></el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewAssignLines && row[cohortCountKey] > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row)"
>{{ row[cohortCountKey] }}</el-link
>
<span v-else>{{ row[cohortCountKey] ?? 0 }}</span>
</template>
</el-table-column>
<template v-for="slot in visitSlots" :key="slot">
<el-table-column
:prop="`visit${slot}_count`"
:label="`${slotLabel(slot)}单数`"
min-width="96"
align="right"
>
<template #header>
<span>{{ slotLabel(slot) }}单数</span>
<el-tooltip effect="dark" placement="top" :content="slotCountTip(slot)"
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
></el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
type="primary"
:underline="false"
@click="openOrderDetail(slot, row)"
>{{ row[`visit${slot}_count`] }}</el-link
>
<span v-else>{{ row[`visit${slot}_count`] ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column
:prop="`visit${slot}_rate`"
:label="`${slotLabel(slot)}接诊率`"
min-width="110"
align="right"
>
<template #header>
<span>{{ slotLabel(slot) }}接诊率</span>
<el-tooltip
effect="dark"
placement="top"
:content="`${slotLabel(slot)}单数 ÷ ${cohortLabel};分母为 0 显示「—」`"
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
></el-tooltip>
</template>
<template #default="{ row }">
<div class="rate-cell">
<span class="rate-cell-main">{{
formatRate(row[`visit${slot}_rate`])
}}</span
><span class="rate-cell-sub">{{
denText(row, `visit${slot}`)
}}</span>
</div>
</template>
</el-table-column>
</template>
<el-table-column
prop="visit_all_count"
label="复诊合计单数"
min-width="110"
align="right"
>
<template #header>
<span>复诊合计单数</span>
<el-tooltip
effect="dark"
placement="top"
content="所选患者从对应指派后累计至查询当天的全部复诊订单数"
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
></el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewOrderLines && row.visit_all_count > 0"
type="primary"
:underline="false"
@click="openOrderDetail(0, row)"
>{{ row.visit_all_count }}</el-link
>
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column
prop="visit_all_rate"
label="复诊合计接诊率"
min-width="128"
align="right"
>
<template #header>
<span>复诊合计接诊率</span>
<el-tooltip
effect="dark"
placement="top"
:content="`复诊合计单数 ÷ ${cohortLabel};表示人均复诊成交单数,可超过 100%`"
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
></el-tooltip>
</template>
<template #default="{ row }">
<div class="rate-cell">
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span
><span class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="detailVisible"
:title="detailTitle"
width="1080px"
top="6vh"
destroy-on-close
@close="invalidateDetail"
>
<p class="rate-detail-hint">{{ detailHint }}</p>
<el-alert
v-if="detailError"
class="mb-3"
type="error"
:title="detailError"
:closable="false"
/>
<el-table
v-loading="detailLoading"
:data="detailRows"
size="small"
border
stripe
max-height="60vh"
>
<template v-if="detailType === 'assign'">
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
>
<span v-else>{{ row.patient_name || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
><template #default="{ row }">{{
row.patient_phone || '—'
}}</template></el-table-column
>
<el-table-column
prop="assistant_names"
:label="isInherit ? '继承医助' : '被指派医助'"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="assign_count"
:label="isInherit ? '范围内继承次数' : '范围内指派次数'"
width="120"
align="right"
/>
<el-table-column
prop="first_assign_time_text"
:label="isInherit ? '首次继承时间' : '首次指派时间'"
min-width="160"
/>
<el-table-column
prop="last_assign_time_text"
:label="isInherit ? '最近继承时间' : '最近指派时间'"
min-width="160"
/>
</template>
<template v-else>
<el-table-column
prop="order_no"
label="订单号"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link
v-if="canViewOrderDetail && row.order_id"
type="primary"
:underline="false"
@click="openOrderInfo(row.order_id)"
>{{ row.order_no || `#${row.order_id}` }}</el-link
>
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
</template>
</el-table-column>
<el-table-column
v-if="detailSlot === 0 || isInherit"
prop="visit_slot"
:label="isInherit ? '继承后诊次' : '诊次'"
width="100"
align="center"
><template #default="{ row }">{{
slotLabel(Number(row.visit_slot))
}}</template></el-table-column
>
<el-table-column
v-if="isInherit"
prop="global_visit_slot"
label="全局诊次(参考)"
width="130"
align="center"
><template #default="{ row }">{{
row.global_visit_slot ? slotLabel(Number(row.global_visit_slot)) : '—'
}}</template></el-table-column
>
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
>
<span v-else>{{ row.patient_name || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
><template #default="{ row }">{{
row.patient_phone || '—'
}}</template></el-table-column
>
<el-table-column prop="amount" label="金额" width="100" align="right"
><template #default="{ row }">¥ {{ row.amount }}</template></el-table-column
>
<el-table-column
prop="cohort_assign_time_text"
:label="isInherit ? '继承时间' : '指派时间'"
min-width="160"
/>
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
</template>
</el-table>
<div class="rate-detail-footer">共 {{ detailCount }} 条</div>
</el-dialog>
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
<diagnosis-edit-popup ref="diagnosisEditRef" />
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
import {
revisitCohortAssignLines,
revisitCohortDeptOptions,
revisitCohortOverview,
revisitCohortVisitOrderLines,
type RevisitRateCohortType,
type RevisitRateDetailQuery,
type RevisitRateQuery
} from '@/api/stats'
import { hasPermission } from '@/utils/perm'
const props = defineProps<{ cohortType: RevisitRateCohortType }>()
const OrderDetailDrawer = defineAsyncComponent(
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
)
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
interface MetricPack extends Record<string, any> {
assigned_count: number
inherit_count: number
assigned_total_count: number
visit_all_count: number
visit_all_den: number
visit_all_rate: number | null
}
interface StatsRow extends MetricPack {
row_key: string
is_dept: 0 | 1
dept_id: number
dept_name: string
assistant_id?: number
assistant_name?: string
assistant_count?: number
children?: StatsRow[]
}
interface DeptOption {
id: number
pid: number
name: string
}
interface DeptTreeNode extends DeptOption {
children?: DeptTreeNode[]
}
interface AssistantOption {
id: number
name: string
dept_id: number
}
interface SummaryCard {
key: string
label: string
value: string
sub: string
clickable: boolean
onClick: () => void
}
const isInherit = computed(() => props.cohortType === 'inherit')
const cohortLabel = computed(() => (isInherit.value ? '继承数' : '新指派数'))
const cohortCountKey = computed(() => (isInherit.value ? 'inherit_count' : 'assigned_count'))
const dateLabel = computed(() => (isInherit.value ? '继承日期' : '指派日期'))
const reportTitle = computed(() => (isInherit.value ? '继承复诊接诊率' : '按指派周期复诊率'))
const reportHint = computed(() =>
isInherit.value
? '按继承日期范围选择患者,订单累计至查询当天。继承后第 1 笔有效订单算二诊,第 2 笔算三诊;忽略继承前全局诊次及偏移,新指派患者另页统计。'
: '按新指派日期范围选择患者,订单累计至查询当天,跨月继续累计。继承患者在独立的「继承复诊接诊率」页面统计。'
)
const permissionPrefix = computed(() =>
isInherit.value ? 'stats.revisitInheritRate' : 'stats.revisitAssignRate'
)
const canViewAssignLines = computed(() => hasPermission([`${permissionPrefix.value}/assignLines`]))
const canViewOrderLines = computed(() =>
hasPermission([`${permissionPrefix.value}/visitOrderLines`])
)
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
const formatDate = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const defaultDateRange = (): [string, string] => {
const now = new Date()
return [formatDate(new Date(now.getFullYear(), now.getMonth(), 1)), formatDate(now)]
}
const dateRange = ref<[string, string]>(defaultDateRange())
const deptId = ref<number | undefined>()
const assistantId = ref<number | undefined>()
const loading = ref(false)
const queryError = ref('')
const rows = ref<StatsRow[]>([])
const total = ref<MetricPack | null>(null)
const visitSlots = ref<number[]>([2, 3, 4])
const observedUntil = ref('')
/** 下钻只复用已展示报表的查询快照,避免读取正在编辑的筛选。 */
const loadedQuery = ref<RevisitRateQuery | null>(null)
let overviewRequestId = 0
let disposed = false
const rangeText = computed(() =>
loadedQuery.value
? `${loadedQuery.value.start_date} 至 ${loadedQuery.value.end_date}`
: `${dateRange.value[0]} 至 ${dateRange.value[1]}`
)
const currentQuery = (): RevisitRateQuery => ({
start_date: dateRange.value[0],
end_date: dateRange.value[1],
dept_ids: deptId.value ? String(deptId.value) : '',
assistant_id: assistantId.value || undefined
})
const deptOptions = ref<DeptOption[]>([])
const allAssistants = ref<AssistantOption[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const deptTreeOptions = computed<DeptTreeNode[]>(() => {
const nodes = new Map<number, DeptTreeNode>()
deptOptions.value.forEach((item) => nodes.set(item.id, { ...item, children: [] }))
const roots: DeptTreeNode[] = []
nodes.forEach((node) => {
const parent = nodes.get(node.pid)
if (parent && parent !== node) parent.children!.push(node)
else roots.push(node)
})
const stripEmptyChildren = (node: DeptTreeNode) => {
if (node.children?.length) node.children.forEach(stripEmptyChildren)
else delete node.children
}
roots.forEach(stripEmptyChildren)
return roots
})
const selectedDeptIds = computed(() => {
if (!deptId.value) return null
const ids = new Set<number>([Number(deptId.value)])
let expanded = true
while (expanded) {
expanded = false
deptOptions.value.forEach((item) => {
if (ids.has(item.pid) && !ids.has(item.id)) {
ids.add(item.id)
expanded = true
}
})
}
return ids
})
const assistantOptions = computed(() =>
allAssistants.value.filter(
(item) => !selectedDeptIds.value || selectedDeptIds.value.has(item.dept_id)
)
)
const handleDeptChange = () => {
if (assistantId.value && !assistantOptions.value.some((item) => item.id === assistantId.value))
assistantId.value = undefined
void loadData()
}
const loadDeptOptions = async () => {
optionsLoading.value = true
optionsError.value = ''
try {
const res = await revisitCohortDeptOptions(props.cohortType)
if (disposed) return
deptOptions.value = (res?.rows ?? []).map((item: DeptOption) => ({
...item,
id: Number(item.id),
pid: Number(item.pid)
}))
allAssistants.value = (res?.assistants ?? []).map((item: AssistantOption) => ({
...item,
id: Number(item.id),
dept_id: Number(item.dept_id)
}))
} catch {
if (!disposed) optionsError.value = '部门及医助选项加载失败,请刷新页面重试。'
} finally {
if (!disposed) optionsLoading.value = false
}
}
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const slotLabel = (slot: number) => {
if (slot === 0) return '复诊合计'
if (slot >= 1 && slot <= 10) return `${cnDigits[slot]}诊`
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
return `${slot}诊`
}
const slotCountTip = (slot: number) =>
isInherit.value
? `所选继承患者在继承后的第 ${slot - 1} 笔有效订单数,累计至查询当天;不使用继承前全局诊次和偏移`
: `所选新指派患者的${slotLabel(slot)}有效订单数,从对应指派后累计至查询当天`
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
const denText = (row: Record<string, any>, prefix: string) =>
`${Number(row[`${prefix}_count`] ?? 0)} / ${row[`${prefix}_den`] ?? '—'}`
const summaryCards = computed<SummaryCard[]>(() => {
const t = total.value
const cards: SummaryCard[] = [
{
key: 'cohort',
label: cohortLabel.value,
value: t ? String(t[cohortCountKey.value] ?? 0) : '—',
sub: `按${dateLabel.value}范围选择患者`,
clickable: !!(canViewAssignLines.value && t && t[cohortCountKey.value] > 0),
onClick: () => void openAssignDetail(null)
}
]
visitSlots.value.forEach((slot) =>
cards.push({
key: `visit${slot}`,
label: `${slotLabel(slot)}接诊率`,
value: t ? formatRate(t[`visit${slot}_rate`]) : '—',
sub: t
? `${slotLabel(slot)}下单 ${t[`visit${slot}_count`] ?? 0} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}`
: '',
clickable: !!(canViewOrderLines.value && t && t[`visit${slot}_count`] > 0),
onClick: () => void openOrderDetail(slot, null)
})
)
cards.push({
key: 'visit_all',
label: '复诊合计接诊率',
value: t ? formatRate(t.visit_all_rate) : '—',
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 分母 ${t.visit_all_den ?? '—'}` : '',
clickable: !!(canViewOrderLines.value && t && t.visit_all_count > 0),
onClick: () => void openOrderDetail(0, null)
})
return cards
})
/** 服务端已按诊单去重,合计不能由各医助行相加。 */
const getSummaries = ({ columns }: { columns: Array<{ property?: string }> }) =>
columns.map((column, index) => {
if (index === 0) return '合计'
const t = total.value
if (!t) return ''
const prop = column.property || ''
if (prop === cohortCountKey.value) return String(t[prop] ?? 0)
const match = /^visit(\d+|_all)_(count|rate)$/.exec(prop)
if (!match) return ''
return match[2] === 'rate'
? `${formatRate(t[prop])}(${denText(t, `visit${match[1]}`)})`
: String(t[prop] ?? 0)
})
const loadData = async () => {
const requestId = ++overviewRequestId
invalidateDetail()
detailVisible.value = false
loadedQuery.value = null
rows.value = []
total.value = null
observedUntil.value = ''
queryError.value = ''
loading.value = true
const query = currentQuery()
try {
const res = await revisitCohortOverview(query, props.cohortType)
if (disposed || requestId !== overviewRequestId) return
rows.value = res?.rows ?? []
total.value = res?.total ?? null
observedUntil.value = res?.observed_until ?? ''
const slots: number[] = Array.isArray(res?.slots) ? res.slots.map(Number) : []
visitSlots.value = slots.length
? [
...new Set(
slots.filter((slot) => Number.isInteger(slot) && slot >= 2 && slot <= 50)
)
].sort((a, b) => a - b)
: [2, 3, 4]
loadedQuery.value = { ...query }
} catch {
if (!disposed && requestId === overviewRequestId)
queryError.value = '报表加载失败,请重新查询。'
} finally {
if (!disposed && requestId === overviewRequestId) loading.value = false
}
}
const detailVisible = ref(false)
const detailType = ref<'assign' | 'orders'>('assign')
const detailTitle = ref('')
const detailHint = ref('')
const detailLoading = ref(false)
const detailError = ref('')
const detailRows = ref<any[]>([])
const detailCount = ref(0)
const detailSlot = ref(2)
let detailRequestId = 0
const invalidateDetail = () => {
++detailRequestId
detailLoading.value = false
detailRows.value = []
detailCount.value = 0
detailError.value = ''
}
const rowScope = (
row: StatsRow | null
): Pick<RevisitRateDetailQuery, 'scope_assistant_id' | 'dept_id'> & { label: string } => {
if (!row) return { label: '合计' }
return row.is_dept
? { dept_id: row.dept_id, label: row.dept_name }
: { scope_assistant_id: row.assistant_id, label: row.assistant_name || '' }
}
const openDetail = async (type: 'assign' | 'orders', slot: number, row: StatsRow | null) => {
if (loading.value || !loadedQuery.value) return
if (type === 'assign' ? !canViewAssignLines.value : !canViewOrderLines.value) return
const query = { ...loadedQuery.value }
const { label, ...scope } = rowScope(row)
const requestId = ++detailRequestId
detailType.value = type
detailSlot.value = slot
detailTitle.value = `${label} · ${type === 'assign' ? `${cohortLabel.value}明细` : `${slotLabel(slot)}订单明细`}(${query.start_date} 至 ${query.end_date})`
detailHint.value = `${dateLabel.value}:${query.start_date} 至 ${query.end_date};订单统计截至:${observedUntil.value || '查询当天'}${isInherit.value && type === 'orders' ? '。继承后首笔有效订单算二诊,全局诊次仅供参考。' : '。'}`
detailRows.value = []
detailCount.value = 0
detailError.value = ''
detailLoading.value = true
detailVisible.value = true
try {
// assistant_id 保留顶层筛选,scope_assistant_id 独立约束医助行。
const params: RevisitRateDetailQuery = { ...query, ...scope }
const res =
type === 'assign'
? await revisitCohortAssignLines(params, props.cohortType)
: await revisitCohortVisitOrderLines({ ...params, slot }, props.cohortType)
if (disposed || requestId !== detailRequestId || !detailVisible.value) return
detailRows.value = res?.rows ?? []
detailCount.value = Number(res?.count ?? detailRows.value.length)
} catch {
if (!disposed && requestId === detailRequestId)
detailError.value = '明细加载失败,请关闭后重试。'
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
const openAssignDetail = (row: StatsRow | null) => openDetail('assign', 0, row)
const openOrderDetail = (slot: number, row: StatsRow | null) => openDetail('orders', slot, row)
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
const openOrderInfo = (orderId: number) => {
if (canViewOrderDetail && orderId) orderDetailRef.value?.open(orderId)
}
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
const openPatientDetail = (diagnosisId: number) => {
if (canViewPatientDetail && diagnosisId) diagnosisEditRef.value?.openViewOnly(diagnosisId)
}
const handleReset = () => {
dateRange.value = defaultDateRange()
deptId.value = undefined
assistantId.value = undefined
void loadData()
}
onMounted(() => {
void loadDeptOptions()
void loadData()
})
onBeforeUnmount(() => {
disposed = true
++overviewRequestId
++detailRequestId
})
</script>
<style lang="scss" scoped>
.revisit-rate-page {
.rate-filter-form {
display: flex;
flex-wrap: wrap;
gap: 12px 0;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.rate-caliber-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
cursor: help;
}
.rate-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.rate-kpi-card {
background: var(--el-bg-color);
border-radius: 8px;
padding: 16px 20px;
&.is-clickable {
cursor: pointer;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: var(--el-box-shadow-light);
}
}
.rate-kpi-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.rate-kpi-value {
margin-top: 6px;
font-size: 26px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.rate-kpi-sub {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-card-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
}
.rate-card-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-dept-cell {
font-weight: 600;
.rate-dept-count {
margin-left: 6px;
font-weight: 400;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-th-hint {
margin-left: 2px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
.rate-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.3;
.rate-cell-main {
font-weight: 500;
}
.rate-cell-sub {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
}
}
.rate-detail-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-detail-footer {
margin-top: 10px;
text-align: right;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-caliber {
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
p + p {
margin-top: 6px;
}
}
</style>
@@ -0,0 +1,7 @@
<template>
<cohort-report cohort-type="new" />
</template>
<script setup lang="ts" name="revisitAssignRateStatsPage">
import CohortReport from './components/CohortReport.vue'
</script>
+863
View File
@@ -0,0 +1,863 @@
<template>
<div class="revisit-due-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="due-filter-form">
<el-form-item label="医助">
<el-select
v-model="queryParams.assistant_id"
clearable
filterable
placeholder="全部"
class="w-[160px]"
@change="doSearch"
>
<el-option
v-for="item in assistantOptions"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptTreeOptions"
placeholder="全部(二中心)"
clearable
filterable
check-strictly
node-key="id"
:default-expand-all="true"
:props="deptTreeProps"
class="w-[200px]"
@change="doSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="queryParams.state"
clearable
placeholder="全部"
class="w-[170px]"
@change="doSearch"
>
<el-option
v-for="opt in stateOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="开药天数">
<el-select
v-model="queryParams.days_bucket"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option
v-for="opt in REVISIT_DAYS_BUCKET_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="延迟次数 ≥">
<el-input-number
v-model="minSnooze"
:min="0"
:max="999"
:step="1"
step-strictly
controls-position="right"
placeholder="不限"
class="!w-[120px]"
@change="onMinSnoozeChange"
/>
</el-form-item>
<el-form-item label="患者">
<el-input
v-model="queryParams.keyword"
placeholder="患者姓名"
clearable
class="w-[160px]"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<export-data
class="ml-2.5"
:fetch-fun="revisitDueLists"
:params="queryParams"
:page-size="pager.size"
export-hint="导出范围与当前筛选一致(医助 / 部门 / 状态 / 开药天数 / 延迟次数 / 患者)"
/>
</el-form-item>
</el-form>
<div class="due-hint">
每位患者(诊单)一行,取最新有效业务订单为本轮周期:预计复诊日 = 用药起算日(签收 → 发货 → 下单)+ 开药天数;
到期仍无有效挂号 / 过号未面诊的行标红,持有医助登录后台会收到右下角弹窗。
「延迟次数」为医助在弹窗上点「延迟」的累计次数(越多说明越未跟进),「已联系」为填写患者承诺复诊日的次数,
「人工关闭」由医助填原因关闭、在此标记供主管复核。数据范围按角色数据权限收窄;状态按今天实时推导。
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<el-table
:data="pager.lists"
v-loading="pager.loading"
size="default"
stripe
row-key="id"
:row-class-name="rowClassName"
>
<el-table-column label="患者" min-width="120" fixed="left">
<template #default="{ row }">
<div class="cell-main">{{ row.patient_name || '—' }}</div>
<div class="cell-sub">诊单 #{{ row.diagnosis_id }}</div>
</template>
</el-table-column>
<el-table-column label="医助" min-width="100">
<template #default="{ row }">{{ assistantText(row) }}</template>
</el-table-column>
<el-table-column label="部门" min-width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.dept_name || '—' }}</template>
</el-table-column>
<el-table-column label="诊次" width="110" align="center">
<template #header>
<span>诊次</span>
<el-tooltip
placement="top"
effect="dark"
content="患者当前已完成诊次 = 有效业务订单数 + 诊单诊次偏移,与复诊接诊率同口径;下一行为待复诊诊次"
>
<el-icon class="col-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<div class="cell-main">{{ slotText(row) }}</div>
<div v-if="Number(row.next_slot) > 0" class="cell-sub">
待复诊第 {{ row.next_slot }} 诊
</div>
</template>
</el-table-column>
<el-table-column label="开药天数" width="110" align="center">
<template #default="{ row }">
<template v-if="Number(row.medication_days) > 0">
<span>{{ row.medication_days }} 天</span>
<el-tag
v-if="Number(row.rule_days) > 0"
size="small"
type="info"
effect="plain"
class="ml-1"
>
{{ row.rule_days }} 档
</el-tag>
</template>
<span v-else class="text-missing">缺开药天数</span>
</template>
</el-table-column>
<el-table-column label="预计复诊" width="110">
<template #default="{ row }">
<div class="cell-main">{{ row.due_date || '—' }}</div>
<div v-if="row.anchor_date" class="cell-sub">
起算 {{ formatRevisitMonthDay(row.anchor_date) }}
</div>
</template>
</el-table-column>
<el-table-column label="超期天数" width="100" align="center">
<template #default="{ row }">
<span :class="['overdue-cell', overdueCell(row).cls]">
{{ overdueCell(row).text }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="150">
<template #default="{ row }">
<el-tag
:type="revisitStateTagType(row.state)"
:effect="revisitStateTagEffect(row.state)"
size="small"
:class="revisitStateChipClass(dueOf(row))"
>
{{ chipText(row) }}
</el-tag>
<el-tag
v-if="Number(row.closed_state) === 4"
size="small"
type="info"
effect="plain"
class="ml-1"
>
人工关闭
</el-tag>
</template>
</el-table-column>
<el-table-column label="延迟次数" width="100" align="center">
<template #header>
<span>延迟次数</span>
<el-tooltip
placement="top"
effect="dark"
content="医助在到期弹窗上点「延迟」的累计次数;1~2 次 L1、3~4 次 L2、≥5 次 L3(居中强制模态)。换医助后清零"
>
<el-icon class="col-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<span :class="['snooze-cell', snoozeClass(row)]">
{{ Number(row.snooze_count ?? 0) }}
</span>
<div v-if="Number(row.escalation_level) > 0" class="cell-sub">
等级 L{{ row.escalation_level }}
</div>
</template>
</el-table-column>
<el-table-column label="已联系" width="80" align="center">
<template #default="{ row }">{{ Number(row.contact_count ?? 0) }}</template>
</el-table-column>
<el-table-column label="承诺日" width="100">
<template #default="{ row }">{{ row.promised_date || '—' }}</template>
</el-table-column>
<el-table-column label="过号" width="70" align="center">
<template #default="{ row }">{{ Number(row.missed_count ?? 0) }}</template>
</el-table-column>
<el-table-column label="关闭原因" min-width="170" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="Number(row.closed_state) === 4">
<span>{{ handleReasonText(row.handle_reason) }}</span>
<span v-if="row.handle_remark" class="cell-sub">
:{{ row.handle_remark }}
</span>
</template>
<span v-else-if="Number(row.closed_state) > 0" class="cell-sub">
{{ row.closed_state_text || '—' }}
</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-perms="['tcm.revisitDue/logs']"
link
type="primary"
@click="openLogs(row)"
>
流水
</el-button>
<el-button link type="primary" @click="goDiagnosis(row)">去诊单</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<el-drawer
v-model="logVisible"
:title="logTitle"
direction="rtl"
size="540px"
destroy-on-close
>
<div v-loading="logLoading" class="log-drawer-body">
<div v-if="logRow" class="log-summary">
<div class="log-summary-row">
<span>医助:{{ assistantText(logRow) }}</span>
<span>{{ slotText(logRow) }}</span>
<span v-if="logRow.due_date">预计复诊 {{ logRow.due_date }}</span>
</div>
<div class="log-summary-row">
<el-tag
:type="revisitStateTagType(logRow.state)"
:effect="revisitStateTagEffect(logRow.state)"
size="small"
:class="revisitStateChipClass(dueOf(logRow))"
>
{{ chipText(logRow) }}
</el-tag>
<span class="cell-sub">{{ followCountText(logRow) }}</span>
</div>
<div v-if="hasShownMeta(logRow)" class="log-summary-row cell-sub">
<span v-if="logRow.first_shown_at_text">
首次弹出 {{ logRow.first_shown_at_text }}
</span>
<span v-if="logRow.last_snoozed_at_text">
最近延迟 {{ logRow.last_snoozed_at_text }}
</span>
<span v-if="logRow.computed_at_text">
重算于 {{ logRow.computed_at_text }}
</span>
</div>
</div>
<el-timeline v-if="logRows.length" class="mt-3">
<el-timeline-item
v-for="item in logRows"
:key="item.id"
:timestamp="item.create_time_text"
placement="top"
:type="eventTimelineType(item.event)"
:hollow="!isUserEvent(item.event)"
>
<div class="log-event">
<span class="log-event-title">
{{ item.event_text || eventLabel(item.event) }}
</span>
<span class="log-event-operator">{{ operatorText(item) }}</span>
</div>
<div class="log-event-meta">
<span>延迟 {{ Number(item.snooze_count_after ?? 0) }} 次</span>
<span>等级 L{{ Number(item.level_after ?? 0) }}</span>
<span>{{ logOverdueText(item.overdue_days) }}</span>
</div>
<div v-if="payloadEntries(item).length" class="log-event-payload">
<span
v-for="p in payloadEntries(item)"
:key="p.key"
class="log-payload-chip"
>
{{ p.label }}:{{ p.value }}
</span>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else-if="!logLoading" description="暂无流水" :image-size="80" />
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts" name="statsRevisitDue">
import { InfoFilled } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
revisitDueLists,
revisitDueLogs,
type RevisitDueListRow,
type RevisitDueListsParams,
type RevisitDueLogRow
} from '@/api/revisitDue'
import { revisitRateDeptOptions } from '@/api/stats'
import { getAssistants } from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import {
REVISIT_DAYS_BUCKET_OPTIONS,
REVISIT_STATE_FILTER_OPTIONS,
formatRevisitMonthDay,
normalizeRevisitDue,
revisitRowClassName,
revisitStateChipClass,
revisitStateChipText,
revisitStateTagEffect,
revisitStateTagType,
type RevisitDue
} from '@/utils/revisitDue'
interface DeptTreeNode {
id: number
name: string
pid: number
children?: DeptTreeNode[]
}
const router = useRouter()
/** 状态筛选:诊单列表的 7 个状态 + 主管视图专有「人工关闭」 */
const stateOptions = [...REVISIT_STATE_FILTER_OPTIONS, { label: '人工关闭', value: 'closed' }]
const assistantOptions = ref<Array<{ id: number; name: string }>>([])
const deptTreeOptions = ref<DeptTreeNode[]>([])
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const queryParams = reactive<RevisitDueListsParams>({
assistant_id: '',
dept_id: '',
state: '',
days_bucket: '',
min_snooze: '',
keyword: ''
})
/** el-input-number 清空时为 null/undefined,单独持有后再写回 queryParams 的 '' */
const minSnooze = ref<number | undefined>(undefined)
const { pager, getLists, resetPage } = usePaging({
fetchFun: revisitDueLists,
params: queryParams
})
const doSearch = () => resetPage()
const onMinSnoozeChange = (val: number | undefined | null) => {
queryParams.min_snooze = val === null || val === undefined ? '' : Number(val)
doSearch()
}
const handleReset = () => {
queryParams.assistant_id = ''
queryParams.dept_id = ''
queryParams.state = ''
queryParams.days_bucket = ''
queryParams.min_snooze = ''
queryParams.keyword = ''
minSnooze.value = undefined
doSearch()
}
// ── 单元格 ──────────────────────────────────────────
/** 列表行 → 复诊节点对象(复用 utils/revisitDue 的 chip 映射;缺失字段按默认值补齐) */
const dueOf = (row: RevisitDueListRow | Record<string, any>): RevisitDue | null =>
normalizeRevisitDue(row)
const rowClassName = ({ row }: { row: RevisitDueListRow }) => revisitRowClassName(dueOf(row))
const assistantText = (row: RevisitDueListRow) =>
row.assistant_name || (Number(row.assistant_id) > 0 ? `#${row.assistant_id}` : '—')
const slotText = (row: RevisitDueListRow) => {
const slot = Number(row.current_slot ?? 0)
return slot > 0 ? `第 ${slot} 诊` : '未成交'
}
const chipText = (row: RevisitDueListRow) =>
revisitStateChipText(dueOf(row)) || row.state_text || '—'
/** 抽屉摘要:「延迟 N 次 · 已联系 N 次 · 过号 N 次」 */
const followCountText = (row: RevisitDueListRow) => {
const snooze = Number(row.snooze_count ?? 0)
const contact = Number(row.contact_count ?? 0)
const missed = Number(row.missed_count ?? 0)
return `延迟 ${snooze} 次 · 已联系 ${contact} 次 · 过号 ${missed} 次`
}
const hasShownMeta = (row: RevisitDueListRow) =>
!!(row.first_shown_at_text || row.last_snoozed_at_text || row.computed_at_text)
const toNullableNumber = (v: unknown): number | null => {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
/** 超期天数列:到期/过号红字(深红 = severe),其它状态显示剩余天数或历史超期 */
const overdueCell = (row: RevisitDueListRow): { text: string; cls: string } => {
const od = toNullableNumber(row.overdue_days)
const dl = toNullableNumber(row.days_left)
const isDue = row.state === 'overdue' || row.state === 'missed'
if (isDue && od !== null && od >= 0) {
return {
text: od === 0 ? '今日到期' : `${od} 天`,
cls: Number(row.severe) === 1 ? 'is-severe' : 'is-overdue'
}
}
if (od !== null && od > 0) {
return { text: `${od} 天`, cls: 'is-muted' }
}
if (dl !== null && dl >= 0) {
return { text: `剩 ${dl} 天`, cls: 'is-muted' }
}
return { text: '—', cls: '' }
}
const snoozeClass = (row: RevisitDueListRow) => {
const level = Number(row.escalation_level ?? 0)
if (level >= 3) return 'is-level-3'
if (level === 2) return 'is-level-2'
if (level === 1) return 'is-level-1'
return ''
}
const handleReasonText = (reason: string | null | undefined) => {
const key = String(reason ?? '').trim()
if (key === 'lost') return '患者流失'
if (key === 'other') return '其它'
return key || '人工关闭'
}
// ── 流水抽屉 ──────────────────────────────────────────
const logVisible = ref(false)
const logLoading = ref(false)
const logRow = ref<RevisitDueListRow | null>(null)
const logRows = ref<RevisitDueLogRow[]>([])
const logTitle = computed(() =>
logRow.value ? `${logRow.value.patient_name || '患者'} · 复诊到期流水` : '复诊到期流水'
)
const EVENT_LABEL: Record<number, string> = {
1: '延迟',
2: '已联系',
3: '人工关闭',
4: '自动关闭 · 已挂号',
5: '自动关闭 · 已面诊',
6: '周期重置(新订单)',
7: '重新打开(挂号取消 / 过号)',
8: '企微推送',
9: '医助变更',
10: '形态升级',
11: '首次弹出'
}
const eventLabel = (event: number) => EVENT_LABEL[Number(event)] ?? `事件 ${event}`
/** 1 延迟 / 2 已联系 / 3 人工关闭 由医助操作,实心节点;其余系统事件空心 */
const isUserEvent = (event: number) => [1, 2, 3].includes(Number(event))
type TimelineType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
const eventTimelineType = (event: number): TimelineType => {
switch (Number(event)) {
case 1:
return 'warning'
case 2:
case 6:
return 'primary'
case 4:
case 5:
return 'success'
case 7:
case 10:
return 'danger'
default:
return 'info'
}
}
const operatorText = (item: RevisitDueLogRow) => {
if (item.operator_name) return item.operator_name
return Number(item.operator_id) > 0 ? `#${item.operator_id}` : '系统'
}
const logOverdueText = (days: number | null | undefined) => {
const n = toNullableNumber(days)
if (n === null) return ''
if (n > 0) return `超期 ${n} 天`
if (n === 0) return '到期当天'
return `未到期(剩 ${-n} 天)`
}
const PAYLOAD_LABEL: Record<string, string> = {
next_remind_at: '下次提醒',
next_remind_at_text: '下次提醒',
promised_date: '承诺日',
appointment_id: '挂号单',
appointment_date: '预约日',
order_id: '订单',
source_order_id: '周期订单',
remark: '备注',
reason: '原因',
from_assistant: '原医助',
from_assistant_id: '原医助',
to_assistant: '新医助',
to_assistant_id: '新医助',
interval: '间隔(秒)',
level: '等级',
channel: '渠道',
message: '说明'
}
const formatPayloadValue = (key: string, value: unknown): string => {
if (value === null || value === undefined || value === '') return ''
if (typeof value === 'object') return JSON.stringify(value)
// 秒级时间戳(next_remind_at 等)转本地时间
if (/_at$/.test(key) && typeof value === 'number' && value > 1e9) {
const d = new Date(value * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
return String(value)
}
interface PayloadEntry {
key: string
label: string
value: string
}
/** payload_arr → [{key,label,value}],有 *_text 版本时隐藏对应原始时间戳 */
const payloadEntries = (item: RevisitDueLogRow): PayloadEntry[] => {
const payload = item.payload_arr
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []
const out: PayloadEntry[] = []
for (const [key, raw] of Object.entries(payload)) {
if (`${key}_text` in payload) continue
const value = formatPayloadValue(key, raw)
if (value === '') continue
out.push({ key, label: PAYLOAD_LABEL[key] ?? key, value })
}
return out
}
const openLogs = async (row: RevisitDueListRow) => {
logRow.value = row
logVisible.value = true
logLoading.value = true
logRows.value = []
try {
// 契约「返回 list」:兼容直接返回数组或 { list } / { lists } 包络
const res: any = await revisitDueLogs({ due_id: Number(row.id) })
const list = Array.isArray(res) ? res : (res?.list ?? res?.lists ?? [])
logRows.value = Array.isArray(list) ? list : []
} finally {
logLoading.value = false
}
}
const goDiagnosis = (row: RevisitDueListRow) => {
const id = Number(row.diagnosis_id ?? 0)
if (id <= 0) return
router.push({ path: '/tcm/diagnosis', query: { id: String(id) } })
}
// ── 下拉数据 ──────────────────────────────────────────
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
const map = new Map<number, DeptTreeNode>()
for (const d of flat) {
map.set(Number(d.id), { id: Number(d.id), pid: Number(d.pid), name: d.name, children: [] })
}
const roots: DeptTreeNode[] = []
for (const d of flat) {
const node = map.get(Number(d.id))!
if (Number(d.pid) > 0 && map.has(Number(d.pid))) {
map.get(Number(d.pid))!.children!.push(node)
} else {
roots.push(node)
}
}
const strip = (n: DeptTreeNode) => {
if (n.children?.length) {
n.children.forEach(strip)
} else {
delete n.children
}
}
roots.forEach(strip)
return roots
}
const loadOptions = async () => {
const [assistants, depts] = await Promise.allSettled([
getAssistants(),
revisitRateDeptOptions()
])
if (assistants.status === 'fulfilled') {
assistantOptions.value = Array.isArray(assistants.value) ? assistants.value : []
}
if (depts.status === 'fulfilled') {
deptTreeOptions.value = buildDeptTree(depts.value?.rows ?? [])
}
}
onMounted(() => {
// 下拉与列表并行加载,不阻塞首屏
void loadOptions()
getLists()
})
</script>
<style lang="scss" scoped>
.revisit-due-page {
.due-filter-form {
:deep(.el-form-item) {
margin-bottom: 8px;
}
}
.due-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.cell-main {
line-height: 1.4;
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.col-hint {
margin-left: 2px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
.text-missing {
color: #ea580c;
font-size: 12px;
}
.overdue-cell.is-overdue {
color: var(--el-color-danger);
font-weight: 600;
}
.overdue-cell.is-severe {
color: #b91c1c;
font-weight: 700;
}
.overdue-cell.is-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.snooze-cell {
font-weight: 600;
}
.snooze-cell.is-level-1 {
color: #ea580c;
}
.snooze-cell.is-level-2 {
color: var(--el-color-danger);
}
.snooze-cell.is-level-3 {
color: #b91c1c;
font-weight: 700;
}
/* 行级标红:与诊单列表一致(utils/revisitDue.ts revisitRowClassName) */
:deep(.el-table__row.row-revisit-overdue) {
border-left: 3px solid var(--el-color-danger);
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-revisit-overdue:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
border-left-color: #b91c1c;
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
}
/* 状态 chip 配色:与 RevisitNodeCell 一致 */
:deep(.rv-chip) {
font-weight: 500;
}
:deep(.rv-chip--overdue),
:deep(.rv-chip--missed) {
--el-tag-bg-color: #dc2626;
--el-tag-border-color: #dc2626;
}
:deep(.rv-chip--severe) {
font-weight: 700;
--el-tag-bg-color: #b91c1c;
--el-tag-border-color: #b91c1c;
}
:deep(.rv-chip--warning) {
--el-tag-text-color: #ea580c;
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
--el-tag-border-color: rgba(234, 88, 12, 0.3);
}
:deep(.rv-chip--no_days) {
--el-tag-text-color: #ea580c;
--el-tag-border-color: #ea580c;
}
:deep(.rv-chip--visited) {
--el-tag-text-color: #16a34a;
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
--el-tag-border-color: rgba(22, 163, 74, 0.3);
}
:deep(.rv-chip--booked) {
--el-tag-text-color: #4a5dff;
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
--el-tag-border-color: rgba(74, 93, 255, 0.3);
}
}
.log-drawer-body {
min-height: 200px;
}
.log-summary {
padding: 12px 14px;
border-radius: 8px;
background: var(--el-fill-color-light);
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
}
.log-summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 14px;
}
.log-event {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.log-event-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.log-event-operator {
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.log-event-meta {
margin-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.log-event-payload {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.log-payload-chip {
font-size: 12px;
line-height: 20px;
padding: 0 8px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-regular);
word-break: break-all;
}
</style>
@@ -0,0 +1,7 @@
<template>
<cohort-report cohort-type="inherit" />
</template>
<script setup lang="ts" name="revisitInheritRateStatsPage">
import CohortReport from '../revisit_assign_rate/components/CohortReport.vue'
</script>
+285 -31
View File
@@ -29,12 +29,18 @@
@change="loadData"
/>
</el-form-item>
<el-form-item label="口径">
<el-radio-group v-model="inheritMode" size="small" @change="loadData">
<el-radio-button value="exclude">原口径(继承不计分母)</el-radio-button>
<el-radio-button value="slot">新口径(继承按继承时诊次计入)</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
<el-button :disabled="loading" @click="handleReset">重置</el-button>
</el-form-item>
<el-form-item>
<el-popover placement="bottom-start" :width="500" trigger="hover">
<el-popover placement="bottom-start" :width="560" trigger="hover">
<template #reference>
<span class="rate-caliber-trigger">
<el-icon><InfoFilled /></el-icon>
@@ -43,16 +49,29 @@
</template>
<div class="rate-caliber">
<p>
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
<b>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>不含勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。与业绩看板「被指派数」同口径。
</p>
<template v-if="isSlotMode">
<p>
<b>继承数</b>:当月勾选「继承」转接给该医助的诊单数(同一诊单多次继承取最早;同月既新指派又继承给同一医助只算新指派;历史「回退自动分配」行按继承处理;指派给自己的记录忽略)。<b>继承时诊次 k</b> = 继承时刻之前患者已完成的诊次(有效实单数 + 偏移),<b>起算诊次 = max(2, k+1)</b>:继承者只对第 k+1 诊及以后负责。
</p>
<p>
<b>N 诊分母</b> = 新指派数 + 起算诊次 ≤ N 的继承数(每列独立);<b>N 诊接诊率</b> = N 诊单数 ÷ N 诊分母,分母为 0 显示「—」。<b>复诊合计</b>:单数 = 各诊次单数之和,分母 = 新指派 ∪ 继承(被指派合计),比率可超过 100%(人均复诊成交单数)。
</p>
<p class="rate-caliber-note">
继承分母依赖指派记录的「继承」标记(2026-06 起写入),之前的转接无法回溯;切换为「旧口径」可对照上线前数字。
</p>
</template>
<template v-else>
<p>
<b>旧口径</b>:继承指派不计入分母,N 诊分母 = 新指派数(对所有 N 相同),新指派数 / N 诊单数 / N 诊接诊率与 2026-10 之前完全一致;继承数恒为 0。切换为「新口径」后继承按继承时诊次计入每列独立分母。
</p>
</template>
<p>
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
</p>
<p>
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
</p>
<p>
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
</p>
<p>
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;继承会转移持有人)。往月指派、当月成交会推高分子,比率可能超过 100%。
</p>
<p>
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
@@ -81,7 +100,9 @@
<template #header>
<div class="rate-card-header">
<span>部门 · 医助明细({{ monthText }})</span>
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
<span class="rate-card-hint">
点击「新指派数 / 继承数 / 被指派合计 / N诊单数 / 复诊合计单数」可查看具体诊单与订单;接诊率下方灰字为「单数 / 分母」;底栏合计按诊单去重
</span>
</div>
</template>
<el-table
@@ -104,19 +125,88 @@
<span v-else>{{ row.assistant_name }}</span>
</template>
</el-table-column>
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
<el-table-column
prop="assigned_count"
:label="isSlotMode ? '新指派数' : '当月被指派数'"
min-width="96"
align="right"
>
<template #header>
<span>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</span>
<el-tooltip
effect="dark"
placement="top"
content="当月非继承指派的「医助 × 诊单」去重数(剔除名下有拒收/退款订单的诊单),与业绩看板「被指派数」同口径"
>
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewAssignLines && row.assigned_count > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row)"
@click="openAssignDetail(row, 'new')"
>
{{ row.assigned_count }}
</el-link>
<span v-else>{{ row.assigned_count }}</span>
</template>
</el-table-column>
<el-table-column
v-if="isSlotMode"
prop="inherit_count"
label="继承数"
min-width="88"
align="right"
>
<template #header>
<span>继承数</span>
<el-tooltip
effect="dark"
placement="top"
content="当月勾选「继承」转接给该医助的诊单数(按继承时诊次 k 从第 k+1 诊起计入分母;点击查看每条的继承时诊次与起算诊次)"
>
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewAssignLines && row.inherit_count > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row, 'inherit')"
>
{{ row.inherit_count }}
</el-link>
<span v-else>{{ row.inherit_count ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column
v-if="isSlotMode"
prop="assigned_total_count"
label="被指派合计"
min-width="100"
align="right"
>
<template #header>
<span>被指派合计</span>
<el-tooltip effect="dark" placement="top" content="新指派 ∪ 继承(按诊单去重),即「复诊合计接诊率」的分母">
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewAssignLines && row.assigned_total_count > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row, 'all')"
>
{{ row.assigned_total_count }}
</el-link>
<span v-else>{{ row.assigned_total_count ?? 0 }}</span>
</template>
</el-table-column>
<template v-for="slot in visitSlots" :key="slot">
<el-table-column
:prop="`visit${slot}_count`"
@@ -149,12 +239,64 @@
<el-table-column
:prop="`visit${slot}_rate`"
:label="`${slotLabel(slot)}接诊率`"
min-width="96"
min-width="104"
align="right"
>
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
<template #header>
<span>{{ slotLabel(slot) }}接诊率</span>
<el-tooltip effect="dark" placement="top" :content="rateHeaderTip(slot)">
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<div class="rate-cell">
<span class="rate-cell-main">{{ formatRate(row[`visit${slot}_rate`]) }}</span>
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, `visit${slot}`) }}</span>
</div>
</template>
</el-table-column>
</template>
<el-table-column prop="visit_all_count" label="复诊合计单数" min-width="110" align="right">
<template #header>
<span>复诊合计单数</span>
<el-tooltip effect="dark" placement="top" content="当月内下单、诊次 ≥ 2 的订单数之和(= 各 N 诊单数之和)">
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewOrderLines && row.visit_all_count > 0"
type="primary"
:underline="false"
@click="openOrderDetail(0, row)"
>
{{ row.visit_all_count }}
</el-link>
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="visit_all_rate" label="复诊合计接诊率" min-width="118" align="right">
<template #header>
<span>复诊合计接诊率</span>
<el-tooltip
effect="dark"
placement="top"
:content="
isSlotMode
? '复诊合计单数 ÷ 被指派合计(新指派 ∪ 继承);可超过 100%,语义为人均复诊成交单数'
: '复诊合计单数 ÷ 新指派数(旧口径继承不计分母);可超过 100%'
"
>
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<div class="rate-cell">
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span>
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
@@ -162,7 +304,7 @@
<el-dialog
v-model="detailVisible"
:title="detailTitle"
width="980px"
width="1080px"
top="6vh"
destroy-on-close
>
@@ -193,6 +335,33 @@
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
</el-table-column>
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
<el-table-column v-if="isSlotMode" prop="assign_type" label="指派类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.assign_type === 'inherit' ? 'warning' : 'success'" size="small">
{{ row.assign_type_text || (row.assign_type === 'inherit' ? '继承' : '新指派') }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="isSlotMode" prop="inherit_visit_slot" label="继承时诊次" width="100" align="center">
<template #header>
<span>继承时诊次</span>
<el-tooltip effect="dark" placement="top" content="k:继承时刻之前患者已完成的诊次(有效实单数 + 偏移);新指派无此值">
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
{{ row.assign_type === 'inherit' ? `第 ${Number(row.inherit_visit_slot ?? 0)} 诊` : '—' }}
</template>
</el-table-column>
<el-table-column prop="entry_slot" label="起算诊次" width="96" align="center">
<template #header>
<span>起算诊次</span>
<el-tooltip effect="dark" placement="top" content="从该诊次列起计入分母:继承 = max(2, k+1);新指派 = 2(二诊)">
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">{{ entrySlotText(row.entry_slot) }}</template>
</el-table-column>
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
</template>
@@ -210,6 +379,9 @@
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
</template>
</el-table-column>
<el-table-column v-if="detailSlot === 0" prop="visit_slot" label="诊次" width="80" align="center">
<template #default="{ row }">{{ slotLabel(Number(row.visit_slot)) }}</template>
</el-table-column>
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
@@ -251,7 +423,9 @@ import {
revisitRateAssignLines,
revisitRateDeptOptions,
revisitRateOverview,
revisitRateVisitOrderLines
revisitRateVisitOrderLines,
type RevisitRateAssignKind,
type RevisitRateInheritMode
} from '@/api/stats'
import { hasPermission } from '@/utils/perm'
@@ -263,7 +437,14 @@ const OrderDetailDrawer = defineAsyncComponent(
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
interface MetricPack extends Record<string, any> {
/** 新指派数(语义不变) */
assigned_count: number
inherit_count?: number
assigned_total_count?: number
visit_all_count?: number
visit_all_den?: number
visit_all_rate?: number | null
visit2_rate_new_assign?: number | null
}
interface StatsRow extends MetricPack {
@@ -289,11 +470,14 @@ const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
/** 接口动态返回的诊次分档(最少 2~4,按分子最大诊次 / 最大起算诊次扩展到五诊、六诊…) */
const visitSlots = ref<number[]>([2, 3, 4])
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const slotLabel = (slot: number) => {
if (slot === 0) {
return '复诊合计'
}
if (slot >= 2 && slot <= 10) {
return `${cnDigits[slot]}诊`
}
@@ -307,6 +491,8 @@ const initNow = new Date()
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
const month = ref<string>(currentMonth)
const deptId = ref<number | undefined>(undefined)
/** 口径:首次加载不传、取后端默认(config default_inherit_mode),之后以用户选择为准 */
const inheritMode = ref<RevisitRateInheritMode | ''>('')
const loading = ref(false)
const rows = ref<StatsRow[]>([])
const total = ref<MetricPack | null>(null)
@@ -314,6 +500,8 @@ const total = ref<MetricPack | null>(null)
const deptTreeOptions = ref<DeptTreeNode[]>([])
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const isSlotMode = computed(() => inheritMode.value !== 'exclude')
const monthText = computed(() => {
const [y, m] = month.value.split('-')
return y && m ? `${y}年${Number(m)}月` : month.value
@@ -322,9 +510,30 @@ const monthText = computed(() => {
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
/** 接诊率单元格下方灰字:单数 / 分母 */
const denText = (row: Record<string, any>, prefix: string) => {
const cnt = Number(row[`${prefix}_count`] ?? 0)
const den = row[`${prefix}_den`]
return `${cnt} / ${den === null || den === undefined ? '—' : den}`
}
const rateHeaderTip = (slot: number) =>
isSlotMode.value
? `${slotLabel(slot)}单数 ÷ ${slotLabel(slot)}分母;分母 = 当月新指派数 + 起算诊次 ≤ ${slot} 的继承数;分母为 0 显示「—」`
: `${slotLabel(slot)}单数 ÷ 当月新指派数(旧口径:继承不计分母);分母为 0 显示「—」`
const entrySlotText = (entry: unknown) => {
const n = Number(entry ?? 0)
if (n <= 0) {
return '—'
}
return n > 50 ? `${n}(仅计合计)` : slotLabel(n)
}
const baseQuery = () => ({
month: month.value,
dept_ids: deptId.value ? String(deptId.value) : ''
dept_ids: deptId.value ? String(deptId.value) : '',
inherit_mode: (inheritMode.value || undefined) as RevisitRateInheritMode | undefined
})
interface SummaryCard {
@@ -341,11 +550,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
const cards: SummaryCard[] = [
{
key: 'assigned',
label: '当月被指派总数',
label: isSlotMode.value ? '当月新指派数' : '当月被指派数',
value: t ? String(t.assigned_count) : '—',
sub: '',
sub: t && isSlotMode.value ? `继承 ${t.inherit_count ?? 0} · 被指派合计 ${t.assigned_total_count ?? 0}` : '',
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
onClick: () => openAssignDetail(null)
onClick: () => openAssignDetail(null, 'new')
}
]
for (const slot of visitSlots.value) {
@@ -355,11 +564,19 @@ const summaryCards = computed<SummaryCard[]>(() => {
key: `visit${slot}`,
label: `当月${slotLabel(slot)}接诊率`,
value: t ? formatRate(rate) : '—',
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}` : '',
clickable: !!(canViewOrderLines && cnt > 0),
onClick: () => openOrderDetail(slot, null)
})
}
cards.push({
key: 'visit_all',
label: '当月复诊合计接诊率',
value: t ? formatRate(t.visit_all_rate) : '—',
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 被指派合计 ${t.visit_all_den ?? '—'}` : '',
clickable: !!(canViewOrderLines && t && Number(t.visit_all_count) > 0),
onClick: () => openOrderDetail(0, null)
})
return cards
})
@@ -374,15 +591,16 @@ const getSummaries = ({ columns }: { columns: any[] }) => {
return ''
}
const prop = String(col.property || '')
if (prop === 'assigned_count') {
return String(t.assigned_count)
if (prop === 'assigned_count' || prop === 'inherit_count' || prop === 'assigned_total_count') {
return String(t[prop] ?? 0)
}
if (/^visit\d+_count$/.test(prop)) {
const m = /^visit(\d+|all)_(count|den|rate)$/.exec(prop)
if (m) {
if (m[2] === 'rate') {
return `${formatRate(t[prop])}(${denText(t, `visit${m[1]}`)})`
}
return String(t[prop] ?? '')
}
if (/^visit\d+_rate$/.test(prop)) {
return formatRate(t[prop])
}
return ''
})
}
@@ -404,6 +622,9 @@ const loadData = async () => {
if (res?.month) {
month.value = res.month
}
if (res?.inherit_mode === 'slot' || res?.inherit_mode === 'exclude') {
inheritMode.value = res.inherit_mode
}
} finally {
loading.value = false
}
@@ -449,6 +670,8 @@ const detailType = ref<'assign' | 'orders'>('assign')
const detailTitle = ref('')
const detailLoading = ref(false)
const detailRows = ref<any[]>([])
/** 订单明细的诊次(0 = 复诊合计,表格多显示「诊次」列) */
const detailSlot = ref(2)
/** row 为 null 时表示合计(当前部门筛选范围) */
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
@@ -461,13 +684,19 @@ const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: numb
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
}
const openAssignDetail = async (row: StatsRow | null) => {
const assignKindLabel: Record<RevisitRateAssignKind, string> = {
new: '新指派明细',
inherit: '继承明细',
all: '被指派合计明细'
}
const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKind = 'all') => {
if (!canViewAssignLines) {
return
}
const scope = rowScope(row)
detailType.value = 'assign'
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
detailTitle.value = `${scope.label} · ${assignKindLabel[kind]}(${monthText.value})`
detailVisible.value = true
detailLoading.value = true
detailRows.value = []
@@ -475,7 +704,8 @@ const openAssignDetail = async (row: StatsRow | null) => {
const res = await revisitRateAssignLines({
...baseQuery(),
assistant_id: scope.assistant_id,
dept_id: scope.dept_id
dept_id: scope.dept_id,
kind
})
detailRows.value = res?.rows ?? []
} finally {
@@ -483,12 +713,14 @@ const openAssignDetail = async (row: StatsRow | null) => {
}
}
/** slot=0 表示全部诊次 ≥ 2(复诊合计单数) */
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
if (!canViewOrderLines) {
return
}
const scope = rowScope(row)
detailType.value = 'orders'
detailSlot.value = slot
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
detailVisible.value = true
detailLoading.value = true
@@ -529,6 +761,7 @@ const openPatientDetail = (diagnosisId: number) => {
const handleReset = () => {
month.value = currentMonth
deptId.value = undefined
inheritMode.value = ''
loadData()
}
@@ -623,6 +856,22 @@ onMounted(() => {
color: var(--el-text-color-secondary);
cursor: help;
}
.rate-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.3;
.rate-cell-main {
font-weight: 500;
}
.rate-cell-sub {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
}
}
.rate-detail-footer {
@@ -640,5 +889,10 @@ onMounted(() => {
p + p {
margin-top: 6px;
}
.rate-caliber-note {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
</style>
+2
View File
@@ -1,5 +1,6 @@
<template>
<div class="appointment-list">
<DoctorWebConsultationNotice />
<el-card class="list-card !border-none" shadow="never">
<!-- 单行工具栏:Tab(主) + 搜索(辅) -->
<div class="list-toolbar">
@@ -559,6 +560,7 @@
</template>
<script setup lang="ts">
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
@@ -1,5 +1,6 @@
<template>
<div class="apt-h5">
<DoctorWebConsultationNotice />
<template v-if="currentView === 'list'">
<header class="apt-h5-topbar">
<div class="apt-h5-search">
@@ -412,6 +413,7 @@
</template>
<script setup lang="ts" name="tcmAppointmentH5">
import DoctorWebConsultationNotice from '@/components/doctor-web-consultation-notice/index.vue'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
import { getWeappConfig } from '@/api/channel/weapp'
@@ -12,9 +12,21 @@
{{ assistantLogCellText(row, 'to') }}
</template>
</el-table-column>
<el-table-column label="继承" width="72" align="center">
<el-table-column label="继承" width="118" align="center">
<template #header>
<span>继承</span>
<el-tooltip
placement="top"
content="继承时第 k 诊:继承时刻之前患者已完成的诊次(有效实单数 + 偏移),复诊接诊率从第 k+1 诊起计入新医助分母;2026-10 前的继承记录无此快照"
>
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small">是</el-tag>
<template v-if="Number(row.is_inherit) === 1">
<el-tag type="success" size="small">是</el-tag>
<div v-if="inheritSlotText(row)" class="assign-log-inherit-slot">{{ inheritSlotText(row) }}</div>
</template>
<span v-else class="text-gray-400">否</span>
</template>
</el-table-column>
@@ -85,6 +97,20 @@ function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 对应日志表 inherit_visit_slot / inherit_visit_slot_calc_time:后端已拼好「继承时第 k 诊」文案,缺失时按快照列兜底 */
function inheritSlotText(row: Record<string, unknown>): string {
const text = row.inherit_visit_slot_text
if (text != null && String(text).trim() !== '') {
return String(text)
}
const calcTime = Number(row.inherit_visit_slot_calc_time)
if (!Number.isFinite(calcTime) || calcTime <= 0) {
return ''
}
const k = Number(row.inherit_visit_slot)
return Number.isFinite(k) && k > 0 ? `继承时第 ${k} 诊` : '继承时未成交'
}
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
@@ -132,4 +158,11 @@ defineExpose({ refresh: load })
color: var(--el-text-color-secondary);
cursor: help;
}
.assign-log-inherit-slot {
margin-top: 2px;
font-size: 12px;
line-height: 1.3;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,168 @@
<template>
<section class="consultation-transcripts">
<header class="transcript-toolbar">
<div>
<h3>面诊文字对话</h3>
<p>按每次通话分别查看已保存的文字记录。</p>
</div>
<el-button :loading="loading" @click="refresh"><el-icon><Refresh /></el-icon>刷新</el-button>
</header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon class="transcript-error" />
<div v-loading="loading" class="transcript-body">
<template v-if="records.length">
<aside class="transcript-sessions" aria-label="历次面诊记录">
<el-input v-model="search" clearable placeholder="搜索面诊时间或对话内容" :prefix-icon="Search" />
<p class="session-count">{{ filteredRecords.length }} 条面诊记录</p>
<div class="session-list">
<button
v-for="row in pageRecords"
:key="row.id"
type="button"
class="session-item"
:class="{ selected: selectedId === row.id }"
:aria-pressed="selectedId === row.id"
@click="selectedId = row.id"
>
<span class="session-time">{{ row.start_time_text || '开始时间未记录' }}</span>
<span class="session-meta">{{ consultationCallLabel(row) }} · 记录 #{{ row.id }}</span>
<el-tag size="small" :type="consultationTranscriptStatus(row).type">{{ consultationTranscriptStatus(row).label }}</el-tag>
<span class="session-preview">{{ preview(row) }}</span>
</button>
</div>
<el-pagination
v-if="filteredRecords.length > PAGE_SIZE"
v-model:current-page="page"
:page-size="PAGE_SIZE"
:total="filteredRecords.length"
layout="prev, pager, next"
small
/>
</aside>
<article v-if="selectedRecord" class="transcript-detail" :key="`${diagnosisId}-${selectedRecord.id}`">
<header class="transcript-detail-header">
<div>
<h4>{{ selectedRecord.start_time_text || '开始时间未记录' }} · {{ consultationCallLabel(selectedRecord) }}</h4>
<p>
通话记录 #{{ selectedRecord.id }}
<span v-if="selectedRecord.duration_text"> · 时长 {{ selectedRecord.duration_text }}</span>
<span v-if="selectedRecord.end_time_text"> · 结束 {{ selectedRecord.end_time_text }}</span>
</p>
</div>
<el-button v-if="selectedText" :loading="copying" @click="copyTranscript">复制全文</el-button>
</header>
<div class="transcript-status">
<el-tag :type="consultationTranscriptStatus(selectedRecord).type">{{ consultationTranscriptStatus(selectedRecord).label }}</el-tag>
<span>{{ callStatusText(selectedRecord.status) }}</span>
<span v-if="selectedRecord.transcription_segment_count">{{ selectedRecord.transcription_segment_count }} 段对话</span>
</div>
<p v-if="selectedRecord.transcription_status === 'partial'" class="transcript-note">本次文字记录不完整,以下展示已保存的内容。</p>
<p v-else-if="selectedRecord.transcription_status === 'failed' && selectedText" class="transcript-note">本次转写未完成,以下展示已保存的内容。</p>
<p v-else-if="selectedRecord.transcription_status === 'running' && selectedText" class="transcript-note">转写仍在进行,可刷新查看最新保存的文字。</p>
<pre v-if="selectedText" class="transcript-text">{{ selectedText }}</pre>
<el-empty v-else :description="consultationTranscriptEmptyText(selectedRecord)" :image-size="72" />
</article>
<el-empty v-else description="没有匹配的面诊记录,请调整搜索内容" class="transcript-detail" :image-size="72" />
</template>
<el-empty v-else-if="!loading && !error" description="该诊单暂无面诊通话记录" :image-size="80" class="transcript-empty" />
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import type { ConsultationCallRecord } from '@/api/tcm'
import { useConsultationTranscripts } from '@/hooks/useConsultationTranscripts'
import {
consultationCallLabel,
consultationTranscriptEmptyText,
consultationTranscriptStatus,
consultationTranscriptText
} from '@/utils/consultation-transcripts'
import feedback from '@/utils/feedback'
const props = defineProps<{ diagnosisId: number }>()
const { records, loading, error, refresh } = useConsultationTranscripts(() => props.diagnosisId)
const search = ref('')
const selectedId = ref<number | null>(null)
const page = ref(1)
const copying = ref(false)
const PAGE_SIZE = 10
const filteredRecords = computed(() => {
const term = search.value.trim().toLocaleLowerCase()
return records.value.filter(row => row.diagnosis_id === props.diagnosisId &&
(!term || `${row.start_time_text || ''} ${row.id} ${consultationTranscriptText(row)}`.toLocaleLowerCase().includes(term)))
})
const pageRecords = computed(() => filteredRecords.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
const selectedRecord = computed(() => filteredRecords.value.find(row => row.id === selectedId.value))
const selectedText = computed(() => consultationTranscriptText(selectedRecord.value))
watch(filteredRecords, rows => {
if (!rows.some(row => row.id === selectedId.value)) {
const selected = rows.find(row => consultationTranscriptText(row)) || rows[0]
selectedId.value = selected?.id ?? null
page.value = selected ? Math.floor(rows.indexOf(selected) / PAGE_SIZE) + 1 : 1
}
page.value = Math.min(page.value, Math.max(1, Math.ceil(rows.length / PAGE_SIZE)))
}, { immediate: true, flush: 'sync' })
watch(search, () => { page.value = 1 }, { flush: 'sync' })
function preview(row: ConsultationCallRecord): string {
const text = consultationTranscriptText(row).replace(/\s+/g, ' ')
return text ? text.slice(0, 90) + (text.length > 90 ? '…' : '') : consultationTranscriptEmptyText(row)
}
function callStatusText(value?: number): string {
return ({ 1: '进行中', 2: '已结束', 3: '未接听', 4: '已取消' } as Record<number, string>)[Number(value)] || '状态未记录'
}
async function copyTranscript() {
const text = selectedText.value
if (!text || copying.value) return
if (!navigator.clipboard?.writeText) {
feedback.msgWarning('当前浏览器不支持自动复制,请选中文字复制')
return
}
copying.value = true
try {
await navigator.clipboard.writeText(text)
feedback.msgSuccess('已复制本次面诊文字对话')
} catch {
feedback.msgWarning('复制失败,请选中文字复制')
} finally {
copying.value = false
}
}
</script>
<style scoped>
.consultation-transcripts { padding: 4px 0 16px; }
.transcript-toolbar, .transcript-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.transcript-toolbar { margin-bottom: 20px; }
.transcript-toolbar h3, .transcript-detail h4 { margin: 0; color: var(--el-text-color-primary); font-weight: 600; }
.transcript-toolbar h3 { font-size: 18px; }
.transcript-toolbar p, .transcript-detail-header p { margin: 7px 0 0; color: var(--el-text-color-secondary); line-height: 1.7; font-size: 13px; }
.transcript-error { margin-bottom: 16px; }
.transcript-body { display: flex; min-height: 360px; border-top: 1px solid var(--el-border-color-lighter); }
.transcript-sessions { flex: 0 0 282px; min-width: 0; padding: 18px 18px 0 0; border-right: 1px solid var(--el-border-color-lighter); }
.session-count { margin: 12px 0 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.session-list { display: flex; flex-direction: column; gap: 8px; max-height: 580px; overflow-y: auto; margin-bottom: 12px; }
.session-item { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; padding: 13px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 7px; background: var(--el-bg-color); text-align: left; cursor: pointer; font: inherit; }
.session-item:hover, .session-item.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.session-item:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
.session-time { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
.session-meta, .session-preview { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
.session-preview { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.transcript-detail { flex: 1; min-width: 0; padding: 20px 0 0 24px; }
.transcript-detail h4 { font-size: 16px; line-height: 1.7; }
.transcript-status { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 16px 0; color: var(--el-text-color-secondary); font-size: 13px; }
.transcript-note { padding: 10px 12px; background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: 13px; line-height: 1.7; }
.transcript-text { margin: 0; padding: 20px 0; color: var(--el-text-color-primary); white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 15px; line-height: 2; user-select: text; }
.transcript-empty { flex: 1; }
@media (max-width: 900px) {
.transcript-body { flex-direction: column; }
.transcript-sessions { flex-basis: auto; padding: 16px 0; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); }
.session-list { max-height: 300px; }
.transcript-detail { padding: 20px 0 0; }
.transcript-detail-header { flex-wrap: wrap; }
}
</style>
@@ -0,0 +1,303 @@
<template>
<div :class="['rv-cell', { 'rv-cell--compact': compact }]">
<!-- 行 1:[第 N 诊] tag + 已开方/未开方 + 复诊医生 -->
<div class="rv-line rv-line--head">
<el-tooltip
v-if="showDue && tooltipLines.length"
placement="top"
:show-after="300"
:hide-after="0"
>
<template #content>
<div class="rv-tooltip">
<div v-for="line in tooltipLines" :key="line">{{ line }}</div>
</div>
</template>
<el-tag size="small" :type="slotTagType" effect="light" class="rv-slot-tag">
{{ slotText }}
</el-tag>
</el-tooltip>
<el-tag
v-else-if="showDue"
size="small"
:type="slotTagType"
effect="light"
class="rv-slot-tag"
>
{{ slotText }}
</el-tag>
<span
v-if="prescribedLabel"
:class="['rv-prescribed', hasPrescription ? 'rv-prescribed--yes' : 'rv-prescribed--no']"
>
{{ prescribedLabel }}
</span>
<span v-if="followupDoctorName" class="rv-doctor">
<span v-if="prescribedLabel" class="rv-dot">·</span>{{ followupDoctorName }}
</span>
</div>
<template v-if="showDue">
<!-- 行 2:最新开药日 · 开药 X 天 · 锚点来源;缺天数时橙色 -->
<div class="rv-line rv-line--order">
<span v-if="orderDateText" class="rv-order-date">{{ orderDateText }} 开药</span>
<span v-if="orderDateText" class="rv-dot">·</span>
<span :class="['rv-days', { 'rv-days--missing': daysMissing }]">{{ medicationText }}</span>
<template v-if="!daysMissing && anchorText">
<span class="rv-dot">·</span>
<span class="rv-anchor">{{ anchorText }}</span>
</template>
</div>
<!-- 行 3:药尽 MM-DD + 状态 chip -->
<div class="rv-line rv-line--due">
<span v-if="dueDateText" class="rv-due-date">{{ dueDateText }}</span>
<el-tag
v-if="chipText"
size="small"
:type="chipType"
:effect="chipEffect"
:class="chipClass"
>
{{ chipText }}
</el-tag>
</div>
<!-- 行 4:已延迟 N 次 · 已联系 约 MM-DD(有值才显示) -->
<div v-if="followLineText" class="rv-line rv-line--follow">{{ followLineText }}</div>
</template>
<span v-else class="rv-none">—</span>
</div>
</template>
<script setup lang="ts">
/**
* 复诊节点单元格(PC 列表 / H5 卡片紧凑 / 只读页 三处共用)
*
* 只负责展示,不发请求;数据来自列表行 `revisit_due`(契约 C),由父组件用
* `normalizeRevisitDue` 规整后传入。state='none' 或 due 为 null 时仅显示行 1 + 「—」。
*/
import { computed } from 'vue'
import {
formatRevisitMonthDay,
hasRevisitDue,
isRevisitDaysMissing,
revisitAnchorSrcText,
revisitDueDateText,
revisitFollowLineText,
revisitMedicationDaysText,
revisitSlotText,
revisitStateChipClass,
revisitStateChipText,
revisitStateTagEffect,
revisitStateTagType,
revisitTooltipLines,
type RevisitDue
} from '@/utils/revisitDue'
const props = withDefaults(
defineProps<{
/** 规整后的复诊节点对象;null / state='none' 显示「—」 */
due: RevisitDue | null
/** H5 紧凑模式:更小字号、更紧行距 */
compact?: boolean
/** 是否已开方(决定「已开方/未开方」默认文案与颜色) */
hasPrescription?: boolean
/** 复诊医生(原列表 followup_doctor_name) */
followupDoctorName?: string
/** 自定义「已开方/未开方」文案;传 '' 隐藏该段(H5 卡片头已有标签时用) */
prescribedText?: string
}>(),
{
compact: false,
hasPrescription: false,
followupDoctorName: ''
}
)
const showDue = computed(() => hasRevisitDue(props.due))
const prescribedLabel = computed(() => {
if (props.prescribedText !== undefined) return props.prescribedText
return props.hasPrescription ? '已开方' : '未开方'
})
const slotText = computed(() => revisitSlotText(props.due))
const slotTagType = computed<'primary' | 'info'>(() =>
showDue.value && Number(props.due?.current_slot) > 0 ? 'primary' : 'info'
)
const tooltipLines = computed(() => revisitTooltipLines(props.due))
const orderDateText = computed(() => (showDue.value ? formatRevisitMonthDay(props.due?.order_date) : ''))
const daysMissing = computed(() => isRevisitDaysMissing(props.due))
const medicationText = computed(() => revisitMedicationDaysText(props.due))
const anchorText = computed(() => (showDue.value ? revisitAnchorSrcText(props.due?.anchor_src) : ''))
const dueDateText = computed(() => revisitDueDateText(props.due))
const chipText = computed(() => revisitStateChipText(props.due))
const chipType = computed(() => revisitStateTagType(props.due?.state))
const chipEffect = computed(() => revisitStateTagEffect(props.due?.state))
const chipClass = computed(() => revisitStateChipClass(props.due))
const followLineText = computed(() => revisitFollowLineText(props.due))
</script>
<style lang="scss" scoped>
.rv-cell {
display: flex;
flex-direction: column;
gap: 3px;
line-height: 1.35;
min-width: 0;
}
.rv-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
.rv-line--head {
font-size: 13px;
color: var(--el-text-color-primary);
}
.rv-slot-tag {
font-weight: 600;
cursor: default;
}
.rv-prescribed {
font-size: 13px;
font-weight: 500;
}
.rv-prescribed--yes {
color: #4a5dff;
}
.rv-prescribed--no {
color: var(--el-text-color-placeholder);
}
.rv-doctor {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rv-dot {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.rv-line--order {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rv-days--missing {
color: #ea580c;
font-weight: 600;
}
.rv-anchor {
color: var(--el-text-color-placeholder);
}
.rv-line--due {
font-size: 13px;
}
.rv-due-date {
font-weight: 500;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
}
/* 状态 chip:el-tag 基础色由 type/effect 决定,这里只补深红加粗与描边细节。
前缀 .rv-cell 提高特异性,压过 Element 的 .el-tag--dark.el-tag--danger 变量定义 */
.rv-cell .rv-chip {
font-weight: 500;
}
.rv-cell .rv-chip--overdue,
.rv-cell .rv-chip--missed {
--el-tag-bg-color: #dc2626;
--el-tag-border-color: #dc2626;
}
.rv-cell .rv-chip--severe,
.rv-cell .rv-chip--overdue.rv-chip--severe,
.rv-cell .rv-chip--missed.rv-chip--severe {
font-weight: 700;
--el-tag-bg-color: #b91c1c;
--el-tag-border-color: #b91c1c;
}
.rv-cell .rv-chip--warning {
--el-tag-text-color: #ea580c;
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
--el-tag-border-color: rgba(234, 88, 12, 0.3);
}
.rv-cell .rv-chip--no_days {
--el-tag-text-color: #ea580c;
--el-tag-border-color: #ea580c;
}
.rv-cell .rv-chip--visited {
--el-tag-text-color: #16a34a;
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
--el-tag-border-color: rgba(22, 163, 74, 0.3);
}
.rv-cell .rv-chip--booked {
--el-tag-text-color: #4a5dff;
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
--el-tag-border-color: rgba(74, 93, 255, 0.3);
}
.rv-cell .rv-chip--contacted {
--el-tag-text-color: #4a5dff;
--el-tag-border-color: #4a5dff;
}
.rv-line--follow {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rv-none {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.rv-tooltip {
line-height: 1.6;
font-size: 12px;
}
/* H5 紧凑模式 */
.rv-cell--compact {
gap: 2px;
.rv-line--head,
.rv-line--due,
.rv-prescribed {
font-size: 12px;
}
.rv-line--order,
.rv-line--follow,
.rv-doctor {
font-size: 11px;
}
.rv-none {
font-size: 12px;
}
}
</style>
+20 -1
View File
@@ -744,6 +744,20 @@
<el-empty v-else description="请先保存诊单后再查看视频录制回放记录" />
</el-tab-pane>
<el-tab-pane
v-if="hasCallTranscriptPermission"
label="面诊文字对话"
name="callTranscript"
:disabled="!formData.id"
lazy
>
<consultation-transcript-panel
v-if="visible && activeTab === 'callTranscript' && formData.id"
:key="Number(formData.id)"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else-if="!formData.id" description="请先保存诊单后再查看面诊文字对话" />
</el-tab-pane>
<el-tab-pane
v-if="hasPermission(['tcm.diagnosis/chat'])"
label="聊天记录"
name="chat"
@@ -827,6 +841,7 @@ import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
import CaseRecordList from './components/CaseRecordList.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ConsultationTranscriptPanel from './components/ConsultationTranscriptPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
@@ -862,6 +877,7 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const hasCallTranscriptPermission = computed(() => hasPermission(['tcm.diagnosis/huifang']) || hasPermission(['tcm.diagnosis/getCallRecords']))
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
@@ -930,12 +946,15 @@ watch(() => activeTab.value, async (tab) => {
}
})
watch([visible, activeTab], () => {
watch([visible, activeTab, hasCallTranscriptPermission], () => {
if (!visible.value) return
if (activeTab.value === 'chat' && !hasPermission(['tcm.diagnosis/chat'])) {
activeTab.value = 'basic'
}
if (activeTab.value === 'visit' && !hasPermission(['tcm.diagnosis/huifang'])) {
activeTab.value = 'basic'
}
if (activeTab.value === 'callTranscript' && !hasCallTranscriptPermission.value) {
activeTab.value = 'basic'
}
if (
+286 -81
View File
@@ -14,6 +14,7 @@
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
formData.revisit_state === '' &&
!hasLatestAppointmentFilter() &&
formData.appointment_date === tab.value
}
@@ -30,6 +31,14 @@
待预约
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
</span>
<!-- 复诊到期:到期未挂号 ∪ 过号未面诊(revisit_state=due),点击默认按药尽日升序 -->
<span
:class="['chip', 'chip-revisit-due', { active: formData.revisit_state === 'due' }]"
@click="handleRevisitDueTabClick"
>
复诊到期
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
</span>
<span
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
@click="handleCompletedVisitTabClick"
@@ -147,6 +156,36 @@
:value="String(item.value)"
/>
</el-select>
<el-select
v-model="formData.revisit_state"
placeholder="复诊状态"
clearable
size="small"
class="revisit-state-select"
@change="handleRevisitFilterChange"
>
<el-option
v-for="item in revisitStateFilterOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="formData.revisit_days_bucket"
placeholder="开药天数档"
clearable
size="small"
class="revisit-days-bucket-select"
@change="doSearch"
>
<el-option
v-for="item in revisitDaysBucketOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button size="small" @click="handleReset">重置</el-button>
</div>
</el-card>
@@ -278,22 +317,20 @@
<span v-else class="status-unconfirmed">未确认</span>
</template>
</el-table-column>
<el-table-column label="复诊" min-width="120">
<!-- 复诊节点:吞掉原「复诊」「开方」两列;排序走后端 sort_revisit_due(药尽日,NULL 置后) -->
<el-table-column
label="复诊节点"
prop="revisit_due_date"
min-width="180"
sortable="custom"
>
<template #default="{ row }">
<div v-if="row.has_prescription" class="followup-cell">
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
<el-tag
v-if="row.followup_rx_voided"
type="info"
size="small"
effect="plain"
class="followup-void-tag"
>
处方已作废
</el-tag>
</div>
<span v-else class="followup-none">无</span>
<revisit-node-cell
:due="revisitDueOf(row)"
:has-prescription="!!row.has_prescription"
:followup-doctor-name="row.followup_doctor_name || ''"
:prescribed-text="prescribedTextOf(row)"
/>
</template>
</el-table-column>
<el-table-column label="助理" width="100" show-overflow-tooltip>
@@ -301,12 +338,6 @@
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
</template>
</el-table-column>
<el-table-column label="开方" width="72" align="center">
<template #default="{ row }">
<span v-if="row.has_prescription" class="status-prescribed">已开方</span>
<span v-else class="status-unprescribed">未开方</span>
</template>
</el-table-column>
<el-table-column
label="未服务天数"
prop="unserved_days"
@@ -519,7 +550,6 @@
</el-form-item>
<el-form-item label="继承">
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
</el-form-item>
<el-form-item v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
@@ -737,7 +767,7 @@
</template>
<script setup lang="ts" name="tcmDiagnosis">
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
import { orderCreate } from '@/api/order'
import { cancelAppointment } from '@/api/doctor'
import { usePaging } from '@/hooks/usePaging'
@@ -752,6 +782,15 @@ import { useRoute, useRouter } from 'vue-router'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import RevisitNodeCell from './components/RevisitNodeCell.vue'
import { formatGender } from '@/utils/diag-display'
import {
normalizeRevisitDue,
REVISIT_DAYS_BUCKET_OPTIONS,
REVISIT_STATE_FILTER_OPTIONS,
revisitRowClassName,
type RevisitDue
} from '@/utils/revisitDue'
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
@@ -798,6 +837,12 @@ const formData = reactive({
latest_assign_end_date: '' as string,
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
sort_unserved_days: '' as '' | 'asc' | 'desc',
/** 复诊状态筛选:due(到期未挂号∪过号)|warning|booked|visited|contacted|no_days|pending,空=不限 */
revisit_state: '' as string,
/** 开药天数档筛选:14|21|28|30|other,空=不限 */
revisit_days_bucket: '' as string,
/** 复诊节点(药尽日)排序:asc=最早到期在前(NULL 置后) desc 反之;与 sort_unserved_days 互斥 */
sort_revisit_due: '' as '' | 'asc' | 'desc',
diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1',
@@ -898,11 +943,13 @@ function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
latest_appointment_end_date: formData.latest_appointment_end_date,
latest_appointment_channel_source: formData.latest_appointment_channel_source,
latest_assign_start_date: formData.latest_assign_start_date,
latest_assign_end_date: formData.latest_assign_end_date
latest_assign_end_date: formData.latest_assign_end_date,
revisit_state: formData.revisit_state,
revisit_days_bucket: formData.revisit_days_bucket
}
}
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件(revisit_state 视为 Tab 条件,角标间互不影响) */
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
page_no: 1,
@@ -915,6 +962,8 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
pending_assign_order_month: '',
pending_assign_keyword: '',
sort_unserved_days: '',
revisit_state: '',
sort_revisit_due: '',
...overrides
}
}
@@ -960,6 +1009,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.revisit_state = ''
formData.revisit_days_bucket = ''
formData.sort_revisit_due = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
@@ -996,6 +1048,7 @@ const handleDateTabClick = async (value: string) => {
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
formData.appointment_date = value
pager.page = 1
@@ -1012,6 +1065,7 @@ const handlePendingBookingTabClick = async () => {
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = '0'
clearRevisitTabFilters()
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
@@ -1027,6 +1081,7 @@ const handleCompletedVisitTabClick = async () => {
formData.completed_appointment = '1'
formData.has_appointment = ''
formData.appointment_date = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
@@ -1039,6 +1094,7 @@ const handlePendingAssignTabClick = async () => {
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
if (!formData.pending_assign_order_month) {
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
@@ -1057,10 +1113,66 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
fetchDateCounts()
}
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量
/** 复诊状态 / 开药天数档 下拉选项(文案与请求值映射在 utils/revisitDue.ts) */
const revisitStateFilterOptions = REVISIT_STATE_FILTER_OPTIONS
const revisitDaysBucketOptions = REVISIT_DAYS_BUCKET_OPTIONS
/** 顶部「复诊到期」角标:revisit_state=due 的 count */
const revisitDueCount = ref<number | undefined>(undefined)
/** 切换其它顶部 Tab 时清掉复诊 chip 状态与其排序(revisit_days_bucket 属「更多筛选」,保留) */
const clearRevisitTabFilters = () => {
formData.revisit_state = ''
formData.sort_revisit_due = ''
}
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊,默认按药尽日升序(超期最多在前) */
const handleRevisitDueTabClick = async () => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = ''
clearLatestAppointmentFilters()
formData.revisit_state = 'due'
formData.sort_revisit_due = 'asc'
formData.sort_unserved_days = ''
pager.page = 1
await getLists()
fetchDateCounts()
}
/** 「更多筛选 → 复诊状态」下拉:与顶部日期 Tab 互斥(同 chip 语义),选中后不再默认当天挂号 */
const handleRevisitFilterChange = () => {
if (formData.revisit_state !== '') {
formData.appointment_date = ''
formData.pending_booking = ''
formData.completed_appointment = ''
const noSortYet = formData.sort_revisit_due === '' && formData.sort_unserved_days === ''
if (formData.revisit_state === 'due' && noSortYet) {
formData.sort_revisit_due = 'asc'
}
} else {
formData.sort_revisit_due = ''
}
doSearch()
}
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,单元格显示「—」) */
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
/** 「复诊节点」行 1 的开方文案:已开方 / 已开方(处方已作废) / 未开方 */
const prescribedTextOf = (row: any): string => {
if (!row?.has_prescription) return '未开方'
return row.followup_rx_voided ? '已开方(处方已作废)' : '已开方'
}
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量 + 复诊到期数量
const fetchDateCounts = async () => {
try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
),
@@ -1081,7 +1193,10 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists(
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
),
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
tcmDiagnosisLists(buildPendingAssignCountPayload() as any),
tcmDiagnosisLists(
buildDateCountRequestPayload({ revisit_state: 'due', has_appointment: '' }) as any
)
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -1094,6 +1209,7 @@ const fetchDateCounts = async () => {
pendingBookingCount.value = noApt?.count ?? 0
completedVisitCount.value = doneVisit?.count ?? 0
pendingAssignCount.value = pending?.count ?? 0
revisitDueCount.value = revisitDue?.count ?? 0
} catch (e) {
console.error('获取日期数量失败', e)
}
@@ -1252,12 +1368,11 @@ const handleTableSortChange = ({
prop: string
order: 'ascending' | 'descending' | null
}) => {
if (prop === 'unserved_days') {
formData.sort_unserved_days =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
} else {
formData.sort_unserved_days = ''
}
const dir: '' | 'asc' | 'desc' =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
// 按列 prop 分派;两种排序后端互斥,切换列时清掉另一列
formData.sort_unserved_days = prop === 'unserved_days' ? dir : ''
formData.sort_revisit_due = prop === 'revisit_due_date' ? dir : ''
pager.page = 1
getLists()
}
@@ -1311,6 +1426,9 @@ const handleReset = () => {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.revisit_state = ''
formData.revisit_days_bucket = ''
formData.sort_revisit_due = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_appointment = ''
@@ -1343,8 +1461,10 @@ const unservedDaysClass = (n: unknown) => {
return 'unserved-green'
}
// 行高亮:未确认诊单优先,其次未挂号
// 行高亮优先级(方案 D3-c 默认 overdue_first):复诊超期/过号 > 未确认 > 未挂号
const getRowClassName = ({ row }: { row: any }) => {
const revisitCls = revisitRowClassName(revisitDueOf(row))
if (revisitCls) return revisitCls
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
if (!row.has_appointment) return 'row-no-appointment'
return ''
@@ -1356,6 +1476,8 @@ const prescriptionRef = ref()
const appointmentRef = ref()
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
const pendingRouteDiagnosisId = ref<number | null>(null)
/** 路由 ?id=&action=appointment 深链(复诊到期弹窗「去挂号」):挂号弹窗同为异步组件,先暂存行再 open */
const pendingRouteAppointmentRow = ref<Record<string, any> | null>(null)
const userStore = useUserStore()
watch(
@@ -1370,6 +1492,53 @@ watch(
{ flush: 'post' }
)
watch(
[appointmentRef, pendingRouteAppointmentRow],
() => {
const row = pendingRouteAppointmentRow.value
const opener = appointmentRef.value as { open?: (row: any) => void } | undefined
if (!row || !opener?.open) return
pendingRouteAppointmentRow.value = null
handleAppointment(row)
},
{ flush: 'post' }
)
/**
* 深链打开挂号弹窗:优先用当前列表里的行(字段最全);不在本页时回退只读详情接口拼最小行
* (appointment.vue open() 只读 patient_id / patient_name / gender_desc / age)。
*/
const openAppointmentFromRoute = async (diagnosisId: number) => {
// 深链打开挂号弹窗前先校验挂号权限(与行内「预约」按钮 v-perms 同口径)
if (!hasPermission(['tcm.diagnosis/guahao'])) {
feedback.msgWarning('无挂号权限')
return
}
const inList = (pager.lists as any[]).find((r) => Number(r?.id) === diagnosisId)
if (inList) {
pendingRouteAppointmentRow.value = inList
return
}
try {
const res: any = await diagnosisReadonlyDetail({ id: diagnosisId })
const diag = res?.diagnosis
if (!diag || !diag.patient_id) {
feedback.msgWarning('未找到该诊单或患者信息不完整,无法打开挂号')
return
}
pendingRouteAppointmentRow.value = {
...diag,
id: diagnosisId,
patient_id: diag.patient_id,
patient_name: diag.patient_name || '',
gender_desc: formatGender(diag.gender),
age: diag.age ?? 0
}
} catch (e: any) {
feedback.msgWarning(e?.msg || e?.message || '加载诊单失败,无法打开挂号')
}
}
const watchCallVisible = ref(false)
const watchCallDiagnosisId = ref(0)
@@ -1614,13 +1783,13 @@ const handleConfirmAssign = async () => {
feedback.msgWarning('请选择医助')
return
}
assignLoading.value = true
try {
if (assignMode.value === 'single') {
// 单独指派
await tcmDiagnosisAssign({
id: currentRow.value.id,
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
@@ -2220,22 +2389,57 @@ const goReadonly = (row: any) => {
}
onMounted(() => {
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
getDictOptions()
getLists()
scheduleDeferredDateCounts()
if (String(route.query.revisit_state ?? '') === 'due') {
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
void handleRevisitDueTabClick()
} else {
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
getLists()
scheduleDeferredDateCounts()
}
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
const raw = route.query.id
if (raw != null && raw !== '') {
const num = Number(raw)
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
if (!Number.isNaN(num) && num > 0) {
if (String(route.query.action ?? '') === 'appointment') {
// 复诊到期弹窗「去挂号」深链:等首屏列表回来后再找行,找不到走详情接口
void getListsPromiseForRoute().then(() => openAppointmentFromRoute(num))
} else {
pendingRouteDiagnosisId.value = num
}
}
}
})
/** onMounted 里 getLists() 已发起;等待首屏列表 loading 结束(最多 8s)再找行,失败/超时不阻断 */
const getListsPromiseForRoute = () =>
new Promise<void>((resolve) => {
if (!pager.loading) {
resolve()
return
}
let settled = false
const finish = () => {
if (settled) return
settled = true
stop()
resolve()
}
const stop = watch(
() => pager.loading,
(loading) => {
if (!loading) finish()
}
)
setTimeout(finish, 8000)
})
onUnmounted(() => {
if (silentListPollTimer != null) {
clearInterval(silentListPollTimer)
@@ -2313,6 +2517,21 @@ onUnmounted(() => {
}
}
.chip-revisit-due {
margin-left: 8px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
&:hover {
background: var(--el-color-danger-light-8);
}
&.active {
color: #fff;
background: var(--el-color-danger);
}
}
.chip-completed-visit {
margin-left: 8px;
color: var(--el-color-success);
@@ -2451,6 +2670,14 @@ onUnmounted(() => {
.latest-appointment-channel {
width: 170px;
}
.revisit-state-select {
width: 170px;
}
.revisit-days-bucket-select {
width: 130px;
}
}
.list-card {
@@ -2641,17 +2868,6 @@ onUnmounted(() => {
font-weight: 600;
}
.status-prescribed {
font-size: 13px;
color: #4A5DFF;
font-weight: 500;
}
.status-unprescribed {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
.unserved-days {
display: inline-block;
@@ -2696,33 +2912,6 @@ onUnmounted(() => {
}
}
.followup-cell {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.35;
.followup-time {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.followup-doctor {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.followup-void-tag {
align-self: flex-start;
}
}
.followup-none {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.assistant-cell {
font-size: 13px;
color: var(--el-text-color-secondary);
@@ -2782,6 +2971,22 @@ onUnmounted(() => {
:deep(.el-table__row.row-no-appointment:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
}
/* 复诊到期未挂号 / 过号未面诊:红色左边条(同 row-unconfirmed 结构,warning → danger) */
:deep(.el-table__row.row-revisit-overdue) {
border-left: 3px solid var(--el-color-danger);
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-revisit-overdue:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
}
/* 超期 ≥ severe_overdue_days:底色加深到 10%,悬停 14% */
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
border-left-color: #b91c1c;
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
}
}
.record-list {
+70 -9
View File
@@ -25,6 +25,7 @@
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
formData.revisit_state === '' &&
formData.appointment_date === tab.value
}
]"
@@ -40,6 +41,13 @@
待预约
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
</span>
<span
:class="['dh5-chip', 'dh5-chip--revisit', { active: formData.revisit_state === 'due' }]"
@click="handleRevisitDueTabClick"
>
复诊到期
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
</span>
<span
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
@click="handleCompletedVisitTabClick"
@@ -170,13 +178,17 @@
</div>
</div>
<!-- 复诊块(仅已开方时显示) -->
<div v-if="row.has_prescription" class="dh5-row dh5-row--followup">
<!-- 复诊节点块(已开方或有复诊节点数据时显示;卡片头已有「已开方」标签,此处隐藏开方文案) -->
<div v-if="row.has_prescription || hasRevisitDue(revisitDueOf(row))" class="dh5-row dh5-row--followup">
<span class="dh5-row-key">复诊</span>
<div class="dh5-row-val">
<span>{{ row.followup_time_text || '—' }}</span>
<span class="dh5-meta-doctor">{{ row.followup_doctor_name || '—' }}</span>
<span v-if="row.followup_rx_voided" class="dh5-tag dh5-tag--gray">处方作废</span>
<div class="dh5-row-val dh5-row-val--revisit">
<revisit-node-cell
compact
:due="revisitDueOf(row)"
:has-prescription="!!row.has_prescription"
:followup-doctor-name="row.followup_doctor_name || ''"
:prescribed-text="row.followup_rx_voided ? '处方作废' : ''"
/>
</div>
</div>
@@ -625,6 +637,13 @@ import {
import useUserStore from '@/stores/modules/user'
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
import dayjs from 'dayjs'
import RevisitNodeCell from './components/RevisitNodeCell.vue'
import {
hasRevisitDue,
isRevisitDueHighlight,
normalizeRevisitDue,
type RevisitDue
} from '@/utils/revisitDue'
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
@@ -685,7 +704,9 @@ const formData = reactive({
has_appointment: '' as '' | '0' | '1',
pending_assign: '' as '' | '1',
pending_booking: '' as '' | '1',
completed_appointment: '' as '' | '1'
completed_appointment: '' as '' | '1',
/** 顶部「复诊到期」chip:due = 到期未挂号 ∪ 过号未面诊(后端 revisit_state) */
revisit_state: '' as '' | 'due'
})
const showMoreFilter = ref(false)
@@ -740,6 +761,7 @@ const handleReset = () => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.revisit_state = ''
pager.page = 1
getLists()
fetchDateCounts()
@@ -767,6 +789,7 @@ const handleDateTabClick = async (value: string) => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.revisit_state = ''
formData.has_appointment = ''
formData.appointment_date = value
pager.page = 1
@@ -778,6 +801,7 @@ const handlePendingBookingTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = '1'
formData.completed_appointment = ''
formData.revisit_state = ''
formData.appointment_date = ''
formData.has_appointment = '0'
pager.page = 1
@@ -789,6 +813,7 @@ const handleCompletedVisitTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = '1'
formData.revisit_state = ''
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
@@ -800,6 +825,7 @@ const handlePendingAssignTabClick = async () => {
formData.pending_assign = '1'
formData.pending_booking = ''
formData.completed_appointment = ''
formData.revisit_state = ''
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
@@ -807,9 +833,27 @@ const handlePendingAssignTabClick = async () => {
fetchDateCounts()
}
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊;后端按 revisit_state=due 过滤,排序默认药尽日升序 */
const handleRevisitDueTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
formData.revisit_state = 'due'
pager.page = 1
await getLists()
fetchDateCounts()
}
const revisitDueCount = ref<number | undefined>(undefined)
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,不渲染复诊块) */
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
const fetchDateCounts = async () => {
try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
@@ -818,7 +862,8 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ revisit_state: 'due', page_no: 1, page_size: 1 })
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -831,6 +876,7 @@ const fetchDateCounts = async () => {
pendingBookingCount.value = noApt?.count ?? 0
completedVisitCount.value = doneVisit?.count ?? 0
pendingAssignCount.value = pending?.count ?? 0
revisitDueCount.value = revisitDue?.count ?? 0
} catch (e) {
console.error('获取日期数量失败', e)
}
@@ -898,7 +944,9 @@ const isDiagnosisConfirmed = (row: any) => {
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
}
// 卡片左边条优先级(同 PC 行高亮):复诊超期/过号 > 未确认 > 未挂号
const getCardClass = (row: any) => {
if (isRevisitDueHighlight(revisitDueOf(row))) return 'dh5-card--overdue'
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
if (!row.has_appointment) return 'dh5-card--noapt'
return ''
@@ -1582,6 +1630,8 @@ $dh5-card-bg: #ffffff;
.dh5-chip--booking.active { background: $dh5-warn; }
.dh5-chip--done.active { background: $dh5-success; }
.dh5-chip--pending.active { background: $dh5-danger; }
.dh5-chip--revisit { color: #b91c1c; background: rgba(239, 68, 68, 0.10); }
.dh5-chip--revisit.active { color: #fff; background: $dh5-danger; }
.dh5-quick {
display: flex;
@@ -1675,6 +1725,12 @@ $dh5-card-bg: #ffffff;
.dh5-card--unconfirmed::before { background: $dh5-danger; }
.dh5-card--noapt::before { background: $dh5-warn; }
/* 复诊到期未挂号 / 过号未面诊:左边条同 danger,外框与底色轻微染红以区分「未确认」 */
.dh5-card--overdue::before { background: $dh5-danger; }
.dh5-card--overdue {
border-color: rgba(239, 68, 68, 0.35);
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, $dh5-card-bg 60%);
}
.dh5-card-head {
display: flex;
@@ -1800,6 +1856,11 @@ $dh5-card-bg: #ffffff;
font-size: 12px;
}
/* 复诊节点块:单元格组件自带多行布局,取消外层 flex 换行间距 */
.dh5-row-val--revisit {
display: block;
}
/* 挂号块 - 状态色 */
.dh5-row--apt {
background: #fafbfc;
@@ -25,6 +25,17 @@
</span>
</el-tooltip>
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
<!-- 复诊节点徽标:第 N 诊 · 药尽 MM-DD · 超期 N 天(接口无 revisit_due 字段时不渲染) -->
<el-tooltip
v-if="revisitBadgeLabel"
placement="bottom"
:disabled="!revisitTooltipText"
:content="revisitTooltipText"
>
<span :class="['unserved-badge', 'revisit-badge', revisitBadgeColorClass]">
{{ revisitBadgeLabel }}
</span>
</el-tooltip>
</div>
</div>
@@ -156,6 +167,12 @@ import { diagnosisReadonlyDetail } from '@/api/tcm'
import { getDoctorNotes } from '@/api/patient'
import { formatGender } from '@/utils/diag-display'
import { hasPermission } from '@/utils/perm'
import {
normalizeRevisitDue,
revisitBadgeText,
revisitStateColorClass,
revisitTooltipLines
} from '@/utils/revisitDue'
import PatientInfoCard from './components/PatientInfoCard.vue'
import PatientCaseCard from './components/PatientCaseCard.vue'
import DailyMatrix from './components/DailyMatrix.vue'
@@ -193,6 +210,17 @@ const unservedDays = computed<number | null>(() => {
})
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
/** 复诊节点:readonlyDetail 顶层 revisit_due(兼容挂在 diagnosis 下);字段不存在 → null → 不渲染徽标 */
const revisitDue = computed(() =>
normalizeRevisitDue(detail.value?.revisit_due ?? detail.value?.diagnosis?.revisit_due)
)
const revisitBadgeLabel = computed(() => revisitBadgeText(revisitDue.value))
const revisitBadgeColorClass = computed(() => {
const due = revisitDue.value
return due ? revisitStateColorClass(due.state) : 'unserved-muted'
})
const revisitTooltipText = computed(() => revisitTooltipLines(revisitDue.value).join(';'))
/**
* 「未服务天数」着色规则(与 T3 列表一致):
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
@@ -352,6 +380,13 @@ onMounted(fetchDetail)
border-color: #fecaca;
background: rgba(220, 38, 38, 0.08);
}
/* 复诊节点徽标专用:已约 / 已联系 */
&.unserved-blue {
color: #4a5dff;
border-color: #c7d2fe;
background: rgba(74, 93, 255, 0.08);
}
}
.empty-state {
+2
View File
@@ -79,6 +79,7 @@
</el-form-item>
</el-form>
</el-card>
<iam-personal />
<footer-btns>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</footer-btns>
@@ -108,6 +109,7 @@
</template>
<script setup lang="ts" name="userSetting">
import IamPersonal from '@/components/iam-v2/personal.vue'
import type { FormInstance } from 'element-plus'
import { nextTick, onMounted, onUnmounted, ref, reactive } from 'vue'
import { Loading, CircleCheck, Connection } from '@element-plus/icons-vue'
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig(({ mode }) => {
return {
base: '/admin/',
// 临时 UI 预览可能共用 node_modules;主开发服务独立缓存,避免预构建互相覆盖。
cacheDir: path.resolve(__dirname, 'node_modules/.vite-admin'),
server: {
port: 5555,
host: '0.0.0.0',