feat(c4): 质控中心页

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 01:46:16 +08:00
parent 08a9182352
commit 44386e90ae
2 changed files with 1020 additions and 55 deletions

153
src/api/qc.ts Normal file
View File

@ -0,0 +1,153 @@
import { http } from '@/api/http'
import type { Elder } from '@/api/elder'
import type { Order } from '@/api/order'
import type { ChangePlanData, Plan } from '@/api/plan'
import type { ServiceRecordDetail } from '@/api/record'
/**
* 质控闸门(qc / ho_record_correction)API。
* 字段锚点见 shared/data-model spec(qc / ho_record_correction)。
* - 时间字段(qcTime/passTime/correctedAt/createdAt/updatedAt)后端经 TransformInterceptor 序列化为毫秒时间戳;
* - 待放行队列项含 record 摘要 + correctionCount + focus(补正≥3)+ waitMinutes(已等待分钟数)。
*/
/** 质控结果(qc_result)。 */
export const QC_RESULTS = ['pending', 'passed', 'returned'] as const
/** 质控类型(qc_type)。 */
export const QC_TYPES = ['normal', 'recheck'] as const
export const QC_RESULT_TAG_MAP: Record<string, { label: string; type: 'success' | 'info' | 'warning' | 'danger' | 'primary' }> = {
pending: { label: '待质控', type: 'warning' },
passed: { label: '已放行', type: 'success' },
returned: { label: '已退回', type: 'danger' },
}
/** 质控记录(qc)。 */
export interface QcRecord {
id: string
tenantCode: string
recordId: string
qcType: string
qcResult: string
qcNote: string | null
qcStaffId: string | null
qcTime: number | null
passTime: number | null
firstQcId: string | null
createdAt: number
createdBy: string | null
updatedAt: number
deletedAt: number | null
}
/** 待放行队列项的留痕摘要。 */
export interface PendingQcRecordSummary {
id: string
elderId: string
assigneeId: string
checkinTime: number | null
recordStatus: string
}
/** 待放行队列项:qc + 留痕摘要 + 补正计数/质控重点/已等待。 */
export interface PendingQcItem extends QcRecord {
record: PendingQcRecordSummary | null
correctionCount: number
focus: boolean
waitMinutes: number
}
/** 单类待办计数 + 消除条件。 */
export interface CountWithCondition {
count: number
clearCondition: string
}
/** 住院仍有派单:老人 + 活跃工单。 */
export interface HospActiveItem {
elder: Elder
order: Order
}
/** 五类主动推送聚合结果。 */
export interface PendingListResult {
abnormalOpen: CountWithCondition
overdue: CountWithCondition & { list: Order[] }
hospActive: CountWithCondition & { list: HospActiveItem[] }
missingPlan: CountWithCondition & { list: Elder[] }
pendingQc: CountWithCondition & { list: PendingQcItem[] }
}
/** 补正说明(ho_record_correction)。 */
export interface QcCorrection {
id: string
tenantCode: string
recordId: string
qcId: string | null
correctionNote: string
correctedBy: string
correctedAt: number
}
/** 质控详情:qc + 关联留痕全文 + 补正历史。 */
export interface QcDetail extends QcRecord {
record: ServiceRecordDetail
corrections: QcCorrection[]
}
/** 退回入参(qc_note 必填)。 */
export interface ReturnQcData {
qcNote: string
}
/** 补正入参。 */
export interface CorrectData {
recordId: string
correctionNote: string
}
/** 批量放行入参。 */
export interface BatchPassData {
recordIds: string[]
}
/** 异常联动改计划入参:复用 plan.change 字段 + abnormalId。 */
export interface ExceptionLinkData extends ChangePlanData {
abnormalId: string
}
/** 五类待办聚合。 */
export function pendingList(signal?: AbortSignal): Promise<PendingListResult> {
return http.get<PendingListResult>('/qc/pending/list', { signal })
}
/** 质控详情(留痕全文/项目/照片/异常/定位/补正历史)。 */
export function getQcDetail(id: string): Promise<QcDetail> {
return http.get<QcDetail>(`/qc/${id}`)
}
/** 放行(后端自检 1004/3001/4001,错误 ApiError.message 直接显示)。 */
export function passQc(id: string): Promise<QcRecord> {
return http.post<QcRecord>(`/qc/${id}/pass`)
}
/** 退回(qc_note 必填)。 */
export function returnQc(id: string, data: ReturnQcData): Promise<QcRecord> {
return http.post<QcRecord>(`/qc/${id}/return`, data)
}
/** 批量放行(后端拦不可批量项)。 */
export function batchPassQc(data: BatchPassData): Promise<{ passed: number }> {
return http.post<{ passed: number }>('/qc/batch-pass', data)
}
/** 异常联动改计划(复用 plan.change + 回填 link_plan_change)。 */
export function exceptionLink(data: ExceptionLinkData): Promise<Plan> {
return http.post<Plan>('/qc/exception/link', data)
}
/** 补正(写更正说明 + 生成 recheck qc)。 */
export function correctQc(data: CorrectData): Promise<QcRecord> {
return http.post<QcRecord>('/qc/correct', data)
}

View File

@ -1,28 +1,391 @@
<template> <template>
<div class="qc-page"> <div class="qc-page">
<!-- ① 待我处理:五类主动推送聚合 -->
<el-card class="mb-md" shadow="never"> <el-card class="mb-md" shadow="never">
<ty-search-form :schema="searchSchema" :model-value="query" @search="handleSearch" @reset="handleReset" /> <template #header>
<div class="flex items-center">
<span>待我处理</span>
<el-button link type="primary" class="ml-xs" :loading="pendingLoading" @click="fetchPending">刷新</el-button>
</div>
</template>
<div class="qc-page__cards">
<div v-for="card in todoCards" :key="card.key" class="todo-card" @click="card.onClick">
<div class="todo-card__count" :class="{ 'todo-card__count--zero': card.count === 0 }">{{ card.count }}</div>
<div class="todo-card__label">{{ card.label }}</div>
<div class="todo-card__hint">{{ card.clearCondition || '—' }}</div>
</div>
</div>
</el-card> </el-card>
<el-card class="qc-page__table" shadow="never"> <!-- ② 待放行队列 -->
<ty-table <el-card id="qc-pending-queue" class="mb-md" shadow="never">
:columns="columns" <template #header>
:data="list" <div class="flex items-center">
:loading="loading" <span>待放行队列</span>
:total="total" <el-button
v-model:page="page" v-permission="'qc:batch-pass'"
v-model:page-size="pageSize" type="primary"
/> size="small"
class="ml-sm"
:disabled="selectedRecordIds.length === 0"
:loading="batchPassing"
@click="confirmBatchPass"
>
批量放行{{ selectedRecordIds.length ? `(${selectedRecordIds.length})` : '' }}
</el-button>
<span class="ml-auto text-xs text-text-placeholder">质控重点(补正 ≥3)行高亮;批量放行会拦截异常/未执行/加项/时长过短/已补正记录</span>
</div>
</template>
<el-table
v-loading="pendingLoading"
:data="pendingQcs"
border
:row-class-name="pendingRowClass"
@selection-change="onPendingSelectionChange"
>
<el-table-column type="selection" width="48" :selectable="isPendingSelectable" />
<el-table-column label="老人" min-width="100">
<template #default="{ row }">{{ elderName(row.record?.elderId) }}</template>
</el-table-column>
<el-table-column label="护理员" min-width="100">
<template #default="{ row }">{{ staffName(row.record?.assigneeId) }}</template>
</el-table-column>
<el-table-column label="签到时间" width="150">
<template #default="{ row }">{{ formatDateTime(row.record?.checkinTime) }}</template>
</el-table-column>
<el-table-column label="留痕状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="RECORD_STATUS_TAG_MAP[row.record?.recordStatus]?.type ?? 'info'" size="small">
{{ RECORD_STATUS_TAG_MAP[row.record?.recordStatus]?.label ?? row.record?.recordStatus ?? '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="补正次数" width="90" align="center">
<template #default="{ row }">{{ row.correctionCount }}</template>
</el-table-column>
<el-table-column label="质控重点" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.focus" type="danger" size="small">质控重点</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="已等待" width="110" align="center">
<template #default="{ row }">{{ waitLabel(row.waitMinutes) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openQcDetail(row)">查看</el-button>
<el-button v-permission="'qc:pass'" link type="success" :loading="passingId === row.id" @click="doPass(row)">放行</el-button>
<el-button v-permission="'qc:return'" link type="warning" @click="openReturn(row)">退回</el-button>
</template>
</el-table-column>
</el-table>
</el-card> </el-card>
<!-- ③ 异常处理台 -->
<el-card id="qc-abnormal" class="qc-page__abnormal" shadow="never">
<template #header>
<div class="flex items-center">
<span>异常处理台</span>
<span class="ml-xs text-xs text-text-placeholder">判断异常需加专项时,可联动变更服务计划</span>
</div>
</template>
<ty-search-form :schema="abnormalSearchSchema" :model-value="query" @search="handleAbnormalSearch" @reset="handleAbnormalReset" />
<ty-table
class="mt-sm"
:columns="abnormalColumns"
:data="abnormalList"
:loading="abnormalLoading"
:total="abnormalTotal"
v-model:page="abnormalPage"
v-model:page-size="abnormalPageSize"
:actions-width="120"
>
<template #actions="{ row }">
<el-button v-permission="'qc:exception-link'" link type="primary" :disabled="row.linkPlanChange" @click="openLink(row)">
{{ row.linkPlanChange ? '已联动' : '联动改计划' }}
</el-button>
</template>
</ty-table>
</el-card>
<!-- 质控详情 -->
<el-dialog v-model="detailVisible" title="质控详情" width="860px">
<div v-loading="detailLoading" class="qc-page__detail">
<template v-if="detail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="质控结果">
<el-tag :type="QC_RESULT_TAG_MAP[detail.qcResult]?.type ?? 'info'" size="small">
{{ QC_RESULT_TAG_MAP[detail.qcResult]?.label ?? detail.qcResult }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="退回原因">{{ detail.qcNote ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="老人">{{ elderName(detail.record.elderId) }}</el-descriptions-item>
<el-descriptions-item label="护理员">{{ staffName(detail.record.assigneeId) }}</el-descriptions-item>
<el-descriptions-item label="签到时间">{{ formatDateTime(detail.record.checkinTime) }}</el-descriptions-item>
<el-descriptions-item label="签退时间">{{ formatDateTime(detail.record.checkoutTime) }}</el-descriptions-item>
<el-descriptions-item label="服务时长">{{ durationLabel(detail.record.durationMin) }}</el-descriptions-item>
<el-descriptions-item label="垫付标记">
<el-tag v-if="detail.record.isPrepaid" type="warning" size="small">垫付</el-tag>
<span v-else>—</span>
</el-descriptions-item>
<el-descriptions-item label="是否计费">{{ detail.record.billable ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="离线补传">{{ detail.record.offlineFlag ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.record.submitTime) }}</el-descriptions-item>
<el-descriptions-item label="定位">
{{ LOCATE_STATUS_TAG_MAP[detail.record.locateStatus]?.label ?? detail.record.locateStatus }}
<span class="text-xs text-text-placeholder">({{ fmtCoord(detail.record.checkinLat) }}, {{ fmtCoord(detail.record.checkinLng) }})</span>
</el-descriptions-item>
<el-descriptions-item label="工单 ID">{{ detail.record.orderId }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">项目执行</el-divider>
<el-table v-if="detail.record.items.length > 0" :data="detail.record.items" border size="small">
<el-table-column prop="svcNameSnapshot" label="项目" min-width="160" />
<el-table-column label="执行结果" width="110" align="center">
<template #default="{ row }">
<el-tag :type="EXEC_RESULT_TAG_MAP[row.execResult]?.type ?? 'info'" size="small">
{{ EXEC_RESULT_TAG_MAP[row.execResult]?.label ?? row.execResult }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="未执行原因" min-width="120">
<template #default="{ row }">{{ row.undoReason ?? '—' }}</template>
</el-table-column>
<el-table-column label="附加项" width="80" align="center">
<template #default="{ row }">{{ row.extraFlag ? '是' : '—' }}</template>
</el-table-column>
<el-table-column label="必拍" width="80" align="center">
<template #default="{ row }">{{ row.photoRequired ? '是' : '—' }}</template>
</el-table-column>
<el-table-column label="拒拍" width="170">
<template #default="{ row }">
<template v-if="row.isRefusedPhoto">是({{ row.refuseReason ?? '-' }})</template>
<template v-else>—</template>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无项目执行明细" :image-size="60" />
<el-divider content-position="left">照片</el-divider>
<el-table v-if="detail.record.photos.length > 0" :data="detail.record.photos" border size="small">
<el-table-column prop="filePath" label="文件 Key(MinIO)" min-width="280" />
<el-table-column prop="fileSize" label="大小(字节)" width="120" align="right" />
<el-table-column label="上传时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无照片" :image-size="60" />
<el-divider content-position="left">异常上报</el-divider>
<el-table v-if="detail.record.abnormals.length > 0" :data="detail.record.abnormals" border size="small">
<el-table-column label="分类" width="130">
<template #default="{ row }">{{ ABNORMAL_TYPE_LABEL_MAP[row.abnormalType] ?? row.abnormalType }}</template>
</el-table-column>
<el-table-column label="描述" min-width="180">
<template #default="{ row }">{{ row.abnormalDesc ?? '—' }}</template>
</el-table-column>
<el-table-column label="照片" width="80" align="center">
<template #default="{ row }">{{ row.hasPhoto ? '有' : '—' }}</template>
</el-table-column>
<el-table-column label="上报时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无异常上报" :image-size="60" />
<el-divider content-position="left">补正历史</el-divider>
<el-table v-if="detail.corrections.length > 0" :data="detail.corrections" border size="small">
<el-table-column label="补正说明" min-width="240">
<template #default="{ row }">{{ row.correctionNote }}</template>
</el-table-column>
<el-table-column label="补正时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.correctedAt) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无补正" :image-size="60" />
</template>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 退回留痕 -->
<el-dialog v-model="returnVisible" title="退回留痕" width="480px">
<el-form :model="returnForm" label-width="90px">
<el-form-item label="退回原因" required>
<el-select v-model="returnForm.qcNote" filterable allow-create default-first-option placeholder="选择或输入原因" style="width: 100%">
<el-option v-for="r in RETURN_REASON_OPTIONS" :key="r" :label="r" :value="r" />
</el-select>
</el-form-item>
<el-form-item label="补充说明">
<el-input v-model="returnForm.extra" type="textarea" :rows="3" placeholder="选填,附在原因后一并写回" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="returnVisible = false">取消</el-button>
<el-button type="primary" :loading="returning" @click="confirmReturn">确认退回</el-button>
</template>
</el-dialog>
<!-- 异常联动改计划 -->
<el-dialog v-model="linkVisible" title="异常联动改计划" width="860px">
<el-alert v-if="linkAbnormal" class="mb-sm" type="info" :closable="false" :title="`关联老人:${elderName(linkAbnormal.elderId)} · 异常分类:${ABNORMAL_TYPE_LABEL_MAP[linkAbnormal.abnormalType] ?? linkAbnormal.abnormalType}`" />
<el-form :model="linkForm" label-width="110px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="原计划" required>
<el-select v-model="linkForm.changeFromPlanId" filterable placeholder="选择被变更的旧计划" style="width: 100%" :loading="linkPlansLoading" @change="onLinkPlanChange">
<el-option v-for="p in linkPlans" :key="p.id" :label="linkPlanLabel(p)" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="护理等级" required>
<el-select v-model="linkForm.careLevel" placeholder="请选择" style="width: 100%">
<el-option v-for="o in CARE_LEVEL_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="护理方式" required>
<el-select v-model="linkForm.careMode" placeholder="请选择" style="width: 100%">
<el-option v-for="o in CARE_MODE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="周期起" required>
<el-date-picker v-model="linkForm.planPeriodStart" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="周期止" required>
<el-date-picker v-model="linkForm.planPeriodEnd" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<div class="mb-sm flex items-center">
<el-button link type="primary" :loading="linkLibLoading" @click="linkBringFromLibrary">从个人项目库带入</el-button>
<span class="ml-xs text-xs text-text-placeholder">带入后可修改每项频次/时长/拍照档位</span>
</div>
<el-table :data="linkForm.items" border size="small">
<el-table-column label="项目名称" min-width="180">
<template #default="{ row }">{{ row.name }}</template>
</el-table-column>
<el-table-column label="频次类型" width="130">
<template #default="{ row }">
<el-select v-model="row.freqType" placeholder="按需" clearable size="small">
<el-option v-for="o in FREQ_TYPE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="频次值" width="110">
<template #default="{ row }">
<el-input-number v-model="row.freqValue" :min="0" :controls="false" size="small" placeholder="次数" />
</template>
</el-table-column>
<el-table-column label="时长(分)" width="110">
<template #default="{ row }">
<el-input-number v-model="row.durationMin" :min="0" :controls="false" size="small" placeholder="分钟" />
</template>
</el-table-column>
<el-table-column label="拍照档位" width="120">
<template #default="{ row }">
<el-select v-model="row.photoTier" size="small">
<el-option v-for="o in PHOTO_TIER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="注意事项" min-width="140">
<template #default="{ row }">
<el-input v-model="row.cautionNote" size="small" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="linkForm.items.splice($index, 1)">移除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="linkForm.items.length === 0" description="暂无计划项,请从个人项目库带入" :image-size="60" />
</el-form>
<template #footer>
<el-button @click="linkVisible = false">取消</el-button>
<el-button type="primary" :loading="linkSubmitting" @click="submitLink">联动改计划</el-button>
</template>
</el-dialog>
<!-- 待办明细(逾期/住院/计划) -->
<el-dialog v-model="todoListVisible" :title="todoTitle" width="720px">
<template v-if="todoActive === 'overdue'">
<el-table :data="todoListData" border size="small">
<el-table-column label="老人" min-width="100">
<template #default="{ row }">{{ elderName(row.elderId) }}</template>
</el-table-column>
<el-table-column label="时段" min-width="160">
<template #default="{ row }">{{ timeRangeLabel(row.timeSlotStart, row.timeSlotEnd) }}</template>
</el-table-column>
<el-table-column label="工单状态" width="110" align="center">
<template #default="{ row }">{{ row.orderStatus }}</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="todoActive === 'hospActive'">
<el-table :data="todoListData" border size="small">
<el-table-column label="老人" min-width="100">
<template #default="{ row }">{{ row.elder?.name ?? elderName(row.elder?.id) }}</template>
</el-table-column>
<el-table-column label="护理员" min-width="100">
<template #default="{ row }">{{ staffName(row.order?.assigneeId) }}</template>
</el-table-column>
<el-table-column label="工单状态" width="110" align="center">
<template #default="{ row }">{{ row.order?.orderStatus }}</template>
</el-table-column>
</el-table>
</template>
<template v-else>
<el-table :data="todoListData" border size="small">
<el-table-column label="老人" min-width="120">
<template #default="{ row }">{{ row.name }}</template>
</el-table-column>
<el-table-column label="待遇生效" width="120">
<template #default="{ row }">{{ formatDate(row.benefitStart) }}</template>
</el-table-column>
<el-table-column label="待遇状态" width="110" align="center">
<template #default="{ row }">{{ row.treatStatus }}</template>
</el-table-column>
</el-table>
</template>
<el-empty v-if="todoListData.length === 0" description="暂无待办" :image-size="60" />
<template #footer>
<el-button @click="todoListVisible = false">关闭</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiError } from '@/api/http'
import type { JsonSchema, TableColumn } from '@/types/crud' import type { JsonSchema, TableColumn } from '@/types/crud'
import { useRequest } from '@/composables/useRequest' import { useRequest } from '@/composables/useRequest'
import { listElders, type Elder } from '@/api/elder' import { listElders, type Elder } from '@/api/elder'
import { listStaff, type Staff } from '@/api/staff'
import { getElderSvc } from '@/api/elder-svc'
import { getPlan, listPlansByElder, type Plan } from '@/api/plan'
import { import {
EXEC_RESULT_TAG_MAP,
LOCATE_STATUS_TAG_MAP,
RECORD_STATUS_TAG_MAP,
} from '@/api/record'
import {
ABNORMAL_TYPE_LABEL_MAP,
ABNORMAL_TYPE_OPTIONS,
HANDLE_STATUS_OPTIONS, HANDLE_STATUS_OPTIONS,
HANDLE_STATUS_TAG_MAP, HANDLE_STATUS_TAG_MAP,
NOTIFY_STATUS_OPTIONS, NOTIFY_STATUS_OPTIONS,
@ -31,30 +394,273 @@ import {
type ListAbnormalQuery, type ListAbnormalQuery,
type RecordAbnormal, type RecordAbnormal,
} from '@/api/abnormal' } from '@/api/abnormal'
import {
QC_RESULT_TAG_MAP,
batchPassQc,
exceptionLink,
getQcDetail,
passQc,
pendingList,
returnQc,
type PendingQcItem,
type QcDetail,
} from '@/api/qc'
import { CARE_LEVEL_OPTIONS, CARE_MODE_OPTIONS, formatDate, formatDateTime } from '@/pages/ltc/elder/constants'
defineOptions({ name: 'qc' }) defineOptions({ name: 'qc' })
const columns: TableColumn[] = [ const RETURN_REASON_OPTIONS = ['项目不符', '时长异常', '照片缺失', '定位异常', '信息不全', '其他']
{ prop: 'abnormalType', label: '分类', width: 120, align: 'center', type: 'tag', dictCode: 'abnormal_type' },
const FREQ_TYPE_OPTIONS = [
{ label: '每日', value: 'daily' },
{ label: '每周N次', value: 'weekly_n' },
{ label: '每月N次', value: 'monthly_n' },
{ label: '按需', value: 'as_needed' },
]
const PHOTO_TIER_OPTIONS = [
{ label: '必须拍照', value: 'must' },
{ label: '可选拍照', value: 'optional' },
{ label: '免拍照', value: 'free' },
]
// ---- 名称映射 ----
const elders = ref<Elder[]>([])
const staffList = ref<Staff[]>([])
const elderNameMap = ref<Map<string, string>>(new Map())
const staffNameMap = ref<Map<string, string>>(new Map())
function elderName(id?: string | null): string {
if (!id) return '—'
return elderNameMap.value.get(id) ?? id
}
function staffName(id?: string | null): string {
if (!id) return '—'
return staffNameMap.value.get(id) ?? id
}
async function loadMaps(): Promise<void> {
try {
const [elderRes, staffRes] = await Promise.all([
listElders({ page: 1, pageSize: 200 }),
listStaff({ page: 1, pageSize: 200 }),
])
elders.value = elderRes.list
staffList.value = staffRes.list
elderNameMap.value = new Map(elderRes.list.map((e) => [e.id, e.name]))
staffNameMap.value = new Map(staffRes.list.map((s) => [s.id, s.name]))
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
}
}
// ---- ① 待我处理 ----
const { data: pendingData, loading: pendingLoading, run: fetchPending } = useRequest(
(signal: AbortSignal) => pendingList(signal),
{ manual: true },
)
const pendingQcs = computed(() => pendingData.value?.pendingQc.list ?? [])
type TodoListKey = 'overdue' | 'hospActive' | 'missingPlan'
const todoListVisible = ref(false)
const todoActive = ref<TodoListKey | null>(null)
const todoTitle = computed(() => {
const map: Record<TodoListKey, string> = { overdue: '逾期未做', hospActive: '住院仍有派单', missingPlan: '计划未制定' }
return todoActive.value ? map[todoActive.value] : ''
})
const todoListData = computed<Record<string, any>[]>(() => {
const d = pendingData.value
if (!d || !todoActive.value) return []
if (todoActive.value === 'overdue') return d.overdue.list
if (todoActive.value === 'hospActive') return d.hospActive.list
return d.missingPlan.list
})
function scrollTo(id: string): void {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function goAbnormal(): void {
if (query.handleStatus !== 'open') {
query.handleStatus = 'open'
if (abnormalPage.value === 1) fetchAbnormal()
else abnormalPage.value = 1
}
scrollTo('qc-abnormal')
}
function openTodoList(key: TodoListKey): void {
todoActive.value = key
todoListVisible.value = true
}
const todoCards = computed(() => {
const d = pendingData.value
return [
{ key: 'abnormalOpen', label: '异常待处理', count: d?.abnormalOpen.count ?? 0, clearCondition: d?.abnormalOpen.clearCondition ?? '', onClick: goAbnormal },
{ key: 'overdue', label: '逾期未做', count: d?.overdue.count ?? 0, clearCondition: d?.overdue.clearCondition ?? '', onClick: () => openTodoList('overdue') },
{ key: 'hospActive', label: '住院仍有派单', count: d?.hospActive.count ?? 0, clearCondition: d?.hospActive.clearCondition ?? '', onClick: () => openTodoList('hospActive') },
{ key: 'missingPlan', label: '计划未制定', count: d?.missingPlan.count ?? 0, clearCondition: d?.missingPlan.clearCondition ?? '', onClick: () => openTodoList('missingPlan') },
{ key: 'pendingQc', label: '留痕待放行', count: d?.pendingQc.count ?? 0, clearCondition: d?.pendingQc.clearCondition ?? '', onClick: () => scrollTo('qc-pending-queue') },
]
})
// ---- ② 待放行队列 ----
const selectedPending = ref<PendingQcItem[]>([])
const selectedRecordIds = computed(() => selectedPending.value.map((r) => r.record?.id).filter((id): id is string => !!id))
const batchPassing = ref(false)
const passingId = ref('')
function pendingRowClass({ row }: { row: PendingQcItem; rowIndex: number }): string {
return row.focus ? 'qc-page__row--focus' : ''
}
function isPendingSelectable(row: PendingQcItem): boolean {
return row.record != null && row.correctionCount === 0
}
function onPendingSelectionChange(rows: PendingQcItem[]): void {
selectedPending.value = rows
}
function waitLabel(min: number): string {
if (min < 60) return `${min} 分钟`
const h = Math.floor(min / 60)
const m = min % 60
return m > 0 ? `${h} 小时 ${m} 分` : `${h} 小时`
}
async function doPass(row: Record<string, any>): Promise<void> {
const item = row as unknown as PendingQcItem
passingId.value = item.id
try {
await passQc(item.id)
ElMessage.success('已放行')
void fetchPending()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
passingId.value = ''
}
}
async function confirmBatchPass(): Promise<void> {
if (selectedRecordIds.value.length === 0) return
try {
await ElMessageBox.confirm(
`确认批量放行 ${selectedRecordIds.value.length} 条留痕?含异常/未执行/临时加项/时长过短/已补正的记录将被后端拦截,须逐条审核。`,
'批量放行',
{ type: 'warning' },
)
} catch {
return
}
batchPassing.value = true
try {
const res = await batchPassQc({ recordIds: selectedRecordIds.value })
ElMessage.success(`已放行 ${res.passed} 条`)
void fetchPending()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
batchPassing.value = false
}
}
// ---- 质控详情 ----
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<QcDetail | null>(null)
function durationLabel(min: number | null): string {
return min == null ? '-' : `${min} 分钟`
}
function fmtCoord(v: unknown): string {
if (v == null) return '-'
if (typeof v === 'number') return String(v)
if (typeof v === 'string') return v === '' ? '-' : v
return '-'
}
async function openQcDetail(row: Record<string, any>): Promise<void> {
const item = row as unknown as PendingQcItem
detailVisible.value = true
detail.value = null
detailLoading.value = true
try {
detail.value = await getQcDetail(item.id)
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
detailLoading.value = false
}
}
// ---- 退回 ----
const returnVisible = ref(false)
const returning = ref(false)
const returnQcId = ref('')
const returnForm = reactive({ qcNote: '', extra: '' })
function openReturn(row: Record<string, any>): void {
const item = row as unknown as PendingQcItem
returnQcId.value = item.id
returnForm.qcNote = ''
returnForm.extra = ''
returnVisible.value = true
}
async function confirmReturn(): Promise<void> {
const note = returnForm.qcNote.trim()
if (!note) {
ElMessage.warning('请选择或填写退回原因')
return
}
const extra = returnForm.extra.trim()
const qcNote = extra ? `${note}:${extra}` : note
returning.value = true
try {
await returnQc(returnQcId.value, { qcNote: qcNote.slice(0, 200) })
ElMessage.success('已退回')
returnVisible.value = false
void fetchPending()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
returning.value = false
}
}
// ---- ③ 异常处理台 ----
const abnormalColumns: TableColumn[] = [
{ prop: 'abnormalType', label: '分类', width: 120, align: 'center', formatter: (v) => ABNORMAL_TYPE_LABEL_MAP[String(v ?? '')] ?? String(v ?? '') },
{ prop: 'elderId', label: '关联老人', minWidth: 100, formatter: (v) => elderName(String(v)) }, { prop: 'elderId', label: '关联老人', minWidth: 100, formatter: (v) => elderName(String(v)) },
{ prop: 'abnormalDesc', label: '描述', minWidth: 180, formatter: (v) => (v ? String(v) : '—') }, { prop: 'abnormalDesc', label: '描述', minWidth: 180, formatter: (v) => (v ? String(v) : '—') },
{ prop: 'hasPhoto', label: '照片', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') }, { prop: 'hasPhoto', label: '照片', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') },
{ prop: 'voiceFile', label: '语音', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') }, { prop: 'voiceFile', label: '语音', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') },
{ prop: 'notifyStatus', label: '通知状态', width: 100, align: 'center', type: 'tag', tagMap: NOTIFY_STATUS_TAG_MAP }, { prop: 'notifyStatus', label: '通知状态', width: 100, align: 'center', type: 'tag', tagMap: NOTIFY_STATUS_TAG_MAP },
{ prop: 'handleStatus', label: '处理状态', width: 100, align: 'center', type: 'tag', tagMap: HANDLE_STATUS_TAG_MAP }, { prop: 'handleStatus', label: '处理状态', width: 100, align: 'center', type: 'tag', tagMap: HANDLE_STATUS_TAG_MAP },
{ prop: 'linkPlanChange', label: '联动', width: 80, align: 'center', formatter: (v) => (v ? '已联动' : '—') },
{ prop: 'visitTime', label: '到访时间', width: 150, type: 'datetime' }, { prop: 'visitTime', label: '到访时间', width: 150, type: 'datetime' },
{ prop: 'createdAt', label: '上报时间', width: 150, type: 'datetime' }, { prop: 'createdAt', label: '上报时间', width: 150, type: 'datetime' },
] ]
const elders = ref<Elder[]>([]) const abnormalSearchSchema = computed<JsonSchema>(() => ({
const elderNameMap = ref<Map<string, string>>(new Map())
const elderOptions = computed(() => elders.value.map((e) => ({ label: e.name, value: e.id })))
const searchSchema = computed<JsonSchema>(() => ({
type: 'object', type: 'object',
properties: { properties: {
abnormalType: { type: 'string', title: '异常分类', placeholder: '全部', dictCode: 'abnormal_type' }, abnormalType: {
type: 'string',
title: '异常分类',
placeholder: '全部',
enum: ABNORMAL_TYPE_OPTIONS.map((o) => o.value),
enumNames: ABNORMAL_TYPE_OPTIONS.map((o) => o.label),
},
notifyStatus: { notifyStatus: {
type: 'string', type: 'string',
title: '通知状态', title: '通知状态',
@ -73,24 +679,24 @@ const searchSchema = computed<JsonSchema>(() => ({
type: 'string', type: 'string',
title: '老人', title: '老人',
placeholder: '全部', placeholder: '全部',
enum: elderOptions.value.map((o) => o.value), enum: elders.value.map((e) => e.id),
enumNames: elderOptions.value.map((o) => o.label), enumNames: elders.value.map((e) => e.name),
}, },
}, },
})) }))
const query = reactive<Record<string, any>>({ abnormalType: '', notifyStatus: '', handleStatus: '', elderId: '' }) const query = reactive<Record<string, any>>({ abnormalType: '', notifyStatus: '', handleStatus: 'open', elderId: '' })
const list = ref<RecordAbnormal[]>([]) const abnormalList = ref<RecordAbnormal[]>([])
const total = ref(0) const abnormalTotal = ref(0)
const page = ref(1) const abnormalPage = ref(1)
const pageSize = ref(10) const abnormalPageSize = ref(10)
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined) const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
function toListQuery(): ListAbnormalQuery { function toAbnormalQuery(): ListAbnormalQuery {
return { return {
page: page.value, page: abnormalPage.value,
pageSize: pageSize.value, pageSize: abnormalPageSize.value,
abnormalType: str(query.abnormalType), abnormalType: str(query.abnormalType),
notifyStatus: str(query.notifyStatus), notifyStatus: str(query.notifyStatus),
handleStatus: str(query.handleStatus), handleStatus: str(query.handleStatus),
@ -98,52 +704,258 @@ function toListQuery(): ListAbnormalQuery {
} }
} }
const { loading, run: fetchList } = useRequest((signal: AbortSignal) => listAbnormals(toListQuery(), signal), { const { loading: abnormalLoading, run: fetchAbnormal } = useRequest(
(signal: AbortSignal) => listAbnormals(toAbnormalQuery(), signal),
{
manual: true, manual: true,
onSuccess: (result) => { onSuccess: (result) => {
list.value = result.list abnormalList.value = result.list
total.value = result.total abnormalTotal.value = result.total
}, },
},
)
watch([abnormalPage, abnormalPageSize], () => fetchAbnormal())
function handleAbnormalSearch(): void {
if (abnormalPage.value === 1) fetchAbnormal()
else abnormalPage.value = 1
}
function handleAbnormalReset(): void {
if (abnormalPage.value === 1) fetchAbnormal()
else abnormalPage.value = 1
}
// ---- 异常联动改计划 ----
interface LinkItemDraft {
svcId: string
name: string
freqType?: string
freqValue?: number
durationMin?: number
photoTier?: string
cautionNote?: string
}
const linkVisible = ref(false)
const linkSubmitting = ref(false)
const linkLibLoading = ref(false)
const linkPlansLoading = ref(false)
const linkAbnormal = ref<RecordAbnormal | null>(null)
const linkPlans = ref<Plan[]>([])
const linkForm = reactive<{
changeFromPlanId: string
careLevel: string
careMode: string
planPeriodStart: string
planPeriodEnd: string
items: LinkItemDraft[]
}>({
changeFromPlanId: '',
careLevel: '',
careMode: '',
planPeriodStart: '',
planPeriodEnd: '',
items: [],
}) })
watch([page, pageSize], () => fetchList()) function toDateStr(ms?: number | null): string {
if (ms == null) return ''
function handleSearch(): void { return formatDate(ms)
if (page.value === 1) fetchList()
else page.value = 1
} }
function handleReset(): void { function linkPlanLabel(p: Plan): string {
if (page.value === 1) fetchList() const start = formatDate(p.planPeriodStart)
else page.value = 1 const end = formatDate(p.planPeriodEnd)
return `${start} ~ ${end}(${p.planStatus})`
} }
function elderName(id: string): string { async function loadLinkPlans(elderId: string): Promise<void> {
return elderNameMap.value.get(id) ?? id linkPlansLoading.value = true
try {
linkPlans.value = await listPlansByElder(elderId)
if (linkPlans.value.length === 1) await onLinkPlanChange(linkPlans.value[0].id)
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
linkPlansLoading.value = false
}
} }
async function loadElders(): Promise<void> { async function onLinkPlanChange(planId: string): Promise<void> {
const result = await listElders({ page: 1, pageSize: 200 }) if (!planId) return
elders.value = result.list try {
elderNameMap.value = new Map(result.list.map((e) => [e.id, e.name])) const plan = await getPlan(planId)
linkForm.careLevel = plan.careLevel
linkForm.careMode = plan.careMode
linkForm.planPeriodStart = toDateStr(plan.planPeriodStart)
linkForm.planPeriodEnd = toDateStr(plan.planPeriodEnd)
linkForm.items = plan.items.map((it) => ({
svcId: it.svcId,
name: it.svcNameSnapshot,
freqType: it.freqType ?? undefined,
freqValue: it.freqValue ?? undefined,
durationMin: it.durationMin ?? undefined,
photoTier: it.photoTier || 'free',
cautionNote: it.cautionNote ?? undefined,
}))
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
}
}
function openLink(row: Record<string, any>): void {
const ab = row as unknown as RecordAbnormal
linkAbnormal.value = ab
linkForm.changeFromPlanId = ''
linkForm.careLevel = ''
linkForm.careMode = ''
linkForm.planPeriodStart = ''
linkForm.planPeriodEnd = ''
linkForm.items = []
linkVisible.value = true
void loadLinkPlans(ab.elderId)
}
async function linkBringFromLibrary(): Promise<void> {
const ab = linkAbnormal.value
if (!ab) return
linkLibLoading.value = true
try {
const lib = await getElderSvc(ab.elderId)
if (lib.length === 0) {
ElMessage.warning('该老人暂无个人项目库,请先在服务对象页维护')
return
}
if (!linkForm.careMode && lib[0]?.careMode) linkForm.careMode = lib[0].careMode
const existing = new Set(linkForm.items.map((i) => i.svcId))
for (const it of lib) {
if (existing.has(it.svcId)) continue
linkForm.items.push({ svcId: it.svcId, name: it.svcNameSnapshot, photoTier: 'free' })
}
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
linkLibLoading.value = false
}
}
async function submitLink(): Promise<void> {
const ab = linkAbnormal.value
if (!ab) return
if (!linkForm.changeFromPlanId) {
ElMessage.warning('请选择原计划')
return
}
if (!linkForm.careLevel) {
ElMessage.warning('请选择护理等级')
return
}
if (!linkForm.careMode) {
ElMessage.warning('请选择护理方式')
return
}
if (!linkForm.planPeriodStart || !linkForm.planPeriodEnd) {
ElMessage.warning('请选择计划周期')
return
}
if (linkForm.planPeriodEnd <= linkForm.planPeriodStart) {
ElMessage.warning('周期止须晚于周期起')
return
}
if (linkForm.items.length === 0) {
ElMessage.warning('请先带入计划项')
return
}
linkSubmitting.value = true
try {
await exceptionLink({
abnormalId: ab.id,
changeFromPlanId: linkForm.changeFromPlanId,
elderId: ab.elderId,
planPeriodStart: linkForm.planPeriodStart,
planPeriodEnd: linkForm.planPeriodEnd,
careLevel: linkForm.careLevel,
careMode: linkForm.careMode,
items: linkForm.items.map((i) => ({
svcId: i.svcId,
freqType: i.freqType || undefined,
freqValue: i.freqValue,
durationMin: i.durationMin,
photoTier: i.photoTier || 'free',
cautionNote: i.cautionNote?.trim() || undefined,
})),
})
ElMessage.success('联动改计划成功')
linkVisible.value = false
void fetchAbnormal()
void fetchPending()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
linkSubmitting.value = false
}
}
// ---- 展示辅助 ----
function timeRangeLabel(start?: number | null, end?: number | null): string {
return `${formatDateTime(start)} ~ ${formatDateTime(end)}`
} }
onMounted(() => { onMounted(() => {
void loadElders().catch(() => {}) void loadMaps()
fetchList() void fetchPending()
void fetchAbnormal()
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.qc-page { .qc-page {
@apply flex h-full flex-col; @apply h-full overflow-auto;
} }
.qc-page__table { .qc-page__cards {
@apply flex min-h-0 flex-1 flex-col; @apply grid grid-cols-5 gap-sm;
}
:deep(.el-card__body) { .todo-card {
@apply flex min-h-0 flex-1 flex-col; @apply cursor-pointer rounded-base border border-solid p-md;
border-color: var(--wm-border-color-light);
&__count {
@apply text-3xl font-semibold;
color: var(--wm-color-primary);
&--zero {
color: var(--wm-color-text-placeholder);
}
}
&__label {
@apply mt-xs font-medium;
color: var(--wm-color-text-primary);
}
&__hint {
@apply mt-xs text-xs;
color: var(--wm-color-text-placeholder);
}
}
.qc-page__detail {
@apply max-h-[60vh] overflow-auto;
}
.qc-page__abnormal {
@apply mb-md;
}
:deep(.qc-page__row--focus) {
background: #fef0f0;
& > td {
background: #fef0f0 !important;
} }
} }
</style> </style>