feat(workbench): 接入工作台首页概览聚合接口
This commit is contained in:
@@ -25,7 +25,9 @@ export function getCampuses(): Promise<CampusRecord[]> {
|
||||
}
|
||||
|
||||
return request
|
||||
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses')
|
||||
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses', {
|
||||
params: { page: 1, size: 200 },
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
.then((data) =>
|
||||
getRecords(data).map((item) => ({
|
||||
|
||||
@@ -8,16 +8,12 @@ const REPORT_PAGE_SIZE = 200
|
||||
|
||||
export const isReportsMockEnabled = isSigningMockEnabled
|
||||
|
||||
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||
return value === '本部院区' || value === '东院区' || value === '西院区'
|
||||
}
|
||||
|
||||
function buildSigningApiOptions(data: ReportTaskData) {
|
||||
return {
|
||||
templates: data.templates,
|
||||
campusNames: Object.fromEntries(
|
||||
data.campuses
|
||||
.filter((campus) => isWorkbenchCampus(campus.name))
|
||||
.filter((campus) => Boolean(campus.name.trim()))
|
||||
.map((campus) => [campus.id, campus.name]),
|
||||
) as Record<string, WorkbenchCampus>,
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { getCampuses } from '@/api/management/organization'
|
||||
|
||||
import { getSigningTasks, getSigningTemplates } from './signing'
|
||||
import { unwrapApiResponse } from '@/utils/api-response'
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types/common'
|
||||
|
||||
import type {
|
||||
BackendSigningMethod,
|
||||
BackendSigningTaskStatus,
|
||||
HomeRankingPeriod,
|
||||
SigningMethod,
|
||||
WorkbenchCampus,
|
||||
WorkbenchDocumentRanking,
|
||||
WorkbenchOverviewQuery,
|
||||
WorkbenchOverviewRequestDto,
|
||||
WorkbenchOverviewResponse,
|
||||
SigningApiOptions,
|
||||
SigningTemplate,
|
||||
SigningTaskRecord,
|
||||
SigningTaskQuery,
|
||||
WorkbenchOverviewResponseDto,
|
||||
WorkbenchTodoStatus,
|
||||
WorkbenchTodoTask,
|
||||
WorkbenchTrendPoint,
|
||||
} from './types'
|
||||
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
|
||||
interface MockCampusOverview {
|
||||
@@ -252,222 +254,124 @@ function createRanking(
|
||||
.sort((left, right) => right.signedCount - left.signedCount)
|
||||
}
|
||||
|
||||
function countAvailableTemplates(templates: SigningTemplate[]) {
|
||||
return new Set(templates.map((template) => template.id)).size
|
||||
}
|
||||
const TODO_LIMIT = 8
|
||||
const RANKING_LIMIT = 10
|
||||
let campusIds: Map<string, string> | null = null
|
||||
let campusIdsRequest: Promise<Map<string, string>> | null = null
|
||||
|
||||
function countCoveredDepartments(templates: SigningTemplate[]) {
|
||||
return new Set(
|
||||
templates
|
||||
.map((template) => {
|
||||
if (template.departmentId) {
|
||||
return `id:${template.departmentId}`
|
||||
}
|
||||
|
||||
return template.department && template.department !== '全院通用'
|
||||
? `name:${template.department}`
|
||||
: null
|
||||
})
|
||||
.filter((department): department is string => Boolean(department)),
|
||||
).size
|
||||
}
|
||||
|
||||
async function getAllSigningTasks(
|
||||
query: SigningTaskQuery,
|
||||
options: SigningApiOptions,
|
||||
): Promise<SigningTaskRecord[]> {
|
||||
const pageSize = 200
|
||||
const firstPage = await getSigningTasks({ ...query, page: 1, pageSize }, options)
|
||||
const totalPages = Math.ceil(firstPage.total / Math.max(firstPage.pageSize, 1))
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return firstPage.records
|
||||
function mapTodoStatus(status: BackendSigningTaskStatus): WorkbenchTodoStatus {
|
||||
switch (status) {
|
||||
case 'CREATED':
|
||||
return 'created'
|
||||
case 'WAITING_SIGN':
|
||||
return 'pending'
|
||||
case 'GENERATING':
|
||||
return 'generating'
|
||||
case 'EXPIRED':
|
||||
return 'overdue'
|
||||
case 'SIGNED':
|
||||
return 'signed'
|
||||
case 'VOIDED':
|
||||
return 'voided'
|
||||
case 'FAILED':
|
||||
default:
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const remainingPages = await Promise.all(
|
||||
Array.from({ length: totalPages - 1 }, (_, index) =>
|
||||
getSigningTasks({ ...query, page: index + 2, pageSize }, options),
|
||||
),
|
||||
)
|
||||
|
||||
return [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||
}
|
||||
|
||||
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||
return value === '本部院区' || value === '东院区' || value === '西院区'
|
||||
function mapSigningMethod(method: BackendSigningMethod): SigningMethod {
|
||||
return method === 'SMS' ? 'sms' : 'pad'
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function isSameDay(left: Date | null, right: Date) {
|
||||
return Boolean(
|
||||
left &&
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate(),
|
||||
)
|
||||
}
|
||||
|
||||
function getSignedDate(task: SigningTaskRecord) {
|
||||
return parseDate(task.signedAt)
|
||||
}
|
||||
|
||||
function getExpiredDate(task: SigningTaskRecord) {
|
||||
return parseDate(task.expiresAt)
|
||||
}
|
||||
|
||||
function getUpdatedDate(task: SigningTaskRecord) {
|
||||
return parseDate(task.updatedAt) ?? parseDate(task.createdAt)
|
||||
}
|
||||
|
||||
function isWithinPeriod(value: Date | null, today: Date, period: HomeRankingPeriod) {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const start = new Date(today)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
start.setDate(start.getDate() - period + 1)
|
||||
|
||||
const end = new Date(today)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
|
||||
return value >= start && value <= end
|
||||
}
|
||||
|
||||
function isPendingTask(task: SigningTaskRecord) {
|
||||
return task.status === 'pending' || task.status === 'signing'
|
||||
}
|
||||
|
||||
function createLiveOverview(
|
||||
tasks: SigningTaskRecord[],
|
||||
templateCount: number,
|
||||
coveredDepartments: number,
|
||||
rankingPeriod: HomeRankingPeriod,
|
||||
): WorkbenchOverviewResponse {
|
||||
const today = new Date()
|
||||
const signedTasks = tasks.filter((task) => task.status === 'signed')
|
||||
const todaySigned = signedTasks.filter((task) => isSameDay(getSignedDate(task), today)).length
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yesterdaySigned = signedTasks.filter((task) =>
|
||||
isSameDay(getSignedDate(task), yesterday),
|
||||
).length
|
||||
const pendingTasks = tasks.filter(isPendingTask)
|
||||
const todayOverdue = tasks.filter(
|
||||
(task) => task.status === 'expired' && isSameDay(getExpiredDate(task), today),
|
||||
).length
|
||||
|
||||
const trend = Array.from({ length: rankingPeriod }, (_, index) => {
|
||||
const date = new Date(today)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
date.setDate(today.getDate() - rankingPeriod + index + 1)
|
||||
|
||||
return {
|
||||
label: `${date.getMonth() + 1}/${date.getDate()}`,
|
||||
value: signedTasks.filter((task) => isSameDay(getSignedDate(task), date)).length,
|
||||
}
|
||||
})
|
||||
|
||||
const todos = tasks
|
||||
.filter((task) => isPendingTask(task) || task.status === 'expired')
|
||||
.sort((left, right) => {
|
||||
const leftDate = getUpdatedDate(left)?.getTime() ?? 0
|
||||
const rightDate = getUpdatedDate(right)?.getTime() ?? 0
|
||||
return rightDate - leftDate
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((task) => ({
|
||||
id: task.id,
|
||||
patientId: task.patientId,
|
||||
patientName: task.patientName,
|
||||
documentName: task.documentName,
|
||||
department: task.department,
|
||||
status: task.status === 'expired' ? ('overdue' as const) : ('pending' as const),
|
||||
method: task.method,
|
||||
updatedAt: task.updatedAt,
|
||||
}))
|
||||
|
||||
const rankingMap = new Map<string, WorkbenchDocumentRanking>()
|
||||
signedTasks
|
||||
.filter((task) => isWithinPeriod(getSignedDate(task), today, rankingPeriod))
|
||||
.forEach((task) => {
|
||||
const current = rankingMap.get(task.documentId)
|
||||
rankingMap.set(task.documentId, {
|
||||
id: current?.id ?? task.documentId,
|
||||
documentName: task.documentName,
|
||||
department: task.department,
|
||||
signedCount: (current?.signedCount ?? 0) + 1,
|
||||
})
|
||||
})
|
||||
function mapWorkbenchOverview(dto: WorkbenchOverviewResponseDto): WorkbenchOverviewResponse {
|
||||
const summary = dto.summary
|
||||
|
||||
return {
|
||||
summary: {
|
||||
todaySigned,
|
||||
todaySignedChange: todaySigned - yesterdaySigned,
|
||||
pendingPatientSigning: pendingTasks.length,
|
||||
todayOverdue,
|
||||
availableTemplates: templateCount,
|
||||
coveredDepartments,
|
||||
todaySigned: summary?.todaySigned ?? 0,
|
||||
todaySignedChange: summary?.todaySignedChange ?? 0,
|
||||
pendingPatientSigning: summary?.pendingPatientSigning ?? 0,
|
||||
todayOverdue: summary?.todayOverdue ?? 0,
|
||||
availableTemplates: summary?.availableTemplates ?? 0,
|
||||
coveredDepartments: summary?.coveredDepartments ?? 0,
|
||||
},
|
||||
trend,
|
||||
todos,
|
||||
documentRanking: [...rankingMap.values()]
|
||||
.sort((left, right) => right.signedCount - left.signedCount)
|
||||
.slice(0, 10),
|
||||
trend: (dto.trend ?? []).map<WorkbenchTrendPoint>((point) => ({
|
||||
label: point.label || point.date,
|
||||
value: point.signedCount,
|
||||
})),
|
||||
todos: (dto.todos ?? []).map<WorkbenchTodoTask>((task) => ({
|
||||
id: task.taskId,
|
||||
patientId: task.patientId,
|
||||
patientName: task.patientName,
|
||||
documentName: task.documentName,
|
||||
department: task.departmentName,
|
||||
status: mapTodoStatus(task.status),
|
||||
method: mapSigningMethod(task.signMethod),
|
||||
updatedAt: task.updatedAt,
|
||||
})),
|
||||
documentRanking: (dto.documentRanking ?? []).map<WorkbenchDocumentRanking>((item) => ({
|
||||
id: item.templateId,
|
||||
documentName: item.documentName,
|
||||
department: item.departmentName,
|
||||
signedCount: item.signedCount,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkbenchOverview(
|
||||
function getCampusIds() {
|
||||
if (campusIds) {
|
||||
return Promise.resolve(campusIds)
|
||||
}
|
||||
|
||||
if (!campusIdsRequest) {
|
||||
campusIdsRequest = getCampuses()
|
||||
.then((campuses) => {
|
||||
campusIds = new Map(campuses.map((campus) => [campus.name, campus.id]))
|
||||
return campusIds
|
||||
})
|
||||
.finally(() => {
|
||||
campusIdsRequest = null
|
||||
})
|
||||
}
|
||||
|
||||
return campusIdsRequest
|
||||
}
|
||||
|
||||
async function resolveCampusId(campusName: WorkbenchCampus) {
|
||||
const campusId = (await getCampusIds()).get(campusName)
|
||||
|
||||
if (!campusId) {
|
||||
throw new Error('未找到院区:' + campusName)
|
||||
}
|
||||
|
||||
return campusId
|
||||
}
|
||||
|
||||
export async function getWorkbenchOverview(
|
||||
query: WorkbenchOverviewQuery,
|
||||
): Promise<WorkbenchOverviewResponse> {
|
||||
if (useMockData) {
|
||||
const overview = mockCampusOverviews[query.campus] ?? mockCampusOverviews['本部院区']
|
||||
|
||||
return Promise.resolve({
|
||||
return {
|
||||
summary: { ...overview.summary },
|
||||
trend: overview.trend.map((point) => ({ ...point })),
|
||||
todos: overview.todos.map((task) => ({ ...task })),
|
||||
documentRanking: createRanking(overview.ranking, query.rankingPeriod),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return getCampuses().then(async (campuses) => {
|
||||
const campus = campuses.find((item) => item.name === query.campus)
|
||||
const campusId = query.campusId || (await resolveCampusId(query.campus))
|
||||
const params: WorkbenchOverviewRequestDto = {
|
||||
campusId,
|
||||
period: query.rankingPeriod,
|
||||
todoLimit: TODO_LIMIT,
|
||||
rankingLimit: RANKING_LIMIT,
|
||||
}
|
||||
const response = await request.get<ApiResponse<WorkbenchOverviewResponseDto>>(
|
||||
'/v1/workbench/overview',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!campus) {
|
||||
throw new Error(`未找到院区:${query.campus}`)
|
||||
}
|
||||
|
||||
const campusIds = Object.fromEntries(campuses.map((item) => [item.name, item.id]))
|
||||
const campusNames = Object.fromEntries(
|
||||
campuses.filter((item) => isWorkbenchCampus(item.name)).map((item) => [item.id, item.name]),
|
||||
) as Record<string, WorkbenchCampus>
|
||||
const templates = await getSigningTemplates({ campusId: campus.id })
|
||||
const tasks = await getAllSigningTasks(
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
status: 'all',
|
||||
dateRange: 'all',
|
||||
campus: query.campus,
|
||||
method: 'all',
|
||||
},
|
||||
{ templates, campusIds, campusNames },
|
||||
)
|
||||
|
||||
return createLiveOverview(
|
||||
tasks,
|
||||
countAvailableTemplates(templates),
|
||||
countCoveredDepartments(templates),
|
||||
query.rankingPeriod,
|
||||
)
|
||||
})
|
||||
return mapWorkbenchOverview(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import type { ApiResponse, PageQuery, PageResult } from '@/types/common'
|
||||
|
||||
export type WorkbenchCampus = '本部院区' | '东院区' | '西院区'
|
||||
export type WorkbenchCampus = string
|
||||
|
||||
export type HomeRankingPeriod = 7 | 14 | 30
|
||||
|
||||
export interface WorkbenchOverviewQuery {
|
||||
campus: WorkbenchCampus
|
||||
campusId?: string
|
||||
rankingPeriod: HomeRankingPeriod
|
||||
}
|
||||
|
||||
/** /api/v1/workbench/overview 的请求 DTO。 */
|
||||
export interface WorkbenchOverviewRequestDto {
|
||||
campusId: string
|
||||
period: HomeRankingPeriod
|
||||
todoLimit?: number
|
||||
rankingLimit?: number
|
||||
}
|
||||
|
||||
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
|
||||
|
||||
export type PatientSex = '男' | '女' | '未知'
|
||||
@@ -72,7 +81,8 @@ export interface WorkbenchTask {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkbenchTodoStatus = 'pending' | 'overdue'
|
||||
export type WorkbenchTodoStatus =
|
||||
'created' | 'pending' | 'generating' | 'overdue' | 'signed' | 'voided' | 'failed'
|
||||
|
||||
export type SigningMethod = 'pad' | 'sms'
|
||||
|
||||
@@ -106,6 +116,42 @@ export interface WorkbenchOverviewResponse {
|
||||
documentRanking: WorkbenchDocumentRanking[]
|
||||
}
|
||||
|
||||
/** 后端工作台概览原始响应,组件只使用 WorkbenchOverviewResponse。 */
|
||||
export interface WorkbenchOverviewResponseDto {
|
||||
campusId: string
|
||||
timezone: string
|
||||
generatedAt: string
|
||||
summary?: Partial<WorkbenchSummary> | null
|
||||
trend?: WorkbenchOverviewTrendPointDto[] | null
|
||||
todos?: WorkbenchOverviewTodoItemDto[] | null
|
||||
documentRanking?: WorkbenchDocumentRankingDto[] | null
|
||||
}
|
||||
|
||||
export interface WorkbenchOverviewTrendPointDto {
|
||||
date: string
|
||||
label: string
|
||||
signedCount: number
|
||||
}
|
||||
|
||||
export interface WorkbenchOverviewTodoItemDto {
|
||||
taskId: string
|
||||
patientId: string
|
||||
patientName: string
|
||||
documentName: string
|
||||
departmentName: string
|
||||
status: BackendSigningTaskStatus
|
||||
signMethod: BackendSigningMethod
|
||||
updatedAt: string
|
||||
expiredAt?: string | null
|
||||
}
|
||||
|
||||
export interface WorkbenchDocumentRankingDto {
|
||||
templateId: string
|
||||
documentName: string
|
||||
departmentName: string
|
||||
signedCount: number
|
||||
}
|
||||
|
||||
export interface SigningTaskQuery extends PageQuery {
|
||||
keyword?: string
|
||||
status?: SigningTaskStatus | 'all'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -9,6 +9,21 @@ const appStore = useAppStore()
|
||||
|
||||
const pageTitle = computed(() => String(route.meta.title ?? '首页'))
|
||||
const pageGroup = computed(() => String(route.meta.group ?? '工作台'))
|
||||
const selectedCampusId = computed<string>({
|
||||
get: () => appStore.selectedCampusId,
|
||||
set: (id) => appStore.selectCampus(id),
|
||||
})
|
||||
const campusPlaceholder = computed(() => {
|
||||
if (appStore.campusLoading) {
|
||||
return '院区加载中…'
|
||||
}
|
||||
|
||||
return appStore.campusLoadFailed ? '院区加载失败' : '暂无可用院区'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void appStore.loadCampuses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,9 +35,16 @@ const pageGroup = computed(() => String(route.meta.group ?? '工作台'))
|
||||
<span class="header-spacer" />
|
||||
<label class="campus-selector">
|
||||
<span class="campus-label">院区</span>
|
||||
<select v-model="appStore.selectedCampus" aria-label="选择院区">
|
||||
<option v-for="campus in appStore.campuses" :key="campus" :value="campus">
|
||||
{{ campus }}
|
||||
<select
|
||||
v-model="selectedCampusId"
|
||||
:disabled="appStore.campusLoading || !appStore.campusRecords.length"
|
||||
aria-label="选择院区"
|
||||
>
|
||||
<option v-if="!appStore.campusRecords.length" value="">
|
||||
{{ campusPlaceholder }}
|
||||
</option>
|
||||
<option v-for="campus in appStore.campusRecords" :key="campus.id" :value="campus.id">
|
||||
{{ campus.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -1,16 +1,99 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { getCampuses } from '@/api/management/organization'
|
||||
import type { CampusRecord } from '@/api/management/types'
|
||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
|
||||
const mockCampusRecords: CampusRecord[] = [
|
||||
{ id: 'mock-campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||||
{ id: 'mock-campus-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
||||
{ id: 'mock-campus-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
|
||||
]
|
||||
|
||||
function getEnabledCampuses(records: CampusRecord[]) {
|
||||
return [...records]
|
||||
.filter((campus) => campus.status !== 'DISABLED')
|
||||
.sort(
|
||||
(left, right) => left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
|
||||
)
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const environment = ref('接口联调环境')
|
||||
const campuses = ref<WorkbenchCampus[]>(['本部院区', '东院区', '西院区'])
|
||||
const selectedCampus = ref<WorkbenchCampus>('本部院区')
|
||||
const campusRecords = ref<CampusRecord[]>(
|
||||
useMockData ? getEnabledCampuses(mockCampusRecords) : [],
|
||||
)
|
||||
const selectedCampusId = ref(useMockData ? (campusRecords.value[0]?.id ?? '') : '')
|
||||
const campusLoading = ref(false)
|
||||
const campusLoaded = ref(useMockData)
|
||||
const campusLoadFailed = ref(false)
|
||||
let campusRequest: Promise<void> | null = null
|
||||
|
||||
const campuses = computed<WorkbenchCampus[]>(() =>
|
||||
campusRecords.value.map((campus) => campus.name),
|
||||
)
|
||||
const selectedCampus = computed<WorkbenchCampus>(
|
||||
() => campusRecords.value.find((campus) => campus.id === selectedCampusId.value)?.name ?? '',
|
||||
)
|
||||
|
||||
function selectCampus(id: string) {
|
||||
if (campusRecords.value.some((campus) => campus.id === id)) {
|
||||
selectedCampusId.value = id
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSelectedCampus() {
|
||||
if (!campusRecords.value.some((campus) => campus.id === selectedCampusId.value)) {
|
||||
selectedCampusId.value = campusRecords.value[0]?.id ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCampuses(force = false): Promise<void> {
|
||||
if (useMockData) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && campusLoaded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (campusRequest) {
|
||||
return campusRequest
|
||||
}
|
||||
|
||||
campusLoading.value = true
|
||||
campusLoadFailed.value = false
|
||||
const request = getCampuses()
|
||||
.then((records) => {
|
||||
campusRecords.value = getEnabledCampuses(records)
|
||||
ensureSelectedCampus()
|
||||
campusLoaded.value = true
|
||||
})
|
||||
.catch(() => {
|
||||
campusLoadFailed.value = true
|
||||
})
|
||||
.finally(() => {
|
||||
campusLoading.value = false
|
||||
campusRequest = null
|
||||
})
|
||||
|
||||
campusRequest = request
|
||||
return request
|
||||
}
|
||||
|
||||
return {
|
||||
environment,
|
||||
campuses,
|
||||
campusRecords,
|
||||
selectedCampusId,
|
||||
selectedCampus,
|
||||
campusLoading,
|
||||
campusLoaded,
|
||||
campusLoadFailed,
|
||||
loadCampuses,
|
||||
selectCampus,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { HomeTrendPoint } from '../types'
|
||||
import type { HomeRankingPeriod, HomeTrendPoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: HomeTrendPoint[]
|
||||
period: HomeRankingPeriod
|
||||
}>()
|
||||
|
||||
const chartWidth = 620
|
||||
@@ -63,12 +64,17 @@ const gridLines = computed(() =>
|
||||
<template>
|
||||
<section class="home-panel trend-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>近一周签署客户趋势</h2>
|
||||
<span>单位:人次</span>
|
||||
<h2>近{{ period }}天签署趋势</h2>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
|
||||
<div v-if="points.length" class="trend-chart-wrap">
|
||||
<svg class="trend-chart" viewBox="0 0 620 210" role="img" aria-label="近一周签署客户趋势图">
|
||||
<svg
|
||||
class="trend-chart"
|
||||
viewBox="0 0 620 210"
|
||||
role="img"
|
||||
:aria-label="'近' + period + '天签署趋势图'"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="home-trend-area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0e6e8c" stop-opacity="0.22" />
|
||||
|
||||
@@ -11,11 +11,22 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
function getStatusLabel(item: HomeTodoItem) {
|
||||
if (item.status === 'overdue') {
|
||||
return '已超时'
|
||||
switch (item.status) {
|
||||
case 'created':
|
||||
return '待发送'
|
||||
case 'pending':
|
||||
return item.method === 'pad' ? '待签署·手写板' : '待签署·短信'
|
||||
case 'generating':
|
||||
return '文书生成中'
|
||||
case 'overdue':
|
||||
return '已超时'
|
||||
case 'signed':
|
||||
return '已签署'
|
||||
case 'voided':
|
||||
return '已作废'
|
||||
case 'failed':
|
||||
return '处理失败'
|
||||
}
|
||||
|
||||
return item.method === 'pad' ? '待签署·手写板' : '待签署·短信'
|
||||
}
|
||||
|
||||
function getStatusClass(item: HomeTodoItem) {
|
||||
@@ -23,11 +34,38 @@ function getStatusClass(item: HomeTodoItem) {
|
||||
return 'todo-status--overdue'
|
||||
}
|
||||
|
||||
return item.method === 'pad' ? 'todo-status--pad' : 'todo-status--sms'
|
||||
if (item.status === 'pending') {
|
||||
return item.method === 'pad' ? 'todo-status--pad' : 'todo-status--sms'
|
||||
}
|
||||
|
||||
if (item.status === 'signed') {
|
||||
return 'todo-status--success'
|
||||
}
|
||||
|
||||
if (item.status === 'failed') {
|
||||
return 'todo-status--danger'
|
||||
}
|
||||
|
||||
return 'todo-status--muted'
|
||||
}
|
||||
|
||||
function getHint(item: HomeTodoItem) {
|
||||
return item.status === 'overdue' ? '⚠ 已超时,需重新发起' : '⏳ 等待患者签署'
|
||||
switch (item.status) {
|
||||
case 'created':
|
||||
return '⏳ 等待发送'
|
||||
case 'pending':
|
||||
return '⏳ 等待患者签署'
|
||||
case 'generating':
|
||||
return '⌛ 正在生成签署文书'
|
||||
case 'overdue':
|
||||
return '⚠ 已超时,需重新发起'
|
||||
case 'signed':
|
||||
return '✓ 已完成签署'
|
||||
case 'voided':
|
||||
return '— 任务已作废'
|
||||
case 'failed':
|
||||
return '⚠ 任务处理失败'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -170,11 +208,22 @@ function getHint(item: HomeTodoItem) {
|
||||
background: #f3ecfb;
|
||||
}
|
||||
|
||||
.todo-status--overdue {
|
||||
.todo-status--overdue,
|
||||
.todo-status--danger {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
}
|
||||
|
||||
.todo-status--success {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.todo-status--muted {
|
||||
color: var(--mut);
|
||||
background: #eef3f5;
|
||||
}
|
||||
|
||||
.todo-row--detail {
|
||||
margin-top: 5px;
|
||||
color: var(--mut);
|
||||
|
||||
@@ -89,8 +89,16 @@ async function loadOverview() {
|
||||
loadFailed.value = false
|
||||
|
||||
try {
|
||||
await appStore.loadCampuses()
|
||||
const campusId = appStore.selectedCampusId
|
||||
|
||||
if (!campusId) {
|
||||
throw new Error('当前用户没有可用院区')
|
||||
}
|
||||
|
||||
const nextOverview = await getWorkbenchOverview({
|
||||
campus: selectedCampus.value,
|
||||
campusId,
|
||||
rankingPeriod: rankingPeriod.value,
|
||||
})
|
||||
if (requestId !== overviewRequestId) {
|
||||
@@ -189,7 +197,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<SigningTrendPanel :points="trendPoints" />
|
||||
<SigningTrendPanel :points="trendPoints" :period="rankingPeriod" />
|
||||
<TodoPanel :items="todoItems" @select="handleTodoSelect" @view-all="goToSigning()" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ export interface HomeTrendPoint {
|
||||
value: number
|
||||
}
|
||||
|
||||
export type HomeTodoStatus = 'pending' | 'overdue'
|
||||
export type HomeTodoStatus =
|
||||
'created' | 'pending' | 'generating' | 'overdue' | 'signed' | 'voided' | 'failed'
|
||||
|
||||
export type HomeSigningMethod = 'pad' | 'sms'
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ const STATUS_VALUES: SigningTaskStatus[] = [
|
||||
'failed',
|
||||
]
|
||||
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
|
||||
const CAMPUS_VALUES: WorkbenchCampus[] = ['本部院区', '东院区', '西院区']
|
||||
|
||||
const statusLabels: Record<SigningTaskStatus, string> = {
|
||||
pending: '待签署',
|
||||
@@ -165,7 +164,7 @@ function isSigningDateRange(value: string): value is SigningDateRange {
|
||||
}
|
||||
|
||||
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||
return CAMPUS_VALUES.includes(value as WorkbenchCampus)
|
||||
return appStore.campuses.includes(value)
|
||||
}
|
||||
|
||||
function syncFilterFromRoute() {
|
||||
@@ -201,12 +200,7 @@ async function loadOrganizationOptions() {
|
||||
campusesResult.value.map((campus) => [campus.name, campus.id]),
|
||||
)
|
||||
campusNames.value = Object.fromEntries(
|
||||
campusesResult.value
|
||||
.filter(
|
||||
(campus): campus is typeof campus & { name: WorkbenchCampus } =>
|
||||
campus.name === '本部院区' || campus.name === '东院区' || campus.name === '西院区',
|
||||
)
|
||||
.map((campus) => [campus.id, campus.name]),
|
||||
campusesResult.value.map((campus) => [campus.id, campus.name]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -558,6 +552,8 @@ async function handleOnlineResend() {
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
await appStore.loadCampuses()
|
||||
syncFilterFromRoute()
|
||||
await loadOrganizationOptions()
|
||||
await loadTemplates()
|
||||
await loadTasks()
|
||||
@@ -589,7 +585,6 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
syncFilterFromRoute()
|
||||
void initializePage()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user