feat(client): 完成 P3 业务页联动闭环(D6/D8/D10/D11)

- 待办:列表/详情 + approve/reject/return/transfer(D6)
- 报销:列表/详情 + create/submit/withdraw/pay(D8)
- 票据:列表/上传/OCR轮询/绑定/预览(D10)
- 统一:Api types + 错误映射 + react-query缓存失效(D11)
这个提交包含在:
2026-02-08 23:08:41 +08:00
父节点 65e7b37ebd
当前提交 7a6f5573a6
共修改 16 个文件,包含 1762 行新增和 31 行删除
+62
查看文件
@@ -0,0 +1,62 @@
export function mapApiErrorMessage(
code: string,
details?: Record<string, unknown>,
fallbackMessage?: string,
) {
if (code === "AUTH_001") {
return "账号或密码错误,请检查后重试。";
}
if (code === "AUTH_004") {
return "登录状态失效,请重新登录。";
}
if (code === "PERM_001") {
return "当前账号无权限执行该操作。";
}
if (code === "PERM_002") {
return "你不是当前任务处理人,无法执行该操作。";
}
if (code === "SYS_404") {
return "目标数据不存在或已被删除。";
}
if (code === "VALID_002") {
return "输入内容长度不足,请补充后再提交。";
}
if (code === "VALID_001") {
if (details?.payment === "already_paid_conflict") {
return "该报销单已支付且付款信息冲突,请刷新后确认。";
}
if (details?.status === "invalid_pay_status") {
return "当前状态不允许执行付款。";
}
if (details?.transfer === "chain_limit_exceeded") {
return "当前任务转审次数已达上限。";
}
if (details?.transfer === "same_assignee_not_allowed") {
return "不能将任务转审给自己。";
}
if (details?.toApproverId === "target_not_approver") {
return "目标人员不具备审批权限。";
}
if (details?.toApproverId === "invalid_target") {
return "目标审批人不存在或已停用。";
}
if (details?.task === "already_processed") {
return "该任务已被处理,请刷新列表。";
}
}
return fallbackMessage || "请求失败,请稍后重试。";
}
+130
查看文件
@@ -0,0 +1,130 @@
import { apiRequestData } from "./http";
export type InvoicePoolStatus = "pending_ocr" | "ocr_failed" | "available" | "bound" | "archived";
export type InvoicePoolListItem = {
id: string;
status: InvoicePoolStatus;
ocrType: string;
ocrProvider: string;
ocrAttempts: number;
ocrLastError: string | null;
ocrCompletedAt: string | null;
invoiceCode: string | null;
invoiceNumber: string | null;
invoiceDate: string | null;
amount: string | null;
taxAmount: string | null;
totalAmount: string | null;
linkedReimbursementId: string | null;
linkedItemId: string | null;
originalName: string;
mimeType: string;
fileSize: number;
owner: {
id: string;
employeeNo: string;
name: string;
} | null;
uploadedBy: {
id: string;
employeeNo: string;
name: string;
} | null;
createdAt: string | null;
updatedAt: string | null;
};
export type InvoicePoolListResponse = {
page: number;
pageSize: number;
total: number;
ownerScope: "own" | "all";
items: InvoicePoolListItem[];
};
export type InvoicePoolDetail = {
id: string;
status: InvoicePoolStatus;
ownerEmployeeId: string;
uploadedByEmployeeId: string;
ocrType: string;
ocrProvider: string;
ocrAttempts: number;
ocrLastError: string | null;
ocrCompletedAt: string | null;
invoiceCode: string | null;
invoiceNumber: string | null;
invoiceDate: string | null;
amount: string | null;
taxAmount: string | null;
totalAmount: string | null;
linkedReimbursementId: string | null;
linkedItemId: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type InvoicePoolListQuery = {
page?: number;
pageSize?: number;
status?: InvoicePoolStatus;
ownerScope?: "own" | "all";
};
function toQueryString(query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, String(value));
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchInvoicePoolList(query: InvoicePoolListQuery = {}) {
return apiRequestData<InvoicePoolListResponse>(
`/api/invoice-pool${toQueryString(query as Record<string, string | number | undefined>)}`,
);
}
export async function fetchInvoicePoolDetail(invoiceId: string) {
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}`);
}
export async function uploadInvoice(file: File, ocrType?: string) {
const formData = new FormData();
formData.append("file", file);
if (ocrType) {
formData.append("ocr_type", ocrType);
}
return apiRequestData<{ invoicePoolId: string; status: string; fileSha256: string }>(
"/api/invoice-pool/upload",
{
method: "POST",
body: formData,
},
);
}
export async function bindInvoice(invoiceId: string, reimbursementId: string, linkedItemId?: string) {
return apiRequestData<Record<string, unknown>>(`/api/invoice-pool/${invoiceId}/bind`, {
method: "POST",
body: JSON.stringify({ reimbursementId, linkedItemId }),
});
}
export async function fetchInvoiceSignedUrl(invoiceId: string) {
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
`/api/invoice-pool/${invoiceId}/signed-url`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
+152
查看文件
@@ -0,0 +1,152 @@
import { apiRequestData } from "./http";
export type ReimbursementStatus =
| "draft"
| "reviewing"
| "approved"
| "paid"
| "rejected"
| "cancelled";
export type ReimbursementListItem = {
id: string;
formNumber: string | null;
status: ReimbursementStatus;
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
payment: {
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: string | null;
paidAt: string | null;
paidBy: string | null;
};
owner: {
id: string;
employeeNo: string;
name: string;
} | null;
submitter: {
id: string;
employeeNo: string;
name: string;
} | null;
createdAt: string | null;
updatedAt: string | null;
};
export type ReimbursementListResponse = {
page: number;
pageSize: number;
total: number;
scope: "own" | "all";
items: ReimbursementListItem[];
};
export type ReimbursementDetail = {
id: string;
formNumber: string | null;
submitterId: string;
ownerId: string;
departmentId: string;
status: ReimbursementStatus;
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
owner: {
id: string;
employee_no: string;
name: string;
} | null;
submitter: {
id: string;
employee_no: string;
name: string;
} | null;
invoices: Array<{
id: string;
status: string;
invoiceCode: string | null;
invoiceNumber: string | null;
totalAmount: string | null;
}>;
pendingWorkflow: {
instanceId: string;
stepId: string;
task: {
id: string;
step_name: string;
assignee_id: string | null;
assignee_role: string | null;
} | null;
} | null;
};
export type ReimbursementListQuery = {
page?: number;
pageSize?: number;
status?: ReimbursementStatus;
scope?: "own" | "all";
};
export type ReimbursementPayPayload = {
paymentVoucherNo?: string;
paymentDate?: string;
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
};
function toQueryString(query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, String(value));
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchReimbursements(query: ReimbursementListQuery = {}) {
return apiRequestData<ReimbursementListResponse>(
`/api/reimbursements${toQueryString(query as Record<string, string | number | undefined>)}`,
);
}
export async function fetchReimbursementDetail(reimbursementId: string) {
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
}
export async function createReimbursement(payload: {
reimbursementDate?: string;
description?: string;
} = {}) {
return apiRequestData<{ id: string }>(`/api/reimbursements`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function submitReimbursement(reimbursementId: string) {
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/submit`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function withdrawReimbursement(reimbursementId: string, comment?: string) {
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, {
method: "POST",
body: JSON.stringify({ comment }),
});
}
export async function payReimbursement(reimbursementId: string, payload: ReimbursementPayPayload = {}) {
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/pay`, {
method: "POST",
body: JSON.stringify(payload),
});
}
+47
查看文件
@@ -65,3 +65,50 @@ export class ApiError extends Error {
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
export type WorkflowTaskSummary = {
id: string;
instanceId: string;
stepId: string;
stepName: string;
assigneeId: string | null;
assigneeRole: string | null;
status: string;
outcome: string | null;
createdAt: string;
completedAt: string | null;
};
export type WorkflowTaskDetail = {
task: {
id: string;
instanceId: string;
stepId: string;
stepName: string;
assigneeId: string | null;
assigneeRole: string | null;
status: string;
outcome: string | null;
comment: string | null;
createdAt: string | null;
completedAt: string | null;
};
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
};
export type ReimbursementListItem = {
id: string;
formNumber: string | null;
status: string;
reimbursementDate: string | null;
totalAmount: string | null;
};
export type InvoicePoolListItem = {
id: string;
status: string;
invoiceCode: string | null;
invoiceNumber: string | null;
ocrLastError: string | null;
};
+163
查看文件
@@ -0,0 +1,163 @@
import { apiRequestData } from "./http";
export type WorkflowTaskSummary = {
id: string;
instanceId: string;
stepId: string;
stepName: string;
assigneeId: string | null;
assigneeRole: string | null;
status: "pending" | "completed" | "rejected" | "cancelled" | "skipped";
outcome: string | null;
createdAt: string;
completedAt: string | null;
workflowInstance: {
entityType: string;
entityId: string;
currentStatus: string;
} | null;
};
export type WorkflowTaskListResponse = {
page: number;
pageSize: number;
total: number;
items: WorkflowTaskSummary[];
};
export type WorkflowHistoryItem = {
id: string;
taskId: string | null;
actorId: string;
actorName: string;
actorEmployeeNo: string;
action: string;
comment: string | null;
payload: Record<string, unknown> | null;
createdAt: string | null;
};
export type WorkflowTaskDetail = {
task: {
id: string;
instanceId: string;
stepId: string;
stepName: string;
assigneeId: string | null;
assigneeRole: string | null;
status: string;
outcome: string | null;
comment: string | null;
createdAt: string | null;
completedAt: string | null;
};
workflowInstance: {
id: string;
currentStatus: string;
currentStepId: string;
startedAt: string | null;
completedAt: string | null;
} | null;
reimbursement: {
id: string;
formNumber: string | null;
status: string;
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
owner: {
id: string;
employeeNo: string;
name: string;
} | null;
submitter: {
id: string;
employeeNo: string;
name: string;
} | null;
} | null;
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
history: WorkflowHistoryItem[];
};
export type WorkflowApprover = {
id: string;
employeeNo: string;
name: string;
email: string;
roleCodes: string[];
};
export type WorkflowApproverListResponse = {
page: number;
pageSize: number;
total: number;
items: WorkflowApprover[];
};
export type WorkflowTaskListQuery = {
status?: "pending" | "completed" | "rejected" | "cancelled" | "skipped";
page?: number;
pageSize?: number;
};
export type WorkflowApproverQuery = {
keyword?: string;
page?: number;
pageSize?: number;
};
function toQueryString(query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, String(value));
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchWorkflowTasks(query: WorkflowTaskListQuery) {
return apiRequestData<WorkflowTaskListResponse>(`/api/workflow/tasks${toQueryString(query)}`);
}
export async function fetchWorkflowTaskDetail(taskId: string) {
return apiRequestData<WorkflowTaskDetail>(`/api/workflow/tasks/${taskId}`);
}
export async function fetchWorkflowApprovers(query: WorkflowApproverQuery = {}) {
return apiRequestData<WorkflowApproverListResponse>(
`/api/workflow/approvers${toQueryString(query as Record<string, string | number | undefined>)}`,
);
}
export async function approveWorkflowTask(taskId: string) {
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/approve`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function rejectWorkflowTask(taskId: string, comment: string) {
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/reject`, {
method: "POST",
body: JSON.stringify({ comment }),
});
}
export async function returnWorkflowTask(taskId: string, comment: string) {
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/return`, {
method: "POST",
body: JSON.stringify({ comment }),
});
}
export async function transferWorkflowTask(taskId: string, toApproverId: string, reason: string) {
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/transfer`, {
method: "POST",
body: JSON.stringify({ toApproverId, reason }),
});
}