Compare commits

...

4 Commits

Author SHA1 Message Date
yelan
b4184260da docs: document captcha and permission APIs 2026-09-01 14:50:50 +08:00
yelan
45971b62d9 feat(management): add API permission client 2026-09-01 14:50:40 +08:00
yelan
2963c6ea56 feat(auth): support captcha login 2026-09-01 14:50:29 +08:00
yelan
5b2cc33038 fix(signing): align workbench with MEDISIGN contract 2026-09-01 14:50:07 +08:00
17 changed files with 543 additions and 55 deletions

View File

@@ -21,6 +21,7 @@ src/
│ ├─ documents.ts │ ├─ documents.ts
│ ├─ reports.ts │ ├─ reports.ts
│ ├─ users.ts │ ├─ users.ts
│ ├─ permissions.ts
│ ├─ document-permissions.ts │ ├─ document-permissions.ts
│ ├─ settings.ts │ ├─ settings.ts
│ └─ types.ts │ └─ types.ts

View File

@@ -125,6 +125,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
- `api/management/reports.ts` - `api/management/reports.ts`
- `api/management/users.ts` - `api/management/users.ts`
- `api/management/roles.ts` - `api/management/roles.ts`
- `api/management/permissions.ts`
- `api/management/organization.ts` - `api/management/organization.ts`
- `api/management/document-permissions.ts` - `api/management/document-permissions.ts`
- `api/management/audit.ts` - `api/management/audit.ts`
@@ -133,6 +134,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端: 所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端:
- `POST /api/v1/auth/login`:账号密码登录; - `POST /api/v1/auth/login`:账号密码登录;
- `GET /api/v1/auth/captcha`:获取按需启用的图形验证码;
- `GET /api/v1/auth/me`:查询当前用户; - `GET /api/v1/auth/me`:查询当前用户;
- `POST /api/v1/auth/logout`:注销当前会话; - `POST /api/v1/auth/logout`:注销当前会话;
- 后续请求自动携带 `X-Token` 请求头。 - 后续请求自动携带 `X-Token` 请求头。
@@ -155,6 +157,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
- `GET/POST /api/v1/templates``GET /api/v1/templates/{id}`:模板列表、详情; - `GET/POST /api/v1/templates``GET /api/v1/templates/{id}`:模板列表、详情;
- `GET/POST /api/v1/templates/{id}/versions`、版本工作流接口:版本查询、创建、送审、驳回、通过、发布、停用、归档; - `GET/POST /api/v1/templates/{id}/versions`、版本工作流接口:版本查询、创建、送审、驳回、通过、发布、停用、归档;
- `GET /api/v1/users``GET /api/v1/roles``GET /api/v1/campuses``GET /api/v1/departments`:用户页真实查询及组织字典; - `GET /api/v1/users``GET /api/v1/roles``GET /api/v1/campuses``GET /api/v1/departments`:用户页真实查询及组织字典;
- `GET/POST/PUT/DELETE /api/v1/permissions`API 权限分页查询、详情、新增、修改和停用;
- `GET/POST/DELETE /api/v1/templates/{id}/permissions`:按模板查询和维护文档权限; - `GET/POST/DELETE /api/v1/templates/{id}/permissions`:按模板查询和维护文档权限;
- `GET /api/v1/audit-logs`:全局审计日志查询 API。 - `GET /api/v1/audit-logs`:全局审计日志查询 API。

View File

@@ -4,6 +4,7 @@ import { ApiResponseError, unwrapApiResponse } from '@/utils/api-response'
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { import type {
AuthenticatedUser, AuthenticatedUser,
CaptchaResponse,
CurrentUserResponse, CurrentUserResponse,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
@@ -12,6 +13,11 @@ import type { ApiResponse } from '@/types/common'
export { ApiResponseError } from '@/utils/api-response' export { ApiResponseError } from '@/utils/api-response'
export async function getCaptcha(): Promise<CaptchaResponse> {
const response = await request.get<ApiResponse<CaptchaResponse>>('/v1/auth/captcha')
return unwrapApiResponse(response)
}
function normalizePermissions(value: unknown): string[] { function normalizePermissions(value: unknown): string[] {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string') return value.filter((item): item is string => typeof item === 'string')

View File

@@ -0,0 +1,209 @@
import axios from 'axios'
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
import { request } from '@/utils/request'
import type { ApiResponse, PageResult } from '@/types/common'
import type {
ApiPermissionListResponse,
ApiPermissionQuery,
ApiPermissionRecord,
ApiPermissionResponseDto,
BackendPage,
CreateApiPermissionRequest,
UpdateApiPermissionRequest,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
export const isApiPermissionMockEnabled = useMockData
const mockRecords: ApiPermissionRecord[] = [
{
id: 'api-permission-001',
parentId: null,
code: 'system:user:query',
name: '查询用户',
resourceType: 'USER',
resourcePath: '/api/v1/users',
action: 'QUERY',
status: 'ENABLED',
createdAt: '2026-08-27 09:00',
updatedAt: '2026-08-27 09:00',
},
{
id: 'api-permission-002',
parentId: null,
code: 'system:sign-task:create',
name: '创建签署任务',
resourceType: 'SIGN_TASK',
resourcePath: '/api/v1/sign-tasks',
action: 'CREATE',
status: 'ENABLED',
createdAt: '2026-08-27 09:02',
updatedAt: '2026-08-27 09:02',
},
]
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 mapPermission(dto: ApiPermissionResponseDto): ApiPermissionRecord {
return {
id: dto.id,
parentId: dto.parentId ?? null,
code: dto.code,
name: dto.name,
resourceType: dto.resourceType,
resourcePath: dto.resourcePath ?? '',
action: dto.action ?? 'QUERY',
status: dto.status ?? 'ENABLED',
createdAt: formatDateTime(dto.createdAt),
updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt),
}
}
function normalizePage<T>(
data: BackendPage<T> | T[],
fallbackPage: number,
fallbackPageSize: number,
): PageResult<T> {
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: ApiPermissionQuery) {
const params: Record<string, string | number> = {
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 getPermissions(query: ApiPermissionQuery): Promise<ApiPermissionListResponse> {
if (!useMockData) {
return request
.get<ApiResponse<BackendPage<ApiPermissionResponseDto>>>('/v1/permissions', {
params: toBackendQuery(query),
})
.then((response) => {
const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize)
return {
...page,
records: page.records.map(mapPermission),
}
})
}
const keyword = query.keyword?.trim().toLowerCase()
const filteredRecords = mockRecords.filter((record) => {
return (
!keyword ||
[record.code, record.name, record.resourceType, record.resourcePath]
.join(' ')
.toLowerCase()
.includes(keyword)
)
})
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).map((record) => ({ ...record })),
total: filteredRecords.length,
page,
pageSize,
})
}
export async function getPermission(id: string): Promise<ApiPermissionRecord | null> {
if (useMockData) {
const record = mockRecords.find((item) => item.id === id)
return record ? { ...record } : null
}
try {
const response = await request.get<ApiResponse<ApiPermissionResponseDto>>(
`/v1/permissions/${encodeURIComponent(id)}`,
)
return mapPermission(unwrapApiResponse(response))
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null
}
throw error
}
}
export function createPermission(
payload: CreateApiPermissionRequest,
): Promise<ApiPermissionRecord> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
}
return request
.post<ApiResponse<ApiPermissionResponseDto>>('/v1/permissions', payload)
.then(unwrapApiResponse)
.then(mapPermission)
}
export function updatePermission(
id: string,
payload: UpdateApiPermissionRequest,
): Promise<ApiPermissionRecord> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
}
return request
.put<ApiResponse<ApiPermissionResponseDto>>(
`/v1/permissions/${encodeURIComponent(id)}`,
payload,
)
.then(unwrapApiResponse)
.then(mapPermission)
}
export function deletePermission(id: string): Promise<void> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
}
return request
.delete<ApiResponse<null>>(`/v1/permissions/${encodeURIComponent(id)}`)
.then((response) => {
unwrapNullableApiResponse(response)
})
}

View File

@@ -146,6 +146,60 @@ export interface CreateRoleRequest {
export type UpdateRoleRequest = Partial<CreateRoleRequest> export type UpdateRoleRequest = Partial<CreateRoleRequest>
export type ApiPermissionStatus = 'ENABLED' | 'DISABLED'
export type ApiPermissionAction = 'QUERY' | 'CREATE' | 'UPDATE' | 'DISABLE'
export interface ApiPermissionQuery extends PageQuery {
keyword?: string
}
export interface ApiPermissionRecord {
id: string
parentId: string | null
code: string
name: string
resourceType: string
resourcePath: string
action: ApiPermissionAction | string
status: ApiPermissionStatus
createdAt: string
updatedAt: string
}
export interface ApiPermissionResponseDto {
id: string
parentId?: string | null
code: string
name: string
resourceType: string
resourcePath?: string | null
action?: ApiPermissionAction | string | null
status?: ApiPermissionStatus | null
createdAt?: string | null
updatedAt?: string | null
}
export interface CreateApiPermissionRequest {
parentId?: string | null
code: string
name: string
resourceType: string
resourcePath?: string
action?: ApiPermissionAction
status?: ApiPermissionStatus
}
export interface UpdateApiPermissionRequest {
parentId?: string | null
name: string
resourceType: string
resourcePath?: string
action?: ApiPermissionAction
status: ApiPermissionStatus
}
export type ApiPermissionListResponse = PageResult<ApiPermissionRecord>
export interface CampusRecord { export interface CampusRecord {
id: string id: string
code: string code: string

View File

@@ -46,21 +46,23 @@ function getCollectionRecords(collection: ArtifactCollection) {
} }
function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact { function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact {
const artifactType: SignArtifactType = dto.artifactType ?? dto.type ?? 'UNKNOWN' const artifactType: SignArtifactType = dto.artifactType
const label = artifactLabels[artifactType] ?? '签署文件' const label = artifactLabels[artifactType] ?? '签署文件'
const mimeType = dto.mimeType ?? dto.contentType ?? 'application/octet-stream' const mimeType = dto.mimeType || 'application/octet-stream'
const extension = const extension =
mimeType === 'application/pdf' ? 'pdf' : mimeType === 'image/png' ? 'png' : 'bin' mimeType === 'application/pdf' ? 'pdf' : mimeType === 'image/png' ? 'png' : 'bin'
return { return {
id: dto.id, id: dto.id,
taskId: dto.taskId, taskId: dto.taskId,
pipelineId: dto.pipelineId,
artifactType, artifactType,
label, label,
fileName: dto.fileName || `${label}.${extension}`, fileName: `${label}.${extension}`,
mimeType, mimeType,
size: dto.size ?? dto.byteSize ?? dto.fileSize ?? 0, sizeBytes: dto.sizeBytes,
sha256: dto.sha256 ?? dto.contentSha256 ?? '', sha256: dto.sha256,
metadata: dto.metadata ?? {},
createdAt: formatDateTime(dto.createdAt), createdAt: formatDateTime(dto.createdAt),
} }
} }

View File

@@ -41,15 +41,12 @@ export function sendSigningSms(
export function resendSigningSms( export function resendSigningSms(
taskId: string, taskId: string,
expectedRowVersion?: number,
idempotencyKey?: string, idempotencyKey?: string,
): Promise<SignDeliveryResponseDto | null> { ): Promise<SignDeliveryResponseDto | null> {
const data = expectedRowVersion === undefined ? undefined : { expectedRowVersion }
return request return request
.post<ApiResponseOf<SignDeliveryResponseDto | null>>( .post<ApiResponseOf<SignDeliveryResponseDto | null>>(
`/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/resend`, `/v1/sign-deliveries/${encodeURIComponent(taskId)}/sms/resend`,
data, undefined,
{ headers: idempotencyHeaders(idempotencyKey) }, { headers: idempotencyHeaders(idempotencyKey) },
) )
.then(unwrapNullableApiResponse) .then(unwrapNullableApiResponse)
@@ -69,9 +66,14 @@ export function createPadSigningSession(
.then(unwrapApiResponse) .then(unwrapApiResponse)
} }
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> { export function consumeSigningToken(
payload: TokenConsumeRequest,
idempotencyKey?: string,
): Promise<TokenConsumeResponse> {
return request return request
.post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload) .post<ApiResponseOf<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
headers: idempotencyHeaders(idempotencyKey),
})
.then(unwrapApiResponse) .then(unwrapApiResponse)
} }

View File

@@ -10,6 +10,7 @@ import type {
AvailableTemplateVersionResponseDto, AvailableTemplateVersionResponseDto,
BackendSigningMethod, BackendSigningMethod,
BackendSigningTaskStatus, BackendSigningTaskStatus,
BackendVisitType,
CreateSigningTaskInput, CreateSigningTaskInput,
CreateSigningTaskRequest, CreateSigningTaskRequest,
PatientProfile, PatientProfile,
@@ -563,7 +564,6 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
pending: 'WAITING_SIGN', pending: 'WAITING_SIGN',
signing: 'GENERATING', signing: 'GENERATING',
signed: 'SIGNED', signed: 'SIGNED',
rejected: 'FAILED',
expired: 'EXPIRED', expired: 'EXPIRED',
void: 'VOIDED', void: 'VOIDED',
failed: 'FAILED', failed: 'FAILED',
@@ -579,6 +579,19 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD' params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD'
} }
if (query.visitType && query.visitType !== 'all') {
const visitTypeMap: Partial<Record<VisitType, BackendVisitType>> = {
: 'OUTPATIENT',
: 'INPATIENT',
: 'CHECKUP',
}
const visitType = visitTypeMap[query.visitType]
if (visitType) {
params.visitType = visitType
}
}
if (query.documentId) { if (query.documentId) {
params.templateVersionId = params.templateVersionId =
findTemplate(options.templates, query.documentId)?.versionId ?? query.documentId findTemplate(options.templates, query.documentId)?.versionId ?? query.documentId
@@ -604,8 +617,12 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
params.createdTo = end.toISOString() params.createdTo = end.toISOString()
} }
if (query.campus && query.campus !== 'all' && options.campusId) { if (query.campus && query.campus !== 'all') {
params.campusId = options.campusId const campusId = options.campusIds?.[query.campus] ?? options.campusId
if (campusId) {
params.campusId = campusId
}
} }
if (query.department && options.departmentIds?.[query.department]) { if (query.department && options.departmentIds?.[query.department]) {
@@ -1031,7 +1048,7 @@ export async function resendSigningSms(
options: SigningTaskActionOptions = {}, options: SigningTaskActionOptions = {},
): Promise<SigningTaskRecord | null> { ): Promise<SigningTaskRecord | null> {
if (!useMockData) { if (!useMockData) {
await resendSigningSmsDelivery(id, options.expectedRowVersion) await resendSigningSmsDelivery(id)
return getSigningTaskDetail(id, options) return getSigningTaskDetail(id, options)
} }
@@ -1107,7 +1124,6 @@ export function getSigningStatusLabel(status: SigningTaskStatus) {
pending: '待签署', pending: '待签署',
signing: '签署中', signing: '签署中',
signed: '已签署', signed: '已签署',
rejected: '已拒签',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败', failed: '处理失败',

View File

@@ -9,8 +9,7 @@ export interface WorkbenchOverviewQuery {
rankingPeriod: HomeRankingPeriod rankingPeriod: HomeRankingPeriod
} }
export type SigningTaskStatus = export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void' | 'failed'
export type PatientSex = '男' | '女' | '未知' export type PatientSex = '男' | '女' | '未知'
@@ -171,6 +170,7 @@ export interface SigningApiOptions {
templates?: SigningTemplate[] templates?: SigningTemplate[]
campus?: WorkbenchCampus campus?: WorkbenchCampus
campusId?: string campusId?: string
campusIds?: Record<string, string>
campusNames?: Record<string, WorkbenchCampus> campusNames?: Record<string, WorkbenchCampus>
departmentIds?: Record<string, string> departmentIds?: Record<string, string>
} }
@@ -438,32 +438,29 @@ export interface SignTaskEventResponseDto {
export type SignArtifactType = 'ORIGINAL_PDF' | 'SIGNATURE_IMAGE' | 'SIGNED_PDF' | string export type SignArtifactType = 'ORIGINAL_PDF' | 'SIGNATURE_IMAGE' | 'SIGNED_PDF' | string
/** MEDISIGN 签署产物 DTO。部分文件元数据由不同版本的服务端返回字段保持可选并在 API 边界归一化。 */
export interface SignArtifactResponseDto { export interface SignArtifactResponseDto {
id: string id: string
taskId: string taskId: string
artifactType?: SignArtifactType pipelineId: string
type?: SignArtifactType artifactType: SignArtifactType
fileName?: string | null sizeBytes: number
mimeType?: string | null mimeType: string
contentType?: string | null sha256: string
size?: number | null metadata: Record<string, unknown>
byteSize?: number | null createdAt: string
fileSize?: number | null
sha256?: string | null
contentSha256?: string | null
createdAt?: string | null
} }
export interface SigningArtifact { export interface SigningArtifact {
id: string id: string
taskId: string taskId: string
pipelineId: string
artifactType: SignArtifactType artifactType: SignArtifactType
label: string label: string
fileName: string fileName: string
mimeType: string mimeType: string
size: number sizeBytes: number
sha256: string sha256: string
metadata: Record<string, unknown>
createdAt: string createdAt: string
} }

View File

@@ -5,6 +5,14 @@ export type AuthUserStatus = 'ENABLED' | 'DISABLED'
export interface LoginRequest { export interface LoginRequest {
username: string username: string
password: string password: string
captchaId?: string
captchaCode?: string
}
export interface CaptchaResponse {
captchaId: string
imageBase64: string
expiresAt: string
} }
export interface AuthenticatedUser { export interface AuthenticatedUser {

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getAuthErrorMessage } from '@/api/auth' import { getAuthErrorMessage, getCaptcha } from '@/api/auth'
import type { CaptchaResponse, LoginRequest } from '@/types/auth'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
const route = useRoute() const route = useRoute()
@@ -11,9 +12,27 @@ const loginStore = useLoginStore()
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const captcha = ref<CaptchaResponse | null>(null)
const captchaCode = ref('')
const captchaLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
async function loadCaptcha() {
captchaLoading.value = true
try {
captcha.value = await getCaptcha()
captchaCode.value = ''
} catch {
// 验证码由服务端按需启用;接口不可用时保持账号密码登录兼容。
captcha.value = null
captchaCode.value = ''
} finally {
captchaLoading.value = false
}
}
function getRedirectPath() { function getRedirectPath() {
const redirect = route.query.redirect const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
@@ -29,22 +48,43 @@ async function handleLogin() {
return return
} }
if (captcha.value && !captchaCode.value.trim()) {
errorMessage.value = '请输入图形验证码'
return
}
errorMessage.value = '' errorMessage.value = ''
isSubmitting.value = true isSubmitting.value = true
try { try {
await loginStore.login({ const payload: LoginRequest = {
username: normalizedUsername, username: normalizedUsername,
password: password.value, password: password.value,
}) ...(captcha.value
? {
captchaId: captcha.value.captchaId,
captchaCode: captchaCode.value.trim(),
}
: {}),
}
await loginStore.login(payload)
await router.replace(getRedirectPath()) await router.replace(getRedirectPath())
} catch (error: unknown) { } catch (error: unknown) {
errorMessage.value = getAuthErrorMessage(error) errorMessage.value = getAuthErrorMessage(error)
if (captcha.value) {
void loadCaptcha()
}
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }
} }
onMounted(() => {
void loadCaptcha()
})
</script> </script>
<template> <template>
@@ -98,6 +138,30 @@ async function handleLogin() {
/> />
</div> </div>
<div v-if="captcha" class="login-field login-captcha">
<label for="login-captcha">验证码</label>
<div class="captcha-row">
<input
id="login-captcha"
v-model="captchaCode"
type="text"
autocomplete="one-time-code"
inputmode="text"
maxlength="8"
placeholder="请输入验证码"
/>
<button
type="button"
class="captcha-image-button"
:disabled="captchaLoading"
aria-label="刷新验证码"
@click="loadCaptcha"
>
<img :src="captcha.imageBase64" alt="图形验证码,点击刷新" />
</button>
</div>
</div>
<p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p>
<button class="login-submit" type="submit" :disabled="isSubmitting"> <button class="login-submit" type="submit" :disabled="isSubmitting">
@@ -192,6 +256,45 @@ async function handleLogin() {
border-color: var(--brand); border-color: var(--brand);
} }
.captcha-row {
display: flex;
gap: 8px;
align-items: center;
}
.captcha-row input {
flex: 1;
}
.captcha-image-button {
display: grid;
width: 112px;
height: 34px;
flex-shrink: 0;
padding: 0;
overflow: hidden;
background: #f4f7f9;
border: 1px solid var(--line);
border-radius: 6px;
place-items: center;
}
.captcha-image-button:hover:not(:disabled) {
border-color: var(--brand);
}
.captcha-image-button:disabled {
cursor: wait;
opacity: 0.6;
}
.captcha-image-button img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.login-error { .login-error {
margin: -4px 0 10px; margin: -4px 0 10px;
color: var(--err); color: var(--err);

View File

@@ -226,7 +226,7 @@ function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): R
? 'signed' ? 'signed'
: task.status === 'expired' : task.status === 'expired'
? 'expired' ? 'expired'
: task.status === 'void' || task.status === 'failed' || task.status === 'rejected' : task.status === 'void' || task.status === 'failed'
? 'void' ? 'void'
: 'pending' : 'pending'

View File

@@ -48,7 +48,7 @@ function formatFileSize(size: number) {
</div> </div>
<div class="artifact-info"> <div class="artifact-info">
<strong>{{ artifact.label }}</strong> <strong>{{ artifact.label }}</strong>
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.size) }}</span> <span>{{ artifact.fileName }} · {{ formatFileSize(artifact.sizeBytes) }}</span>
<small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small> <small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small>
</div> </div>
<button type="button" class="artifact-download" @click="emit('download', artifact)"> <button type="button" class="artifact-download" @click="emit('download', artifact)">

View File

@@ -93,7 +93,7 @@ const emit = defineEmits<{
</button> </button>
</template> </template>
<template v-else-if="task.status === 'expired' || task.status === 'failed'"> <template v-else-if="task.status === 'expired'">
<button type="button" class="action-button" @click="emit('action', 'reopen')"> <button type="button" class="action-button" @click="emit('action', 'reopen')">
重新发起 重新发起
</button> </button>
@@ -105,7 +105,7 @@ const emit = defineEmits<{
class="action-button action-button--ghost" class="action-button action-button--ghost"
@click="emit('action', 'download-pdf')" @click="emit('action', 'download-pdf')"
> >
下载 PDF 原件 下载签署后 PDF
</button> </button>
<button <button
type="button" type="button"

View File

@@ -1,22 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types' import type { SigningTaskRecord } from '@/api/workbench/types'
defineProps<{ const props = defineProps<{
tasks: SigningTaskRecord[] tasks: SigningTaskRecord[]
selectedId: string | null selectedId: string | null
total: number total: number
page: number
pageSize: number
loading: boolean loading: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
select: [id: string] select: [id: string]
pageChange: [page: number]
}>() }>()
const pageCount = computed(() => Math.max(Math.ceil(props.total / props.pageSize), 1))
const statusLabels: Record<SigningTaskRecord['status'], string> = { const statusLabels: Record<SigningTaskRecord['status'], string> = {
pending: '待签署', pending: '待签署',
signing: '签署中', signing: '签署中',
signed: '已签署', signed: '已签署',
rejected: '已拒签',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败', failed: '处理失败',
@@ -100,6 +106,21 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
<span aria-hidden="true">🔍</span> <span aria-hidden="true">🔍</span>
<p>没有符合条件的任务</p> <p>没有符合条件的任务</p>
</div> </div>
<footer v-if="total > pageSize" class="task-pagination">
<span> {{ page }} / {{ pageCount }} </span>
<span class="task-pagination__total"> {{ total }} </span>
<button type="button" :disabled="page <= 1 || loading" @click="emit('pageChange', page - 1)">
上一页
</button>
<button
type="button"
:disabled="page >= pageCount || loading"
@click="emit('pageChange', page + 1)"
>
下一页
</button>
</footer>
</section> </section>
</template> </template>
@@ -212,7 +233,6 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
background: var(--ok-l); background: var(--ok-l);
} }
.task-status--rejected,
.task-status--expired { .task-status--expired {
color: var(--err); color: var(--err);
background: var(--err-l); background: var(--err-l);
@@ -292,6 +312,42 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
font-size: 13px; font-size: 13px;
} }
.task-pagination {
display: flex;
gap: 8px;
align-items: center;
padding: 9px 10px;
margin-top: 10px;
color: var(--mut);
font-size: 11.5px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.task-pagination__total {
margin-right: auto;
}
.task-pagination button {
padding: 4px 8px;
color: var(--brand);
font-size: 11.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 5px;
}
.task-pagination button:hover:not(:disabled) {
border-color: var(--brand);
background: var(--brand-l);
}
.task-pagination button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.task-items--loading { .task-items--loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -48,7 +48,6 @@ const STATUS_VALUES: SigningTaskStatus[] = [
'pending', 'pending',
'signing', 'signing',
'signed', 'signed',
'rejected',
'expired', 'expired',
'void', 'void',
'failed', 'failed',
@@ -59,7 +58,6 @@ const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署', pending: '待签署',
signing: '签署中', signing: '签署中',
signed: '已签署', signed: '已签署',
rejected: '已拒签',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败', failed: '处理失败',
@@ -78,6 +76,8 @@ const filter = reactive<SigningFilterForm>({
const tasks = ref<SigningTaskRecord[]>([]) const tasks = ref<SigningTaskRecord[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedTaskId = ref<string | null>(null) const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null) const selectedTask = ref<SigningTaskRecord | null>(null)
const taskEvents = ref<SigningTaskEvent[]>([]) const taskEvents = ref<SigningTaskEvent[]>([])
@@ -145,6 +145,7 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
const signingApiOptions = computed(() => ({ const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus, campus: appStore.selectedCampus,
campusId: campusIds.value[appStore.selectedCampus], campusId: campusIds.value[appStore.selectedCampus],
campusIds: campusIds.value,
campusNames: campusNames.value, campusNames: campusNames.value,
departmentIds: departmentIds.value, departmentIds: departmentIds.value,
templates: templates.value, templates: templates.value,
@@ -275,7 +276,11 @@ async function loadTaskDetail(id: string | null) {
} }
} }
async function loadTasks(preferredTaskId?: string) { async function loadTasks(preferredTaskId?: string, resetPage = false) {
if (resetPage) {
page.value = 1
}
const requestId = ++listRequestId const requestId = ++listRequestId
loading.value = true loading.value = true
@@ -283,8 +288,8 @@ async function loadTasks(preferredTaskId?: string) {
const response = await getSigningTasks( const response = await getSigningTasks(
{ {
...filter, ...filter,
page: 1, page: page.value,
pageSize: 50, pageSize: pageSize.value,
}, },
signingApiOptions.value, signingApiOptions.value,
) )
@@ -295,6 +300,8 @@ async function loadTasks(preferredTaskId?: string) {
tasks.value = response.records tasks.value = response.records
total.value = response.total total.value = response.total
page.value = response.page
pageSize.value = response.pageSize
const taskIds = new Set(response.records.map((task) => task.id)) const taskIds = new Set(response.records.map((task) => task.id))
const routeTaskId = getQueryString(route.query.taskId) const routeTaskId = getQueryString(route.query.taskId)
@@ -323,7 +330,7 @@ async function loadTasks(preferredTaskId?: string) {
function handleSearch(nextFilter: SigningFilterForm) { function handleSearch(nextFilter: SigningFilterForm) {
Object.assign(filter, nextFilter) Object.assign(filter, nextFilter)
selectedTaskId.value = null selectedTaskId.value = null
void loadTasks() void loadTasks(undefined, true)
} }
function selectTask(id: string) { function selectTask(id: string) {
@@ -331,13 +338,25 @@ function selectTask(id: string) {
void loadTaskDetail(id) void loadTaskDetail(id)
} }
function handlePageChange(nextPage: number) {
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
return
}
page.value = nextPage
selectedTaskId.value = null
void loadTasks()
}
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) { async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
if (!updatedTask) { if (!updatedTask) {
ElMessage.error('签署任务不存在或已被删除') ElMessage.error('签署任务不存在或已被删除')
return return
} }
await loadTasks(updatedTask.id) await loadTasks(updatedTask.id, true)
ElMessage.success(successMessage) ElMessage.success(successMessage)
} }
@@ -389,15 +408,18 @@ async function handleTaskAction(action: SigningTaskAction) {
return return
} }
if (action === 'reopen' && task.status !== 'expired') {
ElMessage.info('只有已超时任务可以重新发起')
return
}
if (action === 'download-pdf') { if (action === 'download-pdf') {
const artifact = const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF')
artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF') ??
artifacts.value.find((item) => item.artifactType === 'ORIGINAL_PDF')
if (artifact) { if (artifact) {
await downloadArtifact(artifact) await downloadArtifact(artifact)
} else { } else {
ElMessage.info('当前任务暂无 PDF 文件') ElMessage.info('当前任务暂无签署后 PDF')
} }
return return
} }
@@ -535,13 +557,14 @@ async function initializePage() {
} }
async function handleTaskCreated(task: SigningTaskRecord) { async function handleTaskCreated(task: SigningTaskRecord) {
await loadTasks(task.id) await loadTasks(task.id, true)
} }
watch( watch(
() => [route.query.status, route.query.range, route.query.taskId], () => [route.query.status, route.query.range, route.query.taskId],
() => { () => {
syncFilterFromRoute() syncFilterFromRoute()
page.value = 1
selectedTaskId.value = null selectedTaskId.value = null
void loadTasks() void loadTasks()
}, },
@@ -553,6 +576,7 @@ watch(
if (filter.campus !== 'all') { if (filter.campus !== 'all') {
filter.campus = campus filter.campus = campus
} }
page.value = 1
void loadTasks() void loadTasks()
}, },
) )
@@ -593,8 +617,11 @@ onMounted(() => {
:tasks="tasks" :tasks="tasks"
:selected-id="selectedTaskId" :selected-id="selectedTaskId"
:total="total" :total="total"
:page="page"
:page-size="pageSize"
:loading="loading" :loading="loading"
@select="selectTask" @select="selectTask"
@page-change="handlePageChange"
/> />
<SigningTaskDetail <SigningTaskDetail
:task="selectedTask" :task="selectedTask"

View File

@@ -11,7 +11,11 @@ import type {
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> { export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
return request return request
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload) .post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
headers: {
'Idempotency-Key': createIdempotencyKey(),
},
})
.then(unwrapApiResponse) .then(unwrapApiResponse)
} }