feat(clinical-web): 添加管理端与工作台 API 模块及类型定义

This commit is contained in:
yelan
2026-08-27 15:35:36 +08:00
parent c89cc6f95b
commit 24d8a9ece1
22 changed files with 797 additions and 17 deletions

View File

@@ -1,2 +1,3 @@
VITE_APP_NAME=medical-sign-clinical VITE_APP_NAME=medical-sign-clinical
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
VITE_USE_MOCK=true

View File

@@ -107,3 +107,19 @@ ClinicalLayout
布局拆分为 `Menu``Header``Container` 三个公共组件,页面内容由各自的 View 负责。 布局拆分为 `Menu``Header``Container` 三个公共组件,页面内容由各自的 View 负责。
页面目录按业务域组织:`auth` 保留登录页;工作台页面位于 `views/workbench`;管理页面位于 `views/management`。每个具体页面目录都有 `index.vue` 入口和 `components` 目录,用于继续拆分当前页面组件。 页面目录按业务域组织:`auth` 保留登录页;工作台页面位于 `views/workbench`;管理页面位于 `views/management`。每个具体页面目录都有 `index.vue` 入口和 `components` 目录,用于继续拆分当前页面组件。
## API 与类型约定
API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 下的页面文件,管理页面使用 `api/management` 下的直接文件:
- `api/workbench/home.ts`
- `api/workbench/signing.ts`
- `api/management/documents.ts`
- `api/management/reports.ts`
- `api/management/users.ts`
- `api/management/document-permissions.ts`
- `api/management/settings.ts`
API 请求和响应类型按业务域放在 `api/workbench/types.ts``api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。当前 API 模块默认返回 mock 数据,设置 `VITE_USE_MOCK=false` 后切换为真实接口。

View File

@@ -1,6 +0,0 @@
import axios from 'axios'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10_000,
})

View File

@@ -0,0 +1,53 @@
import { request } from '@/utils/request'
import type { PermissionListResponse, PermissionQuery, PermissionRecord } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockRecords: PermissionRecord[] = [
{
id: 'permission-001',
subjectType: 'role',
subjectName: '医生',
documentName: '住院患者知情同意书',
permission: 'sign',
updatedAt: '2026-08-26 16:40',
},
{
id: 'permission-002',
subjectType: 'role',
subjectName: '护士',
documentName: '急诊留观知情同意书',
permission: 'view',
updatedAt: '2026-08-25 11:08',
},
]
export function getDocumentPermissions(query: PermissionQuery): Promise<PermissionListResponse> {
if (!useMockData) {
return request.get<PermissionListResponse>('/management/document-permissions', {
params: query,
})
}
const keyword = query.keyword?.trim().toLowerCase()
const filteredRecords = mockRecords.filter((record) => {
const matchesKeyword =
!keyword ||
[record.subjectName, record.documentName].join(' ').toLowerCase().includes(keyword)
const matchesSubjectType =
!query.subjectType || query.subjectType === 'all' || record.subjectType === query.subjectType
return matchesKeyword && matchesSubjectType
})
const page = Math.max(query.page, 1)
const pageSize = Math.max(query.pageSize, 1)
const start = (page - 1) * pageSize
return Promise.resolve({
records: filteredRecords.slice(start, start + pageSize),
total: filteredRecords.length,
page,
pageSize,
})
}

View File

@@ -0,0 +1,60 @@
import { request } from '@/utils/request'
import type { DocumentListResponse, DocumentQuery, DocumentRecord } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockRecords: DocumentRecord[] = [
{
id: 'doc-001',
name: '住院患者知情同意书',
category: '住院',
version: 'V2.1',
status: 'published',
updatedBy: '张文静',
updatedAt: '2026-08-26 17:20',
},
{
id: 'doc-002',
name: '急诊留观知情同意书',
category: '急诊',
version: 'V1.3',
status: 'published',
updatedBy: '张文静',
updatedAt: '2026-08-25 10:15',
},
]
export function getDocuments(query: DocumentQuery): Promise<DocumentListResponse> {
if (!useMockData) {
return request.get<DocumentListResponse>('/management/documents', {
params: query,
})
}
const keyword = query.keyword?.trim().toLowerCase()
const filteredRecords = mockRecords.filter((record) => {
const matchesKeyword = !keyword || record.name.toLowerCase().includes(keyword)
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
return matchesKeyword && matchesStatus
})
const page = Math.max(query.page, 1)
const pageSize = Math.max(query.pageSize, 1)
const start = (page - 1) * pageSize
return Promise.resolve({
records: filteredRecords.slice(start, start + pageSize),
total: filteredRecords.length,
page,
pageSize,
})
}
export function getDocumentDetail(id: string): Promise<DocumentRecord | null> {
if (!useMockData) {
return request.get<DocumentRecord>('/management/documents/' + id)
}
return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null)
}

View File

@@ -0,0 +1,38 @@
import { request } from '@/utils/request'
import type { ReportOverviewResponse, ReportQuery } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockOverview: ReportOverviewResponse = {
metrics: [
{ key: 'total', label: '签署总量', value: 1268, unit: '份', trend: 12.8 },
{ key: 'signed', label: '已完成', value: 1142, unit: '份', trend: 8.6 },
{ key: 'rate', label: '完成率', value: 90.1, unit: '%', trend: 3.2 },
{ key: 'average', label: '平均耗时', value: 6.4, unit: '分钟', trend: -4.5 },
],
trend: [
{ date: '08-21', signed: 146, rejected: 5, pending: 12 },
{ date: '08-22', signed: 158, rejected: 4, pending: 10 },
{ date: '08-23', signed: 132, rejected: 7, pending: 15 },
{ date: '08-24', signed: 176, rejected: 3, pending: 9 },
{ date: '08-25', signed: 188, rejected: 6, pending: 13 },
{ date: '08-26', signed: 172, rejected: 5, pending: 11 },
{ date: '08-27', signed: 170, rejected: 4, pending: 8 },
],
departments: [
{ department: '消化内科', total: 328, signed: 305, completionRate: 93.0 },
{ department: '儿科急诊', total: 286, signed: 249, completionRate: 87.1 },
{ department: '普外科', total: 264, signed: 243, completionRate: 92.0 },
],
}
export function getReportOverview(query: ReportQuery): Promise<ReportOverviewResponse> {
if (useMockData) {
return Promise.resolve(mockOverview)
}
return request.get<ReportOverviewResponse>('/management/reports/overview', {
params: query,
})
}

View File

@@ -0,0 +1,59 @@
import { request } from '@/utils/request'
import type { SystemSettingsResponse, UpdateSystemSettingsRequest } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockSettings: SystemSettingsResponse = {
sections: [
{
key: 'signature',
label: '签署设置',
items: [
{
key: 'linkExpireMinutes',
label: '手机签署链接有效期',
description: '超过有效期后,患者需要重新获取签署链接。',
value: 30,
valueType: 'number',
},
{
key: 'allowTablet',
label: '允许平板端签署',
description: '开启后可以在医院平板设备上发起现场签署。',
value: true,
valueType: 'boolean',
},
],
},
{
key: 'notification',
label: '通知设置',
items: [
{
key: 'notifyOnFailure',
label: '回传失败提醒',
description: '电子病历回传失败时通知相关管理人员。',
value: true,
valueType: 'boolean',
},
],
},
],
}
export function getSystemSettings(): Promise<SystemSettingsResponse> {
if (useMockData) {
return Promise.resolve(mockSettings)
}
return request.get<SystemSettingsResponse>('/management/settings')
}
export function updateSystemSettings(payload: UpdateSystemSettingsRequest): Promise<void> {
if (useMockData) {
return Promise.resolve()
}
return request.put<void>('/management/settings', payload)
}

View File

@@ -0,0 +1,117 @@
import type { PageQuery, PageResult } from '@/types/common'
export type DocumentStatus = 'draft' | 'published' | 'archived'
export interface DocumentQuery extends PageQuery {
keyword?: string
status?: DocumentStatus | 'all'
}
export interface DocumentRecord {
id: string
name: string
category: string
version: string
status: DocumentStatus
updatedBy: string
updatedAt: string
}
export type DocumentListResponse = PageResult<DocumentRecord>
export type ReportPeriod = 'today' | 'week' | 'month' | 'year'
export interface ReportQuery {
period: ReportPeriod
department?: string
}
export interface ReportMetric {
key: string
label: string
value: number
unit: string
trend: number
}
export interface ReportTrendPoint {
date: string
signed: number
rejected: number
pending: number
}
export interface ReportDepartment {
department: string
total: number
signed: number
completionRate: number
}
export interface ReportOverviewResponse {
metrics: ReportMetric[]
trend: ReportTrendPoint[]
departments: ReportDepartment[]
}
export type UserStatus = 'enabled' | 'disabled'
export interface UserQuery extends PageQuery {
keyword?: string
department?: string
status?: UserStatus | 'all'
}
export interface UserRecord {
id: string
name: string
account: string
department: string
role: string
status: UserStatus
lastLoginAt: string
}
export type UserListResponse = PageResult<UserRecord>
export type PermissionSubjectType = 'user' | 'role'
export interface PermissionQuery extends PageQuery {
keyword?: string
subjectType?: PermissionSubjectType | 'all'
}
export interface PermissionRecord {
id: string
subjectType: PermissionSubjectType
subjectName: string
documentName: string
permission: 'view' | 'sign' | 'manage'
updatedAt: string
}
export type PermissionListResponse = PageResult<PermissionRecord>
export type SettingValue = string | number | boolean
export interface SystemSettingRecord {
key: string
label: string
description: string
value: SettingValue
valueType: 'string' | 'number' | 'boolean'
}
export interface SystemSettingSection {
key: string
label: string
items: SystemSettingRecord[]
}
export interface SystemSettingsResponse {
sections: SystemSettingSection[]
}
export interface UpdateSystemSettingsRequest {
values: Record<string, SettingValue>
}

View File

@@ -0,0 +1,63 @@
import { request } from '@/utils/request'
import type { UserListResponse, UserQuery, UserRecord } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockRecords: UserRecord[] = [
{
id: 'user-001',
name: '张文静',
account: 'zhangwenjing',
department: '医务管理',
role: '系统管理员',
status: 'enabled',
lastLoginAt: '2026-08-27 08:12',
},
{
id: 'user-002',
name: '王医生',
account: 'wangdoctor',
department: '消化内科',
role: '医生',
status: 'enabled',
lastLoginAt: '2026-08-27 08:35',
},
{
id: 'user-003',
name: '李护士',
account: 'linurse',
department: '儿科急诊',
role: '护士',
status: 'disabled',
lastLoginAt: '2026-08-20 14:26',
},
]
export function getUsers(query: UserQuery): Promise<UserListResponse> {
if (!useMockData) {
return request.get<UserListResponse>('/management/users', {
params: query,
})
}
const keyword = query.keyword?.trim().toLowerCase()
const filteredRecords = mockRecords.filter((record) => {
const matchesKeyword =
!keyword || [record.name, record.account].join(' ').toLowerCase().includes(keyword)
const matchesDepartment = !query.department || record.department === query.department
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
return matchesKeyword && matchesDepartment && matchesStatus
})
const page = Math.max(query.page, 1)
const pageSize = Math.max(query.pageSize, 1)
const start = (page - 1) * pageSize
return Promise.resolve({
records: filteredRecords.slice(start, start + pageSize),
total: filteredRecords.length,
page,
pageSize,
})
}

View File

@@ -0,0 +1,42 @@
import { request } from '@/utils/request'
import type { WorkbenchOverviewResponse } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockOverview: WorkbenchOverviewResponse = {
summary: {
pendingTasks: 18,
todaySigned: 36,
abnormalTasks: 2,
documentCount: 128,
},
recentTasks: [
{
id: 'task-001',
patientName: '李某某',
visitNo: 'ZY20260827001',
documentName: '住院患者知情同意书',
department: '消化内科',
status: 'pending',
updatedAt: '2026-08-27 09:30',
},
{
id: 'task-002',
patientName: '王某某',
visitNo: 'JZ20260827018',
documentName: '急诊留观知情同意书',
department: '儿科急诊',
status: 'signed',
updatedAt: '2026-08-27 09:12',
},
],
}
export function getWorkbenchOverview(): Promise<WorkbenchOverviewResponse> {
if (useMockData) {
return Promise.resolve(mockOverview)
}
return request.get<WorkbenchOverviewResponse>('/workbench/overview')
}

View File

@@ -0,0 +1,83 @@
import { request } from '@/utils/request'
import type { SigningTaskListResponse, SigningTaskQuery, SigningTaskRecord } from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockRecords: SigningTaskRecord[] = [
{
id: 'task-001',
patientName: '李某某',
visitNo: 'ZY20260827001',
documentName: '住院患者知情同意书',
department: '消化内科',
status: 'pending',
signerName: '李某某',
source: '电子病历',
expiresAt: '2026-08-28 09:30',
updatedAt: '2026-08-27 09:30',
},
{
id: 'task-002',
patientName: '王某某',
visitNo: 'JZ20260827018',
documentName: '急诊留观知情同意书',
department: '儿科急诊',
status: 'signed',
signerName: '王某某家属',
source: '工作台发起',
expiresAt: '2026-08-27 18:00',
updatedAt: '2026-08-27 09:12',
},
{
id: 'task-003',
patientName: '赵某某',
visitNo: 'ZY20260826027',
documentName: '内镜检查知情同意书',
department: '消化内科',
status: 'rejected',
signerName: '赵某某家属',
source: '电子病历',
expiresAt: '2026-08-27 16:00',
updatedAt: '2026-08-27 08:45',
},
]
export function getSigningTasks(query: SigningTaskQuery): Promise<SigningTaskListResponse> {
if (!useMockData) {
return request.get<SigningTaskListResponse>('/workbench/signing/tasks', {
params: query,
})
}
const keyword = query.keyword?.trim().toLowerCase()
const filteredRecords = mockRecords.filter((record) => {
const matchesKeyword =
!keyword ||
[record.patientName, record.visitNo, record.documentName]
.join(' ')
.toLowerCase()
.includes(keyword)
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
return matchesKeyword && matchesStatus
})
const page = Math.max(query.page, 1)
const pageSize = Math.max(query.pageSize, 1)
const start = (page - 1) * pageSize
return Promise.resolve({
records: filteredRecords.slice(start, start + pageSize),
total: filteredRecords.length,
page,
pageSize,
})
}
export function getSigningTaskDetail(id: string): Promise<SigningTaskRecord | null> {
if (!useMockData) {
return request.get<SigningTaskRecord>('/workbench/signing/tasks/' + id)
}
return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null)
}

View File

@@ -0,0 +1,38 @@
import type { PageQuery, PageResult } from '@/types/common'
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired'
export interface WorkbenchSummary {
pendingTasks: number
todaySigned: number
abnormalTasks: number
documentCount: number
}
export interface WorkbenchTask {
id: string
patientName: string
visitNo: string
documentName: string
department: string
status: SigningTaskStatus
updatedAt: string
}
export interface WorkbenchOverviewResponse {
summary: WorkbenchSummary
recentTasks: WorkbenchTask[]
}
export interface SigningTaskQuery extends PageQuery {
keyword?: string
status?: SigningTaskStatus | 'all'
}
export interface SigningTaskRecord extends WorkbenchTask {
signerName: string
source: string
expiresAt: string
}
export type SigningTaskListResponse = PageResult<SigningTaskRecord>

View File

@@ -0,0 +1,29 @@
export type ID = string
export interface PageQuery {
page: number
pageSize: number
}
export interface PageResult<T> {
records: T[]
total: number
page: number
pageSize: number
}
export interface ApiResponse<T> {
code: number
message: string
data: T
}
export interface SelectOption<T extends string = string> {
label: string
value: T
}
export interface DateRange {
startDate: string
endDate: string
}

View File

@@ -1,11 +0,0 @@
export type ConsentTaskStatus = 'pending' | 'signed' | 'rejected' | 'expired'
export interface ConsentTask {
id: string
patientName: string
visitNo: string
documentName: string
source: string
status: ConsentTaskStatus
updatedAt: string
}

View File

@@ -0,0 +1,77 @@
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
const DEFAULT_TIMEOUT = 15_000
class Request {
private readonly instance: AxiosInstance
constructor(baseURL = import.meta.env.VITE_API_BASE_URL || '/api', timeout = DEFAULT_TIMEOUT) {
this.instance = axios.create({
baseURL,
timeout,
withCredentials: true,
})
this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.set('Authorization', 'Bearer ' + token)
}
return config
})
this.instance.interceptors.response.use(
(response: AxiosResponse) => response,
(error: unknown) => Promise.reject(error),
)
}
request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
return this.instance.request<T, AxiosResponse<T>>(config).then((response) => response.data)
}
get<T = unknown>(url: string, config: AxiosRequestConfig = {}): Promise<T> {
return this.request<T>({
...config,
url,
method: 'GET',
})
}
post<T = unknown>(url: string, data?: unknown, config: AxiosRequestConfig = {}): Promise<T> {
return this.request<T>({
...config,
url,
data,
method: 'POST',
})
}
put<T = unknown>(url: string, data?: unknown, config: AxiosRequestConfig = {}): Promise<T> {
return this.request<T>({
...config,
url,
data,
method: 'PUT',
})
}
delete<T = unknown>(url: string, config: AxiosRequestConfig = {}): Promise<T> {
return this.request<T>({
...config,
url,
method: 'DELETE',
})
}
}
export const request = new Request()
export default request

View File

@@ -0,0 +1,15 @@
export type PermissionSubjectFilter = 'all' | 'user' | 'role'
export interface PermissionFilterForm {
keyword: string
subjectType: PermissionSubjectFilter
}
export interface PermissionTableRow {
id: string
subjectType: PermissionSubjectFilter
subjectName: string
documentName: string
permission: 'view' | 'sign' | 'manage'
updatedAt: string
}

View File

@@ -0,0 +1,16 @@
export type DocumentFilterStatus = 'all' | 'draft' | 'published' | 'archived'
export interface DocumentFilterForm {
keyword: string
status: DocumentFilterStatus
}
export interface DocumentTableRow {
id: string
name: string
category: string
version: string
status: DocumentFilterStatus
updatedBy: string
updatedAt: string
}

View File

@@ -0,0 +1,21 @@
export type ReportPeriodOption = 'today' | 'week' | 'month' | 'year'
export interface ReportFilterForm {
period: ReportPeriodOption
department: string
}
export interface ReportMetricCard {
key: string
label: string
value: number
unit: string
trend: number
}
export interface ReportChartPoint {
date: string
signed: number
rejected: number
pending: number
}

View File

@@ -0,0 +1,15 @@
export type SettingValue = string | number | boolean
export interface SettingFormItem {
key: string
label: string
description: string
value: SettingValue
valueType: 'string' | 'number' | 'boolean'
}
export interface SettingSection {
key: string
label: string
items: SettingFormItem[]
}

View File

@@ -0,0 +1,17 @@
export type UserFilterStatus = 'all' | 'enabled' | 'disabled'
export interface UserFilterForm {
keyword: string
department: string
status: UserFilterStatus
}
export interface UserTableRow {
id: string
name: string
account: string
department: string
role: string
status: UserFilterStatus
lastLoginAt: string
}

View File

@@ -0,0 +1,19 @@
export type HomeMetricTone = 'brand' | 'success' | 'warning' | 'danger'
export interface HomeMetricCard {
key: string
label: string
value: number
unit: string
tone: HomeMetricTone
description?: string
}
export interface HomeTaskItem {
id: string
title: string
patientName: string
documentName: string
status: string
updatedAt: string
}

View File

@@ -0,0 +1,18 @@
export type SigningFilterStatus = 'all' | 'pending' | 'signing' | 'signed' | 'rejected' | 'expired'
export interface SigningFilterForm {
keyword: string
status: SigningFilterStatus
dateRange: [string, string] | null
}
export interface SigningTableRow {
id: string
patientName: string
visitNo: string
documentName: string
department: string
signerName: string
status: SigningFilterStatus
updatedAt: string
}