Compare commits
2 Commits
59e04693cd
...
08a9182352
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a9182352 | ||
|
|
f4adbd6976 |
119
src/api/abnormal.ts
Normal file
119
src/api/abnormal.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { http } from '@/api/http'
|
||||||
|
import type { PageResult, TagType } from '@/types/crud'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异常上报(ho_record_abnormal)API。
|
||||||
|
* 字段锚点见 shared/data-model spec(ho_record_abnormal)。
|
||||||
|
* - 时间字段(visit_time/created_at)后端经 TransformInterceptor 序列化为毫秒时间戳;
|
||||||
|
* - visit_lat/visit_lng 为 Decimal;
|
||||||
|
* - 上报(POST /abnormal)为护理员 APP 端能力,admin 仅调用列表。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 异常分类(9 宫格 + 未遇/扑空),值与 tenant-template DICTS abnormal_type 对齐。 */
|
||||||
|
export const ABNORMAL_TYPES = [
|
||||||
|
'pressure_sore',
|
||||||
|
'choking',
|
||||||
|
'fall',
|
||||||
|
'fever',
|
||||||
|
'skin_break',
|
||||||
|
'mood_abnormal',
|
||||||
|
'eat_difficulty',
|
||||||
|
'excretion_abnormal',
|
||||||
|
'other',
|
||||||
|
'not_met',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export interface LabeledValue {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ABNORMAL_TYPE_OPTIONS: LabeledValue[] = [
|
||||||
|
{ label: '压疮', value: 'pressure_sore' },
|
||||||
|
{ label: '呛咳', value: 'choking' },
|
||||||
|
{ label: '跌倒', value: 'fall' },
|
||||||
|
{ label: '发热', value: 'fever' },
|
||||||
|
{ label: '皮肤破损', value: 'skin_break' },
|
||||||
|
{ label: '情绪异常', value: 'mood_abnormal' },
|
||||||
|
{ label: '进食困难', value: 'eat_difficulty' },
|
||||||
|
{ label: '排泄异常', value: 'excretion_abnormal' },
|
||||||
|
{ label: '其他', value: 'other' },
|
||||||
|
{ label: '未遇(扑空)', value: 'not_met' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ABNORMAL_TYPE_LABEL_MAP: Record<string, string> = {
|
||||||
|
pressure_sore: '压疮',
|
||||||
|
choking: '呛咳',
|
||||||
|
fall: '跌倒',
|
||||||
|
fever: '发热',
|
||||||
|
skin_break: '皮肤破损',
|
||||||
|
mood_abnormal: '情绪异常',
|
||||||
|
eat_difficulty: '进食困难',
|
||||||
|
excretion_abnormal: '排泄异常',
|
||||||
|
other: '其他',
|
||||||
|
not_met: '未遇(扑空)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOTIFY_STATUS_OPTIONS: LabeledValue[] = [
|
||||||
|
{ label: '待通知', value: 'pending' },
|
||||||
|
{ label: '已通知', value: 'notified' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const HANDLE_STATUS_OPTIONS: LabeledValue[] = [
|
||||||
|
{ label: '待处理', value: 'open' },
|
||||||
|
{ label: '处理中', value: 'handling' },
|
||||||
|
{ label: '已关闭', value: 'closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NOTIFY_STATUS_TAG_MAP: Record<string, { label: string; type: TagType }> = {
|
||||||
|
pending: { label: '待通知', type: 'info' },
|
||||||
|
notified: { label: '已通知', type: 'success' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HANDLE_STATUS_TAG_MAP: Record<string, { label: string; type: TagType }> = {
|
||||||
|
open: { label: '待处理', type: 'warning' },
|
||||||
|
handling: { label: '处理中', type: 'info' },
|
||||||
|
closed: { label: '已关闭', type: 'success' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常上报记录(ho_record_abnormal)。 */
|
||||||
|
export interface RecordAbnormal {
|
||||||
|
id: string
|
||||||
|
tenantCode: string
|
||||||
|
recordId: string | null
|
||||||
|
orderId: string | null
|
||||||
|
elderId: string
|
||||||
|
reporterId: string
|
||||||
|
abnormalType: string
|
||||||
|
abnormalDesc: string | null
|
||||||
|
voiceFile: string | null
|
||||||
|
hasPhoto: boolean
|
||||||
|
visitTime: number | null
|
||||||
|
visitLat: number | string | null
|
||||||
|
visitLng: number | string | null
|
||||||
|
notifyStatus: string
|
||||||
|
handleStatus: string
|
||||||
|
handleResult: string | null
|
||||||
|
linkPlanChange: boolean
|
||||||
|
createdAt: number
|
||||||
|
createdBy: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAbnormalQuery {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
abnormalType?: string
|
||||||
|
notifyStatus?: string
|
||||||
|
handleStatus?: string
|
||||||
|
elderId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常列表(后台 B4,分页)。 */
|
||||||
|
export function listAbnormals(query: ListAbnormalQuery, signal?: AbortSignal): Promise<PageResult<RecordAbnormal>> {
|
||||||
|
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
|
||||||
|
if (query.abnormalType) params.abnormalType = query.abnormalType
|
||||||
|
if (query.notifyStatus) params.notifyStatus = query.notifyStatus
|
||||||
|
if (query.handleStatus) params.handleStatus = query.handleStatus
|
||||||
|
if (query.elderId) params.elderId = query.elderId
|
||||||
|
return http.get<PageResult<RecordAbnormal>>('/abnormal', { params, signal })
|
||||||
|
}
|
||||||
133
src/api/record.ts
Normal file
133
src/api/record.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { http } from '@/api/http'
|
||||||
|
import type { TagType } from '@/types/crud'
|
||||||
|
import type { RecordAbnormal } from '@/api/abnormal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 履约留痕(ho_record / ho_record_item / ho_record_photo)API。
|
||||||
|
* 字段锚点见 shared/data-model spec(ho_record*)。
|
||||||
|
* - 时间字段(checkin_time/checkout_time/submit_time/keep_until/created_at/updated_at)后端经 TransformInterceptor 序列化为毫秒时间戳;
|
||||||
|
* - checkin_lat/checkin_lng 为 Decimal,序列化为字符串或数值;
|
||||||
|
* - 签到/签退/项目/照片/离线补传为护理员 APP 端能力,admin 仅调用列表/详情/垫付台账/结转。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 留痕状态(record_status)。 */
|
||||||
|
export const RECORD_STATUSES = ['serving', 'submitted'] as const
|
||||||
|
|
||||||
|
/** 项目执行结果(exec_result)。 */
|
||||||
|
export const EXEC_RESULTS = ['done', 'undo_not_done', 'undo_refused'] as const
|
||||||
|
|
||||||
|
/** 定位状态(locate_status)。 */
|
||||||
|
export const LOCATE_STATUSES = ['ok', 'failed', 'denied'] as const
|
||||||
|
|
||||||
|
export const RECORD_STATUS_TAG_MAP: Record<string, { label: string; type: TagType }> = {
|
||||||
|
serving: { label: '服务中', type: 'info' },
|
||||||
|
submitted: { label: '已提交', type: 'success' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXEC_RESULT_TAG_MAP: Record<string, { label: string; type: TagType }> = {
|
||||||
|
done: { label: '已完成', type: 'success' },
|
||||||
|
undo_not_done: { label: '本次未做', type: 'warning' },
|
||||||
|
undo_refused: { label: '拒做', type: 'danger' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCATE_STATUS_TAG_MAP: Record<string, { label: string; type: TagType }> = {
|
||||||
|
ok: { label: '定位成功', type: 'success' },
|
||||||
|
failed: { label: '定位失败', type: 'danger' },
|
||||||
|
denied: { label: '拒绝定位', type: 'warning' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 留痕项目明细(ho_record_item)。 */
|
||||||
|
export interface RecordItem {
|
||||||
|
id: string
|
||||||
|
recordId: string
|
||||||
|
svcId: string
|
||||||
|
svcNameSnapshot: string
|
||||||
|
execResult: string
|
||||||
|
undoReason: string | null
|
||||||
|
extraFlag: boolean
|
||||||
|
photoRequired: boolean
|
||||||
|
isRefusedPhoto: boolean
|
||||||
|
refuseReason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 留痕照片(ho_record_photo)。 */
|
||||||
|
export interface RecordPhoto {
|
||||||
|
id: string
|
||||||
|
recordId: string
|
||||||
|
svcId: string | null
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
visibleToFamily: boolean
|
||||||
|
uploadBatch: string | null
|
||||||
|
keepUntil: number | null
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 履约留痕(ho_record)。Decimal 定位字段序列化为字符串/数值。 */
|
||||||
|
export interface ServiceRecord {
|
||||||
|
id: string
|
||||||
|
tenantCode: string
|
||||||
|
orderId: string
|
||||||
|
elderId: string
|
||||||
|
assigneeId: string
|
||||||
|
checkinTime: number | null
|
||||||
|
checkoutTime: number | null
|
||||||
|
durationMin: number | null
|
||||||
|
checkinLat: number | string | null
|
||||||
|
checkinLng: number | string | null
|
||||||
|
locateStatus: string
|
||||||
|
recordStatus: string
|
||||||
|
voiceFile: string | null
|
||||||
|
offlineFlag: boolean
|
||||||
|
clientMd5: string | null
|
||||||
|
submitTime: number | null
|
||||||
|
actualDurationTier: string | null
|
||||||
|
bizLine: string
|
||||||
|
isPrepaid: boolean
|
||||||
|
billable: boolean
|
||||||
|
sourcePrepaidOf: string | null
|
||||||
|
createdAt: number
|
||||||
|
createdBy: string | null
|
||||||
|
updatedAt: number
|
||||||
|
deletedAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 留痕详情:record + items + photos + abnormals。 */
|
||||||
|
export interface ServiceRecordDetail extends ServiceRecord {
|
||||||
|
items: RecordItem[]
|
||||||
|
photos: RecordPhoto[]
|
||||||
|
abnormals: RecordAbnormal[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListRecordQuery {
|
||||||
|
elderId?: string
|
||||||
|
assigneeId?: string
|
||||||
|
/** 服务日期(YYYY-MM-DD,按 checkin_time 过滤)。 */
|
||||||
|
serviceDate?: string
|
||||||
|
recordStatus?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 留痕列表(后台不分页,返回全量数组)。 */
|
||||||
|
export function listRecords(query: ListRecordQuery, signal?: AbortSignal): Promise<ServiceRecord[]> {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (query.elderId) params.elderId = query.elderId
|
||||||
|
if (query.assigneeId) params.assigneeId = query.assigneeId
|
||||||
|
if (query.serviceDate) params.serviceDate = query.serviceDate
|
||||||
|
if (query.recordStatus) params.recordStatus = query.recordStatus
|
||||||
|
return http.get<ServiceRecord[]>('/record', { params, signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 留痕详情。 */
|
||||||
|
export function getRecord(id: string): Promise<ServiceRecordDetail> {
|
||||||
|
return http.get<ServiceRecordDetail>(`/record/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 垫付台账:某老人 is_prepaid=1 的留痕列表。 */
|
||||||
|
export function listPrepaidRecords(elderId: string): Promise<ServiceRecord[]> {
|
||||||
|
return http.get<ServiceRecord[]>('/record/prepaid-list', { params: { elderId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结转垫付期服务:勾选垫付留痕 → 复制为正式计费记录。 */
|
||||||
|
export function prepaidCarryover(recordIds: string[]): Promise<ServiceRecord[]> {
|
||||||
|
return http.post<ServiceRecord[]>('/record/prepaid/carryover', { recordIds })
|
||||||
|
}
|
||||||
@ -50,6 +50,49 @@
|
|||||||
<el-button v-permission="'plan:create'" type="primary" @click="openPlanForm">制定计划</el-button>
|
<el-button v-permission="'plan:create'" type="primary" @click="openPlanForm">制定计划</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="elder?.treatStatus === 'active'">
|
||||||
|
<el-divider content-position="left">垫付台账</el-divider>
|
||||||
|
<div v-loading="prepaidLoading">
|
||||||
|
<div class="mb-sm flex items-center">
|
||||||
|
<el-button
|
||||||
|
v-permission="'record:prepaid-carryover'"
|
||||||
|
type="primary"
|
||||||
|
:disabled="prepaidSelection.length === 0"
|
||||||
|
:loading="carryingOver"
|
||||||
|
@click="carryover"
|
||||||
|
>
|
||||||
|
结转垫付期服务
|
||||||
|
</el-button>
|
||||||
|
<span class="ml-xs text-xs text-text-placeholder">勾选待结转的垫付留痕(is_prepaid),结转后生成正式计费记录</span>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-if="prepaidRecords.length > 0"
|
||||||
|
:data="prepaidRecords"
|
||||||
|
border
|
||||||
|
size="small"
|
||||||
|
@selection-change="onPrepaidSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
|
<el-table-column label="签到时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.checkinTime) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="签退时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.checkoutTime) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时长(分)" width="90" align="center">
|
||||||
|
<template #default="{ row }">{{ row.durationMin ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">{{ row.recordStatus === 'submitted' ? '已提交' : '服务中' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="工单 ID" min-width="180">
|
||||||
|
<template #default="{ row }">{{ row.orderId }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无垫付记录" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="!isTerminal" v-permission="'elder:status-change'">
|
<div v-if="!isTerminal" v-permission="'elder:status-change'">
|
||||||
<el-divider content-position="left">状态变更</el-divider>
|
<el-divider content-position="left">状态变更</el-divider>
|
||||||
<el-form inline>
|
<el-form inline>
|
||||||
@ -86,6 +129,7 @@ import { ApiError } from '@/api/http'
|
|||||||
import { changeElderStatus, getElder, getStatusLog, type Elder, type ElderStatusLog } from '@/api/elder'
|
import { changeElderStatus, getElder, getStatusLog, type Elder, type ElderStatusLog } from '@/api/elder'
|
||||||
import { listLocal, listNewNational, listOrg } from '@/api/catalog'
|
import { listLocal, listNewNational, listOrg } from '@/api/catalog'
|
||||||
import { getElderSvc, putElderSvc } from '@/api/elder-svc'
|
import { getElderSvc, putElderSvc } from '@/api/elder-svc'
|
||||||
|
import { listPrepaidRecords, prepaidCarryover, type ServiceRecord } from '@/api/record'
|
||||||
import { showDialog } from '@/utils/dialog'
|
import { showDialog } from '@/utils/dialog'
|
||||||
import {
|
import {
|
||||||
ASSESS_LEVEL_OPTIONS,
|
ASSESS_LEVEL_OPTIONS,
|
||||||
@ -124,6 +168,12 @@ const svcSaving = ref(false)
|
|||||||
const l4Projects = ref<L4Project[]>([])
|
const l4Projects = ref<L4Project[]>([])
|
||||||
const svcForm = reactive({ careMode: '', svcIds: [] as string[] })
|
const svcForm = reactive({ careMode: '', svcIds: [] as string[] })
|
||||||
|
|
||||||
|
// ---- 垫付台账 ----
|
||||||
|
const prepaidRecords = ref<ServiceRecord[]>([])
|
||||||
|
const prepaidLoading = ref(false)
|
||||||
|
const prepaidSelection = ref<ServiceRecord[]>([])
|
||||||
|
const carryingOver = ref(false)
|
||||||
|
|
||||||
/** 终态(dead/terminated):后端 IRREVERSIBLE 源态守卫,禁止从终态再变更 → 隐藏整个状态变更区块。 */
|
/** 终态(dead/terminated):后端 IRREVERSIBLE 源态守卫,禁止从终态再变更 → 隐藏整个状态变更区块。 */
|
||||||
const isTerminal = computed(() => {
|
const isTerminal = computed(() => {
|
||||||
const s = elder.value?.treatStatus
|
const s = elder.value?.treatStatus
|
||||||
@ -146,6 +196,7 @@ async function load(): Promise<void> {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
await loadSvcLibrary()
|
await loadSvcLibrary()
|
||||||
|
await loadPrepaid()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 个人项目库:当前勾选(快照)+ 本机构 L4 项目(名称 join L2/L1 目录)。 */
|
/** 个人项目库:当前勾选(快照)+ 本机构 L4 项目(名称 join L2/L1 目录)。 */
|
||||||
@ -200,6 +251,41 @@ async function saveSvcLibrary(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 垫付台账:is_prepaid=1 的留痕,供勾选结转。 */
|
||||||
|
async function loadPrepaid(): Promise<void> {
|
||||||
|
prepaidLoading.value = true
|
||||||
|
try {
|
||||||
|
prepaidRecords.value = await listPrepaidRecords(props.elderId)
|
||||||
|
prepaidSelection.value = []
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
prepaidLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPrepaidSelectionChange(rows: ServiceRecord[]): void {
|
||||||
|
prepaidSelection.value = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结转垫付期服务:勾选 → POST /record/prepaid/carryover(复制为正式计费记录)。 */
|
||||||
|
async function carryover(): Promise<void> {
|
||||||
|
if (prepaidSelection.value.length === 0) {
|
||||||
|
ElMessage.warning('请先勾选待结转的垫付留痕')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
carryingOver.value = true
|
||||||
|
try {
|
||||||
|
await prepaidCarryover(prepaidSelection.value.map((r) => r.id))
|
||||||
|
ElMessage.success('结转成功')
|
||||||
|
await loadPrepaid()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
carryingOver.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 制定计划:弹窗,从个人项目库带入 → 生成 plan_item 初稿(可改)。 */
|
/** 制定计划:弹窗,从个人项目库带入 → 生成 plan_item 初稿(可改)。 */
|
||||||
async function openPlanForm(): Promise<void> {
|
async function openPlanForm(): Promise<void> {
|
||||||
const plan = await showDialog({
|
const plan = await showDialog({
|
||||||
|
|||||||
@ -1,7 +1,149 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>质控中心(C1–C4 实现)</div>
|
<div class="qc-page">
|
||||||
|
<el-card class="mb-md" shadow="never">
|
||||||
|
<ty-search-form :schema="searchSchema" :model-value="query" @search="handleSearch" @reset="handleReset" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="qc-page__table" shadow="never">
|
||||||
|
<ty-table
|
||||||
|
:columns="columns"
|
||||||
|
:data="list"
|
||||||
|
:loading="loading"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
import { listElders, type Elder } from '@/api/elder'
|
||||||
|
import {
|
||||||
|
HANDLE_STATUS_OPTIONS,
|
||||||
|
HANDLE_STATUS_TAG_MAP,
|
||||||
|
NOTIFY_STATUS_OPTIONS,
|
||||||
|
NOTIFY_STATUS_TAG_MAP,
|
||||||
|
listAbnormals,
|
||||||
|
type ListAbnormalQuery,
|
||||||
|
type RecordAbnormal,
|
||||||
|
} from '@/api/abnormal'
|
||||||
|
|
||||||
defineOptions({ name: 'qc' })
|
defineOptions({ name: 'qc' })
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ prop: 'abnormalType', label: '分类', width: 120, align: 'center', type: 'tag', dictCode: 'abnormal_type' },
|
||||||
|
{ prop: 'elderId', label: '关联老人', minWidth: 100, formatter: (v) => elderName(String(v)) },
|
||||||
|
{ prop: 'abnormalDesc', label: '描述', minWidth: 180, formatter: (v) => (v ? String(v) : '—') },
|
||||||
|
{ prop: 'hasPhoto', 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: 'handleStatus', label: '处理状态', width: 100, align: 'center', type: 'tag', tagMap: HANDLE_STATUS_TAG_MAP },
|
||||||
|
{ prop: 'visitTime', label: '到访时间', width: 150, type: 'datetime' },
|
||||||
|
{ prop: 'createdAt', label: '上报时间', width: 150, type: 'datetime' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const elders = ref<Elder[]>([])
|
||||||
|
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',
|
||||||
|
properties: {
|
||||||
|
abnormalType: { type: 'string', title: '异常分类', placeholder: '全部', dictCode: 'abnormal_type' },
|
||||||
|
notifyStatus: {
|
||||||
|
type: 'string',
|
||||||
|
title: '通知状态',
|
||||||
|
placeholder: '全部',
|
||||||
|
enum: NOTIFY_STATUS_OPTIONS.map((o) => o.value),
|
||||||
|
enumNames: NOTIFY_STATUS_OPTIONS.map((o) => o.label),
|
||||||
|
},
|
||||||
|
handleStatus: {
|
||||||
|
type: 'string',
|
||||||
|
title: '处理状态',
|
||||||
|
placeholder: '全部',
|
||||||
|
enum: HANDLE_STATUS_OPTIONS.map((o) => o.value),
|
||||||
|
enumNames: HANDLE_STATUS_OPTIONS.map((o) => o.label),
|
||||||
|
},
|
||||||
|
elderId: {
|
||||||
|
type: 'string',
|
||||||
|
title: '老人',
|
||||||
|
placeholder: '全部',
|
||||||
|
enum: elderOptions.value.map((o) => o.value),
|
||||||
|
enumNames: elderOptions.value.map((o) => o.label),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const query = reactive<Record<string, any>>({ abnormalType: '', notifyStatus: '', handleStatus: '', elderId: '' })
|
||||||
|
const list = ref<RecordAbnormal[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
||||||
|
|
||||||
|
function toListQuery(): ListAbnormalQuery {
|
||||||
|
return {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
abnormalType: str(query.abnormalType),
|
||||||
|
notifyStatus: str(query.notifyStatus),
|
||||||
|
handleStatus: str(query.handleStatus),
|
||||||
|
elderId: str(query.elderId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { loading, run: fetchList } = useRequest((signal: AbortSignal) => listAbnormals(toListQuery(), signal), {
|
||||||
|
manual: true,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
list.value = result.list
|
||||||
|
total.value = result.total
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([page, pageSize], () => fetchList())
|
||||||
|
|
||||||
|
function handleSearch(): void {
|
||||||
|
if (page.value === 1) fetchList()
|
||||||
|
else page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset(): void {
|
||||||
|
if (page.value === 1) fetchList()
|
||||||
|
else page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function elderName(id: string): string {
|
||||||
|
return elderNameMap.value.get(id) ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadElders(): Promise<void> {
|
||||||
|
const result = await listElders({ page: 1, pageSize: 200 })
|
||||||
|
elders.value = result.list
|
||||||
|
elderNameMap.value = new Map(result.list.map((e) => [e.id, e.name]))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadElders().catch(() => {})
|
||||||
|
fetchList()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.qc-page {
|
||||||
|
@apply flex h-full flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qc-page__table {
|
||||||
|
@apply flex min-h-0 flex-1 flex-col;
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
@apply flex min-h-0 flex-1 flex-col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,7 +1,281 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>工单留痕(C1–C4 实现)</div>
|
<div class="record-page">
|
||||||
|
<el-card class="mb-md" shadow="never">
|
||||||
|
<ty-search-form :schema="searchSchema" :model-value="query" @search="handleSearch" @reset="handleReset" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="record-page__table" shadow="never">
|
||||||
|
<ty-table :columns="columns" :data="list" :loading="loading" :actions-width="120">
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<el-button link type="primary" @click="openDetail(row)">查看</el-button>
|
||||||
|
</template>
|
||||||
|
</ty-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="detailVisible" title="工单留痕详情" width="860px">
|
||||||
|
<div v-loading="detailLoading" class="record-page__detail">
|
||||||
|
<template v-if="detail">
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="老人">{{ elderName(detail.elderId) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="护理员">{{ staffName(detail.assigneeId) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="签到时间">{{ formatDateTime(detail.checkinTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="签退时间">{{ formatDateTime(detail.checkoutTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务时长">{{ durationLabel(detail.durationMin) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="留痕状态">
|
||||||
|
<el-tag :type="RECORD_STATUS_TAG_MAP[detail.recordStatus]?.type ?? 'info'" size="small">
|
||||||
|
{{ RECORD_STATUS_TAG_MAP[detail.recordStatus]?.label ?? detail.recordStatus }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="垫付标记">
|
||||||
|
<el-tag v-if="detail.isPrepaid" type="warning" size="small">垫付</el-tag>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="是否计费">{{ detail.billable ? '是' : '否' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="离线补传">{{ detail.offlineFlag ? '是' : '否' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.submitTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="定位">
|
||||||
|
{{ LOCATE_STATUS_TAG_MAP[detail.locateStatus]?.label ?? detail.locateStatus }}
|
||||||
|
<span class="text-xs text-text-placeholder">({{ fmtCoord(detail.checkinLat) }}, {{ fmtCoord(detail.checkinLng) }})</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="工单 ID">{{ detail.orderId }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="left">项目执行</el-divider>
|
||||||
|
<el-table v-if="detail.items.length > 0" :data="detail.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.photos.length > 0" :data="detail.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.abnormals.length > 0" :data="detail.abnormals" border size="small">
|
||||||
|
<el-table-column label="分类" width="130">
|
||||||
|
<template #default="{ row }">{{ abnormalTypeLabel(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" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ApiError } from '@/api/http'
|
||||||
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
import { listElders, type Elder } from '@/api/elder'
|
||||||
|
import { listStaff, type Staff } from '@/api/staff'
|
||||||
|
import {
|
||||||
|
EXEC_RESULT_TAG_MAP,
|
||||||
|
LOCATE_STATUS_TAG_MAP,
|
||||||
|
RECORD_STATUS_TAG_MAP,
|
||||||
|
getRecord,
|
||||||
|
listRecords,
|
||||||
|
type ServiceRecord,
|
||||||
|
type ServiceRecordDetail,
|
||||||
|
} from '@/api/record'
|
||||||
|
import { ABNORMAL_TYPE_LABEL_MAP } from '@/api/abnormal'
|
||||||
|
import { formatDateTime } from '@/pages/ltc/elder/constants'
|
||||||
|
|
||||||
defineOptions({ name: 'record' })
|
defineOptions({ name: 'record' })
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ prop: 'elderId', label: '老人', minWidth: 100, formatter: (v) => elderName(String(v)) },
|
||||||
|
{ prop: 'assigneeId', label: '护理员', minWidth: 100, formatter: (v) => staffName(String(v)) },
|
||||||
|
{ prop: 'checkinTime', label: '签到时间', width: 150, type: 'datetime' },
|
||||||
|
{ prop: 'checkoutTime', label: '签退时间', width: 150, type: 'datetime' },
|
||||||
|
{ prop: 'durationMin', label: '时长(分)', width: 90, align: 'center', formatter: (v) => (v == null ? '-' : String(v)) },
|
||||||
|
{ prop: 'recordStatus', label: '状态', width: 90, align: 'center', type: 'tag', tagMap: RECORD_STATUS_TAG_MAP },
|
||||||
|
{ prop: 'isPrepaid', label: '垫付', width: 70, align: 'center', formatter: (v) => (v ? '垫付' : '—') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const elders = ref<Elder[]>([])
|
||||||
|
const staffList = ref<Staff[]>([])
|
||||||
|
const elderNameMap = ref<Map<string, string>>(new Map())
|
||||||
|
const staffNameMap = ref<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
const elderOptions = computed(() => elders.value.map((e) => ({ label: e.name, value: e.id })))
|
||||||
|
const staffOptions = computed(() => staffList.value.map((s) => ({ label: s.name, value: s.id })))
|
||||||
|
|
||||||
|
const searchSchema = computed<JsonSchema>(() => ({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
elderId: {
|
||||||
|
type: 'string',
|
||||||
|
title: '老人',
|
||||||
|
placeholder: '全部',
|
||||||
|
enum: elderOptions.value.map((o) => o.value),
|
||||||
|
enumNames: elderOptions.value.map((o) => o.label),
|
||||||
|
},
|
||||||
|
assigneeId: {
|
||||||
|
type: 'string',
|
||||||
|
title: '护理员',
|
||||||
|
placeholder: '全部',
|
||||||
|
enum: staffOptions.value.map((o) => o.value),
|
||||||
|
enumNames: staffOptions.value.map((o) => o.label),
|
||||||
|
},
|
||||||
|
serviceDate: { type: 'string', title: '服务日期', placeholder: 'YYYY-MM-DD' },
|
||||||
|
recordStatus: {
|
||||||
|
type: 'string',
|
||||||
|
title: '状态',
|
||||||
|
placeholder: '全部',
|
||||||
|
enum: ['serving', 'submitted'],
|
||||||
|
enumNames: ['服务中', '已提交'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const query = reactive<Record<string, any>>({ elderId: '', assigneeId: '', serviceDate: '', recordStatus: '' })
|
||||||
|
const list = ref<ServiceRecord[]>([])
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detail = ref<ServiceRecordDetail | null>(null)
|
||||||
|
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
||||||
|
|
||||||
|
function toListQuery() {
|
||||||
|
return {
|
||||||
|
elderId: str(query.elderId),
|
||||||
|
assigneeId: str(query.assigneeId),
|
||||||
|
serviceDate: str(query.serviceDate),
|
||||||
|
recordStatus: str(query.recordStatus),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { loading, run: fetchList } = useRequest((signal: AbortSignal) => listRecords(toListQuery(), signal), {
|
||||||
|
manual: true,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
list.value = result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch(): void {
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset(): void {
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function elderName(id: string): string {
|
||||||
|
return elderNameMap.value.get(id) ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
function staffName(id: string): string {
|
||||||
|
return staffNameMap.value.get(id) ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(min: number | null): string {
|
||||||
|
return min == null ? '-' : `${min} 分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定位坐标:兼容 number/字符串/Decimal 序列化结果。 */
|
||||||
|
function fmtCoord(v: unknown): string {
|
||||||
|
if (v == null) return '-'
|
||||||
|
if (typeof v === 'number') return String(v)
|
||||||
|
if (typeof v === 'string') return v === '' ? '-' : v
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function abnormalTypeLabel(t: string): string {
|
||||||
|
return ABNORMAL_TYPE_LABEL_MAP[t] ?? t
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(row: Record<string, any>): Promise<void> {
|
||||||
|
detailVisible.value = true
|
||||||
|
detail.value = null
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await getRecord(String(row.id))
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadMaps()
|
||||||
|
fetchList()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.record-page {
|
||||||
|
@apply flex h-full flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-page__table {
|
||||||
|
@apply flex min-h-0 flex-1 flex-col;
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
@apply flex min-h-0 flex-1 flex-col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-page__detail {
|
||||||
|
@apply max-h-[60vh] overflow-auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user