diff --git a/clinical-web/src/api/management/audit.ts b/clinical-web/src/api/management/audit.ts new file mode 100644 index 0000000..e8da6ed --- /dev/null +++ b/clinical-web/src/api/management/audit.ts @@ -0,0 +1,82 @@ +import { unwrapApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' +import type { ApiResponse, PageResult } from '@/types/common' + +import type { + AuditLogListResponse, + AuditLogQuery, + AuditLogRecord, + AuditLogResponseDto, + BackendCollection, + BackendPage, +} from './types' + +const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' + +function formatDateTime(value: string) { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + .replace('T', ' ') + .replace(/\.\d+Z$/, '') + .replace(/Z$/, '') + } + + const pad = (part: number) => String(part).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}` +} + +function mapAuditLog(dto: AuditLogResponseDto): AuditLogRecord { + return { + id: dto.id, + action: dto.action, + resourceType: dto.resourceType, + resourceId: dto.resourceId, + operatorId: dto.operatorId, + clientIp: dto.clientIp ?? '—', + userAgent: dto.userAgent ?? '—', + detailsJson: dto.detailsJson ?? '', + createdAt: formatDateTime(dto.createdAt), + } +} + +function normalizePage( + data: BackendPage | T[], + fallbackPage: number, + fallbackPageSize: number, +): PageResult { + const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) + + return { + records, + total: Array.isArray(data) ? records.length : (data.total ?? records.length), + page: Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage), + pageSize: Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize), + } +} + +export function getAuditLogs(query: AuditLogQuery): Promise { + if (useMockData) { + return Promise.resolve({ records: [], total: 0, page: query.page, pageSize: query.pageSize }) + } + + const params: Record = { + page: Math.max(query.page, 1), + size: Math.min(Math.max(query.pageSize, 1), 200), + } + + if (query.keyword?.trim()) { + params.keyword = query.keyword.trim() + } + + return request + .get>>('/v1/audit-logs', { params }) + .then(unwrapApiResponse) + .then((data) => { + const page = normalizePage(data, query.page, query.pageSize) + return { ...page, records: page.records.map(mapAuditLog) } + }) +} diff --git a/clinical-web/src/api/management/document-permissions.ts b/clinical-web/src/api/management/document-permissions.ts index 072d229..05b5a8b 100644 --- a/clinical-web/src/api/management/document-permissions.ts +++ b/clinical-web/src/api/management/document-permissions.ts @@ -1,9 +1,21 @@ +import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response' import { request } from '@/utils/request' +import type { ApiResponse } from '@/types/common' -import type { PermissionListResponse, PermissionQuery, PermissionRecord } from './types' +import type { + BackendCollection, + CreateTemplatePermissionRequest, + PermissionListResponse, + PermissionQuery, + PermissionRecord, + TemplatePermissionRecord, + TemplatePermissionResponseDto, +} from './types' const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' +export const isPermissionMockEnabled = useMockData + const mockRecords: PermissionRecord[] = [ { id: 'permission-001', @@ -25,9 +37,9 @@ const mockRecords: PermissionRecord[] = [ export function getDocumentPermissions(query: PermissionQuery): Promise { if (!useMockData) { - return request.get('/management/document-permissions', { - params: query, - }) + return Promise.reject( + new Error('MEDISIGN 未提供跨模板权限汇总接口,请使用 getTemplatePermissions'), + ) } const keyword = query.keyword?.trim().toLowerCase() @@ -51,3 +63,80 @@ export function getDocumentPermissions(query: PermissionQuery): Promise String(part).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}` +} + +function mapTemplatePermission(dto: TemplatePermissionResponseDto): TemplatePermissionRecord { + return { + id: dto.id, + templateId: dto.templateId, + subjectType: dto.subjectType, + subjectId: dto.subjectId, + subjectName: dto.subjectId, + permissionLevel: dto.permissionLevel, + effect: dto.effect, + inherited: dto.inherited, + createdAt: formatDateTime(dto.createdAt), + } +} + +export async function getTemplatePermissions( + templateId: string, + userId?: string, +): Promise { + if (useMockData) { + return [] + } + + const response = await request.get>>( + `/v1/templates/${encodeURIComponent(templateId)}/permissions`, + { params: userId ? { userId } : undefined }, + ) + const data = unwrapApiResponse(response) + const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) + return records.map(mapTemplatePermission) +} + +export async function createTemplatePermission( + templateId: string, + payload: CreateTemplatePermissionRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行权限写操作') + } + + const response = await request.post>( + `/v1/templates/${encodeURIComponent(templateId)}/permissions`, + payload, + ) + return mapTemplatePermission(unwrapApiResponse(response)) +} + +export async function deleteTemplatePermission(templateId: string, permissionId: string) { + if (useMockData) { + throw new Error('Mock 模式不执行权限写操作') + } + + const response = await request.delete>( + `/v1/templates/${encodeURIComponent(templateId)}/permissions/${encodeURIComponent(permissionId)}`, + ) + unwrapNullableApiResponse(response) +} diff --git a/clinical-web/src/api/management/documents.ts b/clinical-web/src/api/management/documents.ts index ed29dde..255b4b2 100644 --- a/clinical-web/src/api/management/documents.ts +++ b/clinical-web/src/api/management/documents.ts @@ -1,9 +1,26 @@ -import { request } from '@/utils/request' +import axios from 'axios' -import type { DocumentListResponse, DocumentQuery, DocumentRecord } from './types' +import { unwrapApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' +import type { ApiResponse, PageResult } from '@/types/common' + +import type { + BackendCollection, + BackendPage, + BackendTemplateStatus, + CreateTemplateRequest, + CreateTemplateVersionRequest, + DocumentListResponse, + DocumentQuery, + DocumentRecord, + TemplateResponseDto, + TemplateVersionResponseDto, +} from './types' const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' +export const isDocumentMockEnabled = useMockData + const mockRecords: DocumentRecord[] = [ { id: 'doc-001', @@ -25,11 +42,119 @@ const mockRecords: DocumentRecord[] = [ }, ] +function formatDateTime(value: string | null | undefined) { + if (!value) { + return '' + } + + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + .replace('T', ' ') + .replace(/\.\d+Z$/, '') + .replace(/Z$/, '') + } + + const pad = (part: number) => String(part).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}` +} + +function normalizeDocumentStatus(status: BackendTemplateStatus | null | undefined) { + switch (status) { + case 'PUBLISHED': + case 'APPROVED': + return 'published' as const + case 'ARCHIVED': + case 'DISABLED': + return 'archived' as const + case 'PENDING_REVIEW': + case 'REJECTED': + return 'review' as const + case 'DRAFT': + default: + return 'draft' as const + } +} + +function mapDocument(dto: TemplateResponseDto): DocumentRecord { + const versionId = dto.currentVersionId ?? undefined + const version = dto.versionSequence ? `v${dto.versionSequence}` : '—' + + return { + id: dto.id, + name: dto.name, + code: dto.templateCode, + category: dto.category ?? '未分类', + version: String(version), + status: normalizeDocumentStatus(dto.status), + backendStatus: dto.status ?? undefined, + updatedBy: dto.updatedBy ?? dto.createdBy ?? '—', + updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt), + versionId, + departmentId: dto.departmentId, + departmentName: dto.departmentId ? undefined : '全院通用', + campusId: dto.campusId, + description: dto.description ?? '', + } +} + +function normalizePage( + data: BackendPage | T[], + fallbackPage: number, + fallbackPageSize: number, +): PageResult { + const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) + const page = Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage) + const pageSize = Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize) + + return { + records, + total: Array.isArray(data) ? records.length : (data.total ?? records.length), + page, + pageSize, + } +} + +function toBackendQuery(query: DocumentQuery) { + const params: Record = { + page: Math.max(query.page, 1), + size: Math.min(Math.max(query.pageSize, 1), 200), + } + + if (query.keyword?.trim()) { + params.keyword = query.keyword.trim() + } + + if (query.status && query.status !== 'all') { + const statusMap: Record, string> = { + draft: 'DRAFT', + published: 'PUBLISHED', + archived: 'ARCHIVED', + review: 'PENDING_REVIEW', + } + params.status = statusMap[query.status] + } + + return params +} + export function getDocuments(query: DocumentQuery): Promise { if (!useMockData) { - return request.get('/management/documents', { - params: query, - }) + return request + .get>>('/v1/templates', { + params: toBackendQuery(query), + }) + .then((response) => { + const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize) + + return { + ...page, + records: page.records.map(mapDocument), + } + }) } const keyword = query.keyword?.trim().toLowerCase() @@ -44,17 +169,111 @@ export function getDocuments(query: DocumentQuery): Promise ({ ...record })), total: filteredRecords.length, page, pageSize, }) } -export function getDocumentDetail(id: string): Promise { - if (!useMockData) { - return request.get('/management/documents/' + id) +export async function getDocumentDetail(id: string): Promise { + if (useMockData) { + const record = mockRecords.find((item) => item.id === id) + return record ? { ...record } : null } - return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null) + try { + const response = await request.get>( + `/v1/templates/${encodeURIComponent(id)}`, + ) + return mapDocument(unwrapApiResponse(response)) + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null + } + + throw error + } +} + +export async function createTemplate(payload: CreateTemplateRequest): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行模板写操作') + } + + const response = await request.post>('/v1/templates', payload) + return mapDocument(unwrapApiResponse(response)) +} + +export async function createTemplateVersion( + templateId: string, + payload: CreateTemplateVersionRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行模板写操作') + } + + const response = await request.post>( + `/v1/templates/${encodeURIComponent(templateId)}/versions`, + payload, + ) + return unwrapApiResponse(response) +} + +export async function getTemplateVersions( + templateId: string, +): Promise { + if (useMockData) { + return [] + } + + const response = await request.get>>( + `/v1/templates/${encodeURIComponent(templateId)}/versions`, + ) + return normalizePage(unwrapApiResponse(response), 1, 200).records +} + +export async function getTemplateVersionDetail( + templateId: string, + versionId: string, +): Promise { + if (useMockData) { + return null + } + + try { + const response = await request.get>( + `/v1/templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(versionId)}`, + ) + return unwrapApiResponse(response) + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null + } + + throw error + } +} + +export type TemplateVersionAction = + 'submit-review' | 'reject' | 'approve' | 'publish' | 'disable' | 'archive' + +export function updateTemplateVersionStatus( + templateId: string, + versionId: string, + action: TemplateVersionAction, + reason?: string, +): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行模板版本工作流')) + } + + const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined + + return request + .post>( + `/v1/templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(versionId)}/${action}`, + requestData, + ) + .then(unwrapApiResponse) } diff --git a/clinical-web/src/api/management/organization.ts b/clinical-web/src/api/management/organization.ts new file mode 100644 index 0000000..6b6e3e0 --- /dev/null +++ b/clinical-web/src/api/management/organization.ts @@ -0,0 +1,159 @@ +import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' +import type { ApiResponse } from '@/types/common' + +import type { + BackendCollection, + CampusRecord, + CreateCampusRequest, + CreateDepartmentRequest, + DepartmentRecord, + OrganizationResponseDto, + UpdateCampusRequest, + UpdateDepartmentRequest, +} from './types' + +const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' + +function getRecords(data: BackendCollection) { + return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) +} + +export function getCampuses(): Promise { + if (useMockData) { + return Promise.resolve([]) + } + + return request + .get>>('/v1/campuses') + .then(unwrapApiResponse) + .then((data) => + getRecords(data).map((item) => ({ + id: item.id, + code: item.code ?? item.id, + name: item.name ?? item.campusName ?? item.id, + address: item.address ?? undefined, + status: item.status ?? 'ENABLED', + sortNo: item.sortNo ?? 0, + })), + ) +} + +export function getDepartments(campusId?: string): Promise { + if (useMockData) { + return Promise.resolve([]) + } + + return request + .get>>('/v1/departments', { + params: campusId ? { campusId } : undefined, + }) + .then(unwrapApiResponse) + .then((data) => + getRecords(data).map((item) => ({ + id: item.id, + campusId: item.campusId ?? '', + parentId: item.parentId, + code: item.code ?? item.id, + name: item.name ?? item.departmentName ?? item.id, + status: item.status ?? 'ENABLED', + sortNo: item.sortNo ?? 0, + })), + ) +} + +function mapCampus(data: OrganizationResponseDto): CampusRecord { + return { + id: data.id, + code: data.code ?? data.id, + name: data.name ?? data.campusName ?? data.id, + address: data.address ?? undefined, + status: data.status ?? 'ENABLED', + sortNo: data.sortNo ?? 0, + } +} + +function mapDepartment(data: OrganizationResponseDto): DepartmentRecord { + return { + id: data.id, + campusId: data.campusId ?? '', + parentId: data.parentId, + code: data.code ?? data.id, + name: data.name ?? data.departmentName ?? data.id, + status: data.status ?? 'ENABLED', + sortNo: data.sortNo ?? 0, + } +} + +export async function createCampus(payload: CreateCampusRequest): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行院区写操作') + } + + const response = await request.post>('/v1/campuses', payload) + return mapCampus(unwrapApiResponse(response)) +} + +export async function updateCampus( + id: string, + payload: UpdateCampusRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行院区写操作') + } + + const response = await request.put>( + `/v1/campuses/${encodeURIComponent(id)}`, + payload, + ) + return mapCampus(unwrapApiResponse(response)) +} + +export async function deleteCampus(id: string): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行院区写操作') + } + + const response = await request.delete>(`/v1/campuses/${encodeURIComponent(id)}`) + unwrapNullableApiResponse(response) +} + +export async function createDepartment( + payload: CreateDepartmentRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行科室写操作') + } + + const response = await request.post>( + '/v1/departments', + payload, + ) + return mapDepartment(unwrapApiResponse(response)) +} + +export async function updateDepartment( + id: string, + payload: UpdateDepartmentRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行科室写操作') + } + + const response = await request.put>( + `/v1/departments/${encodeURIComponent(id)}`, + payload, + ) + return mapDepartment(unwrapApiResponse(response)) +} + +export async function deleteDepartment(id: string): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行科室写操作') + } + + const response = await request.delete>( + `/v1/departments/${encodeURIComponent(id)}`, + ) + unwrapNullableApiResponse(response) +} diff --git a/clinical-web/src/api/management/roles.ts b/clinical-web/src/api/management/roles.ts new file mode 100644 index 0000000..73c95ee --- /dev/null +++ b/clinical-web/src/api/management/roles.ts @@ -0,0 +1,79 @@ +import { unwrapApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' +import type { ApiResponse } from '@/types/common' + +import type { + BackendCollection, + CreateRoleRequest, + RoleRecord, + RoleResponseDto, + UpdateRoleRequest, +} from './types' + +const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' + +const fallbackColors = ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d'] + +function normalizeRole(dto: RoleResponseDto, index: number): RoleRecord { + return { + id: dto.id, + code: dto.code, + name: dto.name, + description: dto.description ?? '—', + color: fallbackColors[index % fallbackColors.length], + userCount: dto.userCount ?? 0, + status: dto.status ?? 'ENABLED', + } +} + +export function getRoles(): Promise { + if (useMockData) { + return Promise.resolve([]) + } + + return request + .get>>('/v1/roles') + .then(unwrapApiResponse) + .then((data) => { + const records = Array.isArray(data) + ? data + : (data.records ?? data.items ?? data.content ?? []) + return records.map(normalizeRole) + }) +} + +export function createRole(payload: CreateRoleRequest): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行角色写操作')) + } + + return request + .post>('/v1/roles', payload) + .then(unwrapApiResponse) + .then((role) => normalizeRole(role, 0)) +} + +export function updateRole(id: string, payload: UpdateRoleRequest): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行角色写操作')) + } + + return request + .put>(`/v1/roles/${encodeURIComponent(id)}`, payload) + .then(unwrapApiResponse) + .then((role) => normalizeRole(role, 0)) +} + +export function deleteRole(id: string): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行角色写操作')) + } + + return request + .delete>(`/v1/roles/${encodeURIComponent(id)}`) + .then((response) => { + if (response.code !== 0 && response.code !== '0') { + throw new Error(response.message || '角色删除失败') + } + }) +} diff --git a/clinical-web/src/api/management/types.ts b/clinical-web/src/api/management/types.ts index e1a47c2..08c048a 100644 --- a/clinical-web/src/api/management/types.ts +++ b/clinical-web/src/api/management/types.ts @@ -1,6 +1,6 @@ import type { PageQuery, PageResult } from '@/types/common' -export type DocumentStatus = 'draft' | 'published' | 'archived' +export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review' export interface DocumentQuery extends PageQuery { keyword?: string @@ -10,15 +10,198 @@ export interface DocumentQuery extends PageQuery { export interface DocumentRecord { id: string name: string + code?: string category: string version: string status: DocumentStatus + backendStatus?: BackendTemplateStatus updatedBy: string updatedAt: string + versionId?: string + departmentId?: string | null + departmentName?: string + campusId?: string | null + campusName?: string + description?: string + contentHtml?: string + contentSha256?: string } export type DocumentListResponse = PageResult +export type BackendTemplateStatus = + | 'DRAFT' + | 'PENDING_REVIEW' + | 'REJECTED' + | 'APPROVED' + | 'PUBLISHED' + | 'DISABLED' + | 'ARCHIVED' + | string + +export interface TemplateResponseDto { + id: string + templateCode: string + name: string + description?: string | null + campusId: string + departmentId?: string | null + category?: string | null + status?: BackendTemplateStatus | null + currentVersionId?: string | null + versionSequence?: number | null + createdAt?: string | null + createdBy?: string | null + updatedAt?: string | null + updatedBy?: string | null +} + +export interface TemplateVersionResponseDto { + id: string + templateId: string + versionNo?: string | null + versionNumber?: number | null + contentHtml?: string | null + content?: string | null + signatureFields?: unknown[] | null + contentSha256?: string | null + requestFingerprint?: string | null + fileSizeBytes?: number | null + fileMimeType?: string | null + fileSha256?: string | null + fileMetadata?: Record | null + status?: BackendTemplateStatus | null + effectiveAt?: string | null + disabledAt?: string | null + reviewSubmittedAt?: string | null + reviewedAt?: string | null + reviewedBy?: string | null + reviewComment?: string | null + publishedAt?: string | null + createdAt?: string | null + createdBy?: string | null +} + +export interface CreateTemplateRequest { + templateCode: string + name: string + description?: string + campusId: string + departmentId?: string | null + category?: string +} + +export interface CreateTemplateVersionRequest { + versionNo?: string + contentHtml: string + signatureFields?: unknown[] + fileStorageKey?: string + fileRelativePath?: string + fileSizeBytes?: number + fileMimeType?: string + fileSha256?: string + fileMetadata?: Record + idempotencyKey?: string +} + +export interface BackendPage { + records?: T[] + items?: T[] + content?: T[] + page?: number + size?: number + total?: number + pages?: number +} + +export type BackendCollection = T[] | BackendPage + +export interface RoleRecord { + id: string + code: string + name: string + description: string + color?: string + userCount?: number + status: RoleStatus +} + +export type RoleStatus = 'ENABLED' | 'DISABLED' | string + +export interface RoleResponseDto { + id: string + code: string + name: string + description?: string | null + status?: RoleStatus | null + userCount?: number | null +} + +export interface CreateRoleRequest { + code: string + name: string + description?: string + status?: RoleStatus +} + +export type UpdateRoleRequest = Partial + +export interface CampusRecord { + id: string + code: string + name: string + address?: string + status: OrganizationStatus + sortNo: number +} + +export interface DepartmentRecord { + id: string + campusId: string + parentId?: string | null + code: string + name: string + status: OrganizationStatus + sortNo: number +} + +export type OrganizationStatus = 'ENABLED' | 'DISABLED' | string + +export interface CreateCampusRequest { + code: string + name: string + address?: string + status?: OrganizationStatus + sortNo?: number +} + +export type UpdateCampusRequest = Partial + +export interface CreateDepartmentRequest { + campusId: string + parentId?: string | null + code: string + name: string + status?: OrganizationStatus + sortNo?: number +} + +export type UpdateDepartmentRequest = Partial + +export interface OrganizationResponseDto { + id: string + code?: string | null + name?: string | null + address?: string | null + status?: OrganizationStatus | null + sortNo?: number | null + campusId?: string | null + parentId?: string | null + campusName?: string | null + departmentId?: string | null + departmentName?: string | null +} + export type ReportPeriod = 'today' | 'week' | 'month' | 'year' export interface ReportQuery { @@ -70,10 +253,62 @@ export interface UserRecord { role: string status: UserStatus lastLoginAt: string + employeeNo?: string | null + phone?: string | null + email?: string | null + campusId?: string | null + campusName?: string + departmentId?: string | null + departmentName?: string + roleNames?: string[] + dataScope?: string } export type UserListResponse = PageResult +export interface UserResponseDto { + id: string + username: string + displayName: string + employeeNo?: string | null + phone?: string | null + email?: string | null + campusId?: string | null + departmentId?: string | null + dataScope?: string | null + status?: string | null + lastLoginAt?: string | null + createdAt?: string | null + updatedAt?: string | null +} + +export interface CreateUserRequest { + username: string + password: string + displayName: string + employeeNo?: string + phone?: string + email?: string + campusId: string + departmentId?: string | null + status?: BackendUserStatus + dataScope?: BackendDataScope +} + +export type BackendUserStatus = 'ENABLED' | 'DISABLED' | string +export type BackendDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL' | string + +export interface UpdateUserRequest { + displayName: string + employeeNo?: string + phone?: string + email?: string + campusId: string + departmentId?: string | null + status: BackendUserStatus + dataScope: BackendDataScope +} + export type PermissionSubjectType = 'user' | 'role' export interface PermissionQuery extends PageQuery { @@ -115,3 +350,67 @@ export interface SystemSettingsResponse { export interface UpdateSystemSettingsRequest { values: Record } + +export type ApiPermissionSubjectType = 'ROLE' | 'DEPARTMENT' | 'USER' +export type ApiPermissionLevel = 'VIEW' | 'USE' | 'MAINTAIN' +export type ApiPermissionEffect = 'ALLOW' | 'DENY' + +export interface TemplatePermissionResponseDto { + id: string + templateId: string + subjectType: ApiPermissionSubjectType + subjectId: string + permissionLevel: ApiPermissionLevel + effect: ApiPermissionEffect + inherited: boolean + createdAt: string +} + +export interface TemplatePermissionRecord { + id: string + templateId: string + subjectType: ApiPermissionSubjectType + subjectId: string + subjectName: string + permissionLevel: ApiPermissionLevel + effect: ApiPermissionEffect + inherited: boolean + createdAt: string +} + +export interface CreateTemplatePermissionRequest { + subjectType: ApiPermissionSubjectType + subjectId: string + permissionLevel: ApiPermissionLevel + effect: ApiPermissionEffect +} + +export interface AuditLogQuery extends PageQuery { + keyword?: string +} + +export interface AuditLogResponseDto { + id: string + action: string + resourceType: string + resourceId: string + operatorId: string + clientIp?: string | null + userAgent?: string | null + detailsJson?: string | null + createdAt: string +} + +export interface AuditLogRecord { + id: string + action: string + resourceType: string + resourceId: string + operatorId: string + clientIp: string + userAgent: string + detailsJson: string + createdAt: string +} + +export type AuditLogListResponse = PageResult diff --git a/clinical-web/src/api/management/users.ts b/clinical-web/src/api/management/users.ts index 823b2e2..3296b3b 100644 --- a/clinical-web/src/api/management/users.ts +++ b/clinical-web/src/api/management/users.ts @@ -1,9 +1,24 @@ -import { request } from '@/utils/request' +import axios from 'axios' -import type { UserListResponse, UserQuery, UserRecord } from './types' +import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' +import type { ApiResponse, PageResult } from '@/types/common' + +import type { + BackendCollection, + BackendPage, + CreateUserRequest, + UpdateUserRequest, + UserListResponse, + UserQuery, + UserRecord, + UserResponseDto, +} from './types' const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' +export const isUserMockEnabled = useMockData + const mockRecords: UserRecord[] = [ { id: 'user-001', @@ -34,11 +49,90 @@ const mockRecords: UserRecord[] = [ }, ] +function formatDateTime(value: string | null | undefined) { + if (!value) { + return '—' + } + + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + .replace('T', ' ') + .replace(/\.\d+Z$/, '') + .replace(/Z$/, '') + } + + const pad = (part: number) => String(part).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}` +} + +function normalizeStatus(value: string | null | undefined): UserRecord['status'] { + return value === 'DISABLED' || value === 'INACTIVE' ? 'disabled' : 'enabled' +} + +function mapUser(dto: UserResponseDto): UserRecord { + return { + id: dto.id, + name: dto.displayName, + account: dto.username, + department: dto.departmentId ?? '未指定科室', + role: '未分配角色', + status: normalizeStatus(dto.status), + lastLoginAt: formatDateTime(dto.lastLoginAt), + employeeNo: dto.employeeNo, + phone: dto.phone, + email: dto.email, + campusId: dto.campusId, + departmentId: dto.departmentId, + dataScope: dto.dataScope ?? '—', + } +} + +function normalizePage( + data: BackendPage | T[], + fallbackPage: number, + fallbackPageSize: number, +): PageResult { + const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) + + return { + records, + total: Array.isArray(data) ? records.length : (data.total ?? records.length), + page: Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage), + pageSize: Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize), + } +} + +function toBackendQuery(query: UserQuery) { + const params: Record = { + page: Math.max(query.page, 1), + size: Math.min(Math.max(query.pageSize, 1), 200), + } + + if (query.keyword?.trim()) { + params.keyword = query.keyword.trim() + } + + return params +} + export function getUsers(query: UserQuery): Promise { if (!useMockData) { - return request.get('/management/users', { - params: query, - }) + return request + .get>>('/v1/users', { + params: toBackendQuery(query), + }) + .then((response) => { + const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize) + + return { + ...page, + records: page.records.map(mapUser), + } + }) } const keyword = query.keyword?.trim().toLowerCase() @@ -61,3 +155,57 @@ export function getUsers(query: UserQuery): Promise { pageSize, }) } + +export async function getUserDetail(id: string): Promise { + if (useMockData) { + const record = mockRecords.find((item) => item.id === id) + return record ? { ...record } : null + } + + try { + const response = await request.get>( + `/v1/users/${encodeURIComponent(id)}`, + ) + return mapUser(unwrapApiResponse(response)) + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null + } + + throw error + } +} + +export function createUser(payload: CreateUserRequest): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行用户写操作')) + } + + return request + .post>('/v1/users', payload) + .then(unwrapApiResponse) + .then(mapUser) +} + +export function updateUser(id: string, payload: UpdateUserRequest): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行用户写操作')) + } + + return request + .put>(`/v1/users/${encodeURIComponent(id)}`, payload) + .then(unwrapApiResponse) + .then(mapUser) +} + +export function deleteUser(id: string): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行用户写操作')) + } + + return request + .delete>(`/v1/users/${encodeURIComponent(id)}`) + .then((response) => { + unwrapNullableApiResponse(response) + }) +} diff --git a/clinical-web/src/api/workbench/artifacts.ts b/clinical-web/src/api/workbench/artifacts.ts new file mode 100644 index 0000000..4ae822a --- /dev/null +++ b/clinical-web/src/api/workbench/artifacts.ts @@ -0,0 +1,121 @@ +import axios from 'axios' + +import { unwrapApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' + +import type { + ApiPageResponse, + ApiResponseOf, + SignArtifactResponseDto, + SignArtifactType, + SigningArtifact, +} from './types' + +const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' + +type ArtifactCollection = SignArtifactResponseDto[] | ApiPageResponse + +const artifactLabels: Record = { + ORIGINAL_PDF: 'PDF 原件', + SIGNATURE_IMAGE: '签名原图', + SIGNED_PDF: '签署后 PDF', +} + +function formatDateTime(value: string | null | undefined) { + if (!value) { + return '' + } + + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + .replace('T', ' ') + .replace(/\.\d+Z$/, '') + .replace(/Z$/, '') + } + + const pad = (part: number) => String(part).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}` +} + +function getCollectionRecords(collection: ArtifactCollection) { + return Array.isArray(collection) ? collection : collection.records +} + +function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact { + const artifactType: SignArtifactType = dto.artifactType ?? dto.type ?? 'UNKNOWN' + const label = artifactLabels[artifactType] ?? '签署文件' + const mimeType = dto.mimeType ?? dto.contentType ?? 'application/octet-stream' + const extension = + mimeType === 'application/pdf' ? 'pdf' : mimeType === 'image/png' ? 'png' : 'bin' + + return { + id: dto.id, + taskId: dto.taskId, + artifactType, + label, + fileName: dto.fileName || `${label}.${extension}`, + mimeType, + size: dto.size ?? dto.byteSize ?? dto.fileSize ?? 0, + sha256: dto.sha256 ?? dto.contentSha256 ?? '', + createdAt: formatDateTime(dto.createdAt), + } +} + +export const isArtifactMockEnabled = useMockData + +export async function getSigningArtifacts(taskId: string): Promise { + if (useMockData) { + return [] + } + + try { + const response = await request.get>( + `/v1/sign-artifacts/task/${encodeURIComponent(taskId)}`, + ) + + return getCollectionRecords(unwrapApiResponse(response)).map(normalizeArtifact) + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return [] + } + + throw error + } +} + +export async function getSigningArtifact( + taskId: string, + artifactId: string, +): Promise { + if (useMockData) { + return null + } + + try { + const response = await request.get>( + `/v1/sign-artifacts/task/${encodeURIComponent(taskId)}/${encodeURIComponent(artifactId)}`, + ) + + return normalizeArtifact(unwrapApiResponse(response)) + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null + } + + throw error + } +} + +export function downloadSigningArtifact(artifactId: string): Promise { + if (useMockData) { + return Promise.resolve(new Blob(['Mock 签署文件,仅用于界面演示。'], { type: 'text/plain' })) + } + + return request.get(`/v1/sign-artifacts/${encodeURIComponent(artifactId)}/download`, { + responseType: 'blob', + }) +} diff --git a/clinical-web/src/api/workbench/deliveries.ts b/clinical-web/src/api/workbench/deliveries.ts new file mode 100644 index 0000000..780fb11 --- /dev/null +++ b/clinical-web/src/api/workbench/deliveries.ts @@ -0,0 +1,152 @@ +import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' + +import type { + ApiResponseOf, + PadBindingRequest, + PadSessionUploadInput, + SendSmsDeliveryRequest, + SignDeliveryResponseDto, + SignatureUploadResponse, + SigningTokenUploadInput, + TokenConsumeRequest, + TokenConsumeResponse, +} from './types' + +function createIdempotencyKey() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `clinical-web-${crypto.randomUUID()}` + } + + return `clinical-web-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +function idempotencyHeaders(value?: string) { + return { 'Idempotency-Key': value || createIdempotencyKey() } +} + +export function sendSigningSms( + taskId: string, + payload: SendSmsDeliveryRequest, + idempotencyKey?: string, +): Promise { + return request + .post>( + `/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/send`, + payload, + { headers: idempotencyHeaders(idempotencyKey) }, + ) + .then(unwrapNullableApiResponse) +} + +export function resendSigningSms( + taskId: string, + expectedRowVersion?: number, + idempotencyKey?: string, +): Promise { + const data = expectedRowVersion === undefined ? undefined : { expectedRowVersion } + + return request + .post>( + `/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/resend`, + data, + { headers: idempotencyHeaders(idempotencyKey) }, + ) + .then(unwrapNullableApiResponse) +} + +export function createPadSigningSession( + taskId: string, + payload: PadBindingRequest = {}, + idempotencyKey?: string, +): Promise { + return request + .post>( + `/v1/sign-deliveries/${encodeURIComponent(taskId)}/pad/sessions`, + payload, + { headers: idempotencyHeaders(idempotencyKey) }, + ) + .then(unwrapApiResponse) +} + +export function consumeSigningToken(payload: TokenConsumeRequest): Promise { + return request + .post>('/v1/sign-deliveries/token/consume', payload) + .then(unwrapApiResponse) +} + +function createSignatureFormData(file: File | Blob, metadata: string) { + const formData = new FormData() + const fileName = + typeof File !== 'undefined' && file instanceof File && file.name ? file.name : 'signature.png' + formData.append('file', file, fileName) + formData.append('metadata', metadata) + return formData +} + +export function uploadSigningSignature( + payload: SigningTokenUploadInput, +): Promise { + const formData = createSignatureFormData(payload.file, payload.metadata) + + return request + .post>( + `/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`, + formData, + { + params: { + deliveryId: payload.deliveryId, + uploadToken: payload.uploadToken, + }, + headers: idempotencyHeaders(payload.idempotencyKey), + }, + ) + .then(unwrapApiResponse) +} + +/** + * PAD 专用兼容路径。服务端要求同时提交 PAD 元数据和真实 PNG,旧的 JSON-only 提交会返回 409。 + * 具体的 challenge、文件摘要和 storageKey 由设备适配层生成,页面不应伪造这些值。 + */ +export function uploadPadSigningSignature( + payload: PadSessionUploadInput, +): Promise { + if (!payload.pad) { + return Promise.reject(new Error('PAD 上传缺少会话元数据')) + } + + const formData = createSignatureFormData(payload.file, payload.metadata) + formData.append('challenge', payload.pad.challenge) + formData.append('contentType', payload.pad.contentType) + formData.append('sizeBytes', String(payload.pad.sizeBytes ?? payload.file.size)) + formData.append('sha256', payload.pad.sha256) + formData.append('storageKey', payload.pad.storageKey) + + return request + .post>( + `/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/pad/sessions/${encodeURIComponent(payload.deliveryId)}/upload`, + formData, + { + params: { uploadToken: payload.uploadToken }, + headers: idempotencyHeaders(payload.idempotencyKey), + }, + ) + .then(unwrapApiResponse) +} + +export function uploadSigningSignatureByProof( + payload: SigningTokenUploadInput, +): Promise { + const formData = createSignatureFormData(payload.file, payload.metadata) + + return request + .post>('/v1/sign-deliveries/token/signature', formData, { + params: { + taskId: payload.taskId, + deliveryId: payload.deliveryId, + uploadToken: payload.uploadToken, + }, + headers: idempotencyHeaders(payload.idempotencyKey), + }) + .then(unwrapApiResponse) +} diff --git a/clinical-web/src/api/workbench/home.ts b/clinical-web/src/api/workbench/home.ts index 6a54ed5..e254325 100644 --- a/clinical-web/src/api/workbench/home.ts +++ b/clinical-web/src/api/workbench/home.ts @@ -1,4 +1,6 @@ -import { request } from '@/utils/request' +import { getCampuses } from '@/api/management/organization' + +import { getSigningTasks, getSigningTemplates } from './signing' import type { HomeRankingPeriod, @@ -6,6 +8,7 @@ import type { WorkbenchDocumentRanking, WorkbenchOverviewQuery, WorkbenchOverviewResponse, + SigningTaskRecord, WorkbenchTodoTask, WorkbenchTrendPoint, } from './types' @@ -243,6 +246,107 @@ function createRanking( .sort((left, right) => right.signedCount - left.signedCount) } +function isWorkbenchCampus(value: string): value is WorkbenchCampus { + return value === '本部院区' || value === '东院区' || value === '西院区' +} + +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 getTaskDate(task: SigningTaskRecord) { + return parseDate(task.createdAt) ?? parseDate(task.updatedAt) +} + +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(getTaskDate(task), today)).length + const pendingTasks = tasks.filter(isPendingTask) + const todayOverdue = tasks.filter( + (task) => task.status === 'expired' && isSameDay(getTaskDate(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(getTaskDate(task), date)).length, + } + }) + + const todos = tasks + .filter((task) => isPendingTask(task) || task.status === 'expired') + .sort((left, right) => { + const leftDate = getTaskDate(left)?.getTime() ?? 0 + const rightDate = getTaskDate(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() + signedTasks.forEach((task) => { + const current = rankingMap.get(task.documentName) + rankingMap.set(task.documentName, { + id: current?.id ?? task.documentId, + documentName: task.documentName, + department: task.department, + signedCount: (current?.signedCount ?? 0) + 1, + }) + }) + + return { + summary: { + todaySigned, + pendingPatientSigning: pendingTasks.length, + todayOverdue, + availableTemplates: templateCount, + coveredDepartments, + }, + trend, + todos, + documentRanking: [...rankingMap.values()] + .sort((left, right) => right.signedCount - left.signedCount) + .slice(0, 10), + } +} + export function getWorkbenchOverview( query: WorkbenchOverviewQuery, ): Promise { @@ -257,7 +361,38 @@ export function getWorkbenchOverview( }) } - return request.get('/workbench/overview', { - params: query, - }) + return Promise.allSettled([getSigningTemplates(), getCampuses()]).then( + async ([templatesResult, campusesResult]) => { + const templates = templatesResult.status === 'fulfilled' ? templatesResult.value : [] + const campusNames: Record = + campusesResult.status === 'fulfilled' + ? (Object.fromEntries( + campusesResult.value + .filter((campus) => isWorkbenchCampus(campus.name)) + .map((campus) => [campus.id, campus.name]), + ) as Record) + : {} + const tasksResponse = await getSigningTasks( + { + page: 1, + pageSize: 200, + status: 'all', + dateRange: 'all', + campus: 'all', + method: 'all', + }, + { templates, campusNames }, + ) + const scopedTasks = Object.keys(campusNames).length + ? tasksResponse.records.filter((task) => task.campus === query.campus) + : tasksResponse.records + + return createLiveOverview( + scopedTasks, + templates.length, + new Set(templates.map((template) => template.department)).size, + query.rankingPeriod, + ) + }, + ) } diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 0e53075..4d2cc7f 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -1,6 +1,8 @@ import axios from 'axios' +import { unwrapApiResponse } from '@/utils/api-response' import { request } from '@/utils/request' +import { resendSigningSms as resendSigningSmsDelivery } from './deliveries' import type { ApiPageResponse, @@ -368,22 +370,6 @@ function addHours(value: string, hours: number) { export const isSigningMockEnabled = useMockData -function isSuccessCode(code: number | string) { - return code === 0 || code === '0' -} - -function unwrapApiResponse(response: ApiResponseOf): T { - if (!isSuccessCode(response.code)) { - throw new Error(response.message || '接口请求失败') - } - - if (response.data === null) { - throw new Error(response.message || '接口未返回业务数据') - } - - return response.data -} - function isNotFoundError(error: unknown) { return axios.isAxiosError(error) && error.response?.status === 404 } @@ -521,7 +507,8 @@ function mapSigningTask( return { id: dto.id, templateVersionId: dto.templateVersionId, - campus: options.campus ?? '本部院区', + campus: options.campusNames?.[dto.campusId] ?? options.campus ?? '本部院区', + campusId: dto.campusId, patientId: dto.patientSnapshot.patientId, patientName: dto.patientSnapshot.name, sex: normalizePatientSex(dto.patientSnapshot.sex), @@ -532,11 +519,13 @@ function mapSigningTask( documentId: template?.id ?? dto.templateVersionId, documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`, department: dto.visitSnapshot.departmentName || '未指定科室', + departmentId: dto.departmentId, signerName: dto.patientSnapshot.name, method: normalizeSigningMethod(dto.signMethod), status: normalizeTaskStatus(dto.status), source: dto.visitSnapshot.sourceSystem || dto.patientSnapshot.sourceSystem || '—', expiresAt: formatApiDateTime(dto.expiredAt), + createdAt: formatApiDateTime(dto.createdAt), updatedAt: formatApiDateTime(dto.updatedAt), rowVersion: dto.rowVersion, backendStatus: dto.status, @@ -559,7 +548,7 @@ function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate } } -function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate[]) { +function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions = {}) { const params: Record = { page: Math.max(query.page, 1), size: Math.min(Math.max(query.pageSize, 1), 200), @@ -592,7 +581,7 @@ function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate if (query.documentId) { params.templateVersionId = - findTemplate(templates, query.documentId)?.versionId ?? query.documentId + findTemplate(options.templates, query.documentId)?.versionId ?? query.documentId } const now = new Date() @@ -615,8 +604,14 @@ function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate params.createdTo = end.toISOString() } - // 院区、科室和就诊类型在页面中使用展示名称,不能直接当作 UUID 发送。 - // 后端会按当前登录用户的数据范围默认过滤,待院区/科室字典接入后再补充 ID 映射。 + if (query.campus && query.campus !== 'all' && options.campusId) { + params.campusId = options.campusId + } + + if (query.department && options.departmentIds?.[query.department]) { + params.departmentId = options.departmentIds[query.department] + } + return params } @@ -675,7 +670,7 @@ export async function getSigningTasks( if (!useMockData) { const response = await request.get>>( '/v1/sign-tasks', - { params: toBackendTaskQuery(query, options.templates) }, + { params: toBackendTaskQuery(query, options) }, ) const page = unwrapApiResponse(response) @@ -853,12 +848,15 @@ export async function getSigningTemplates( } = {}, ): Promise { if (!useMockData) { - const response = await request.get>( - '/v1/templates/available-versions', - { params: query }, - ) + const response = await request.get< + ApiResponseOf< + AvailableTemplateVersionResponseDto[] | ApiPageResponse + > + >('/v1/templates/available-versions', { params: query }) - return unwrapApiResponse(response).map(mapAvailableTemplate) + const data = unwrapApiResponse(response) + const records = Array.isArray(data) ? data : data.records + return records.map(mapAvailableTemplate) } return Promise.resolve(mockTemplates.map((template) => ({ ...template }))) @@ -1033,11 +1031,7 @@ export async function resendSigningSms( options: SigningTaskActionOptions = {}, ): Promise { if (!useMockData) { - const response = await request.post>( - `/v1/sign-tasks/${encodeURIComponent(id)}/resend`, - getTaskActionBody(options.expectedRowVersion), - ) - unwrapApiResponse(response) + await resendSigningSmsDelivery(id, options.expectedRowVersion) return getSigningTaskDetail(id, options) } diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 5b93a8e..6d004c3 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -122,14 +122,17 @@ export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all' export interface SigningTaskRecord extends WorkbenchTask { templateVersionId: string campus: WorkbenchCampus + campusId?: string patientId: string sex: PatientSex age: number documentId: string + departmentId?: string | null signerName: string method: SigningMethod source: string expiresAt: string + createdAt?: string visitType: VisitType visitDate: string rowVersion?: number @@ -167,6 +170,9 @@ export interface CreateSigningTaskRequest { export interface SigningApiOptions { templates?: SigningTemplate[] campus?: WorkbenchCampus + campusId?: string + campusNames?: Record + departmentIds?: Record } export interface SigningTaskActionOptions extends SigningApiOptions { @@ -301,6 +307,113 @@ export interface SignTaskSendPreparationResponseDto { rowVersion: number } +export interface SendSmsDeliveryRequest { + destination: string + templateCode: string +} + +export type SignDeliveryChannel = 'PAD' | 'SMS' + +export type SignDeliveryStatus = + | 'PENDING' + | 'IN_FLIGHT' + | 'SUCCEEDED' + | 'RETRYABLE' + | 'FAILED' + | 'DEAD' + | 'CANCELLED' + | 'UNKNOWN' + | 'REVOKED' + | 'EXPIRED' + | string + +export interface SignDeliveryResponseDto { + deliveryId: string + taskId: string + channel: SignDeliveryChannel + status: SignDeliveryStatus + attemptId?: string | null + attemptNo?: number | null + tokenVersion?: number | null + destinationMasked?: string | null + token?: string | null + tokenExpiresAt?: string | null + errorCode?: string | null + errorMessage?: string | null + traceId?: string | null + timestamp?: string | null +} + +export interface PadBindingRequest { + deviceId?: string + expiresAt?: string +} + +export interface PadUploadRequest { + challenge: string + contentType: 'image/png' + sizeBytes?: number + sha256: string + storageKey: string +} + +export interface TokenConsumeRequest { + token: string + channel?: SignDeliveryChannel + taskId?: string +} + +export interface TokenConsumeResponse { + deliveryId: string + taskId: string + channel: SignDeliveryChannel + uploadToken: string + traceId?: string | null + timestamp?: string | null +} + +export type SignatureUploadStatus = BackendSigningTaskStatus + +export interface SignatureUploadResponse { + taskId: string + pipelineId: string + status: SignatureUploadStatus + originalPdfArtifactId: string + signedPdfArtifactId: string + signatureImageArtifactId: string + originalPdfSha256: string + signedPdfSha256: string + signatureImageSha256: string + completedAt: string | null + traceId?: string | null + timestamp?: string | null +} + +export interface SignatureUploadInput { + deliveryId: string + uploadToken: string + file: File | Blob + metadata: string + pad?: PadUploadRequest + idempotencyKey?: string +} + +export interface SigningTokenUploadInput extends Omit { + taskId: string +} + +export interface PadSessionUploadInput extends SignatureUploadInput { + taskId: string +} + +export interface SignatureDeliveryResult { + taskId: string + deliveryId: string + channel: SignDeliveryChannel + uploadToken: string + upload: SignatureUploadResponse +} + export interface SignTaskEventResponseDto { id: string taskId: string @@ -323,6 +436,37 @@ export interface SignTaskEventResponseDto { detail: Record | null } +export type SignArtifactType = 'ORIGINAL_PDF' | 'SIGNATURE_IMAGE' | 'SIGNED_PDF' | string + +/** MEDISIGN 签署产物 DTO。部分文件元数据由不同版本的服务端返回,字段保持可选并在 API 边界归一化。 */ +export interface SignArtifactResponseDto { + id: string + taskId: string + artifactType?: SignArtifactType + type?: SignArtifactType + fileName?: string | null + mimeType?: string | null + contentType?: string | null + size?: number | null + byteSize?: number | null + fileSize?: number | null + sha256?: string | null + contentSha256?: string | null + createdAt?: string | null +} + +export interface SigningArtifact { + id: string + taskId: string + artifactType: SignArtifactType + label: string + fileName: string + mimeType: string + size: number + sha256: string + createdAt: string +} + export interface SigningTaskEvent { id: string time: string