云
2026-09-29 06f814c1275892783291d4b26b3271d5c2b33ffb
feat(archive): 添加档案管理模块及相关功能

- 新增档案管理字典常量定义(分类状态、文档状态、密级等)
- 为报销单、课题申报、会员增加归档字段和归档接口
- 实现报销单、课题申报、会员归档功能及界面按钮
- 添加档案借阅管理的数据模型和表单配置
- 添加档案分类管理的数据模型和表单配置
- 更新归档相关API返回生成的档案编号信息
已添加18个文件
已修改7个文件
已删除3个文件
2916 ■■■■■ 文件已修改
.playwright-mcp/_e2e_attachment.txt 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-09-28T10-11-52-064Z.yml 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-09-28T10-11-57-412Z.yml 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/borrow/index.ts 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/category/index.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/document/index.ts 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/stat/index.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/finance/reimbursement/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/application/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/member/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/data.ts 307 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/detail/approve/index.vue 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/index.vue 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/modules/form.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/modules/process-select-modal.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/modules/return-form.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/category/data.ts 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/category/index.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/category/modules/form.vue 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/document/data.ts 442 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/document/index.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/document/modules/form.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/stat/data.ts 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/stat/index.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/index.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/_e2e_attachment.txt
ÎļþÒÑɾ³ý
.playwright-mcp/page-2026-09-28T10-11-52-064Z.yml
ÎļþÒÑɾ³ý
.playwright-mcp/page-2026-09-28T10-11-57-412Z.yml
ÎļþÒÑɾ³ý
src/api/archive/borrow/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ArchiveBorrowApi {
  /** å€Ÿé˜…单 */
  export interface Borrow {
    id?: number; // å€Ÿé˜…单编号
    no?: string; // å€Ÿé˜…单号(后端生成:JY+yyyyMMdd+6位流水,只读)
    documentId: number; // æ¡£æ¡ˆ ID
    documentNo?: string; // æ¡£æ¡ˆç¼–号(快照)
    documentTitle?: string; // æ¡£æ¡ˆæ ‡é¢˜ï¼ˆå¿«ç…§ï¼‰
    borrowerUserId?: number; // å€Ÿé˜…人用户 ID
    borrowerName: string; // å€Ÿé˜…人姓名
    borrowerDept?: string; // å€Ÿé˜…人部门
    borrowerPhone?: string; // å€Ÿé˜…人联系电话
    borrowDate: string; // å€Ÿé˜…日期
    borrowPurpose?: string; // å€Ÿé˜…目的
    returnDeadline: string; // å½’还期限
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ archive_borrow_status
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ ID
    approveTime?: string; // å®¡æ‰¹é€šè¿‡ï¼ˆå®žé™…借出)时间
    returnTime?: string; // å®žé™…归还时间
    returnConfirmUserId?: number; // å½’还确认人用户 ID
    returnConfirmUserName?: string; // å½’还确认人姓名
    returnRemark?: string; // å½’还备注
    rejectReason?: string; // é©³å›žåŽŸå› 
    rejectTime?: string; // é©³å›žæ—¶é—´
    remark?: string; // å¤‡æ³¨
    overdue?: boolean; // æ˜¯å¦é€¾æœŸï¼ˆåŽç«¯å®žæ—¶è®¡ç®—)
    overdueDays?: number; // é€¾æœŸå¤©æ•°ï¼ˆåŽç«¯å®žæ—¶è®¡ç®—)
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
  /** å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ProcessDefinition {
    id: string;
    key: string;
    name: string;
  }
  /** å½’还登记请求 */
  export interface ReturnReq {
    id: number; // å€Ÿé˜…单编号
    returnRemark?: string; // å½’还备注
  }
}
/** æŸ¥è¯¢å€Ÿé˜…单分页 */
export function getBorrowPage(params: PageParam) {
  return requestClient.get<PageResult<ArchiveBorrowApi.Borrow>>(
    '/archive/borrow/page',
    { params },
  );
}
/** æŸ¥è¯¢é€¾æœŸæœªè¿˜å€Ÿé˜…单分页 */
export function getOverdueBorrowPage(params: PageParam) {
  return requestClient.get<PageResult<ArchiveBorrowApi.Borrow>>(
    '/archive/borrow/overdue-page',
    { params },
  );
}
/** æŸ¥è¯¢å€Ÿé˜…单详情 */
export function getBorrow(id: number) {
  return requestClient.get<ArchiveBorrowApi.Borrow>(
    `/archive/borrow/get?id=${id}`,
  );
}
/** æ–°å¢žå€Ÿé˜…单 */
export function createBorrow(data: ArchiveBorrowApi.Borrow) {
  return requestClient.post('/archive/borrow/create', data);
}
/** ä¿®æ”¹å€Ÿé˜…单 */
export function updateBorrow(data: ArchiveBorrowApi.Borrow) {
  return requestClient.put('/archive/borrow/update', data);
}
/** åˆ é™¤å€Ÿé˜…单 */
export function deleteBorrow(id: number) {
  return requestClient.delete('/archive/borrow/delete', {
    params: { id },
  });
}
/** æäº¤å€Ÿé˜…单审批 */
export function submitBorrow(id: number, processDefinitionKey?: string) {
  return requestClient.post('/archive/borrow/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–æ¡£æ¡ˆå€Ÿé˜…å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getBorrowApproveProcessList() {
  return requestClient.get<ArchiveBorrowApi.ProcessDefinition[]>(
    '/archive/borrow/approve-process-list',
  );
}
/** å½’还登记 */
export function returnBorrow(data: ArchiveBorrowApi.ReturnReq) {
  return requestClient.post('/archive/borrow/return', data);
}
src/api/archive/category/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
import { requestClient } from '#/api/request';
export namespace ArchiveCategoryApi {
  /** æ¡£æ¡ˆåˆ†ç±»ï¼ˆä¹¦æž¶/分类树) */
  export interface Category {
    id?: number; // åˆ†ç±»ç¼–号
    parentId?: number; // çˆ¶åˆ†ç±» ID,0=顶级
    name?: string; // åˆ†ç±»åç§°
    code?: string; // åˆ†ç±»ç¼–码,唯一(member/research/meeting/finance/document/party)
    sort?: number; // æ˜¾ç¤ºæŽ’序
    description?: string; // åˆ†ç±»è¯´æ˜Ž
    defaultSecretLevel?: number; // è¯¥åˆ†ç±»ä¸‹æ¡£æ¡ˆé»˜è®¤å¯†çº§ï¼Œå­—å…¸ archive_secret_level
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ archive_category_status
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    children?: Category[]; // å­åˆ†ç±»ï¼ˆæ ‘形返回)
  }
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†ç±»åˆ—表(平铺) */
export function getCategoryList(params?: ArchiveCategoryApi.Category) {
  return requestClient.get<ArchiveCategoryApi.Category[]>(
    '/archive/category/list',
    { params },
  );
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†ç±»æ ‘ */
export function getCategoryTree() {
  return requestClient.get<ArchiveCategoryApi.Category[]>(
    '/archive/category/tree',
  );
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†ç±»è¯¦æƒ… */
export function getCategory(id: number) {
  return requestClient.get<ArchiveCategoryApi.Category>(
    `/archive/category/get?id=${id}`,
  );
}
/** æ–°å¢žæ¡£æ¡ˆåˆ†ç±» */
export function createCategory(data: ArchiveCategoryApi.Category) {
  return requestClient.post('/archive/category/create', data);
}
/** ä¿®æ”¹æ¡£æ¡ˆåˆ†ç±» */
export function updateCategory(data: ArchiveCategoryApi.Category) {
  return requestClient.put('/archive/category/update', data);
}
/** åˆ é™¤æ¡£æ¡ˆåˆ†ç±» */
export function deleteCategory(id: number) {
  return requestClient.delete('/archive/category/delete', {
    params: { id },
  });
}
src/api/archive/document/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace ArchiveDocumentApi {
  /** æ¡£æ¡ˆï¼ˆç”µå­é™„ä»¶ + å®žä½“存放位置) */
  export interface Document {
    id?: number; // æ¡£æ¡ˆç¼–号
    no?: string; // æ¡£æ¡ˆç¼–号(后端生成:DA+yyyyMMdd+6位流水,只读)
    title: string; // æ¡£æ¡ˆæ ‡é¢˜
    categoryId: number; // æ¡£æ¡ˆåˆ†ç±» ID
    categoryName?: string; // åˆ†ç±»åç§°ï¼ˆåŽç«¯å†—余)
    secretLevel?: number; // å¯†çº§ï¼Œå­—å…¸ archive_secret_level
    carrierType?: number; // è½½ä½“,字典 archive_carrier_type
    shelfNo?: string; // å®žä½“-书架号
    layerNo?: string; // å®žä½“-层号
    columnNo?: string; // å®žä½“-列号
    boxNo?: string; // å®žä½“-册号/档案盒号
    storePlace?: string; // å®žä½“存放位置描述
    sourceType?: number; // æ¥æºï¼Œå­—å…¸ archive_source_type(后端写,前端只读)
    sourceNo?: string; // æ¥æºå•号(立项编号/会员编号/报销单号)
    sourceId?: number; // æ¥æºå•据 ID
    sourceTitle?: string; // æ¥æºå•据标题
    archiveDate?: string; // å½’档日期
    archivistUserId?: number; // å½’档人用户 ID
    archivistName?: string; // å½’档人姓名
    status?: number; // æ¡£æ¡ˆçŠ¶æ€ï¼Œå­—å…¸ archive_document_status
    summary?: string; // æ‘˜è¦
    tags?: string; // å…³é”®è¯ï¼Œè‹±æ–‡é€—号分隔
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰é™„ä»¶
    blobIds?: number[]; // æäº¤æ—¶çš„附件 ID åˆ—表(全量)
  }
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†é¡µ */
export function getDocumentPage(params: PageParam) {
  return requestClient.get<PageResult<ArchiveDocumentApi.Document>>(
    '/archive/document/page',
    { params },
  );
}
/** æŸ¥è¯¢æ¡£æ¡ˆè¯¦æƒ… */
export function getDocument(id: number) {
  return requestClient.get<ArchiveDocumentApi.Document>(
    `/archive/document/get?id=${id}`,
  );
}
/** æ–°å¢žæ¡£æ¡ˆ */
export function createDocument(data: ArchiveDocumentApi.Document) {
  return requestClient.post('/archive/document/create', data);
}
/** ä¿®æ”¹æ¡£æ¡ˆ */
export function updateDocument(data: ArchiveDocumentApi.Document) {
  return requestClient.put('/archive/document/update', data);
}
/** åˆ é™¤æ¡£æ¡ˆ */
export function deleteDocument(id: number) {
  return requestClient.delete('/archive/document/delete', {
    params: { id },
  });
}
/** å¯¼å‡ºæ¡£æ¡ˆ Excel */
export function exportDocument(params: ArchiveDocumentApi.Document) {
  return requestClient.download('/archive/document/export-excel', { params });
}
src/api/archive/stat/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import { requestClient } from '#/api/request';
export namespace ArchiveStatApi {
  /** åˆ†ç±»ç»´åº¦ç»Ÿè®¡é¡¹ */
  export interface StatCategory {
    categoryId?: number; // åˆ†ç±» ID
    name?: string; // åˆ†ç±»åç§°
    total?: number; // æ¡£æ¡ˆæ•°é‡
  }
  /** å­—典值维度统计项 */
  export interface StatValue {
    value?: number; // å­—典值
    name?: string; // å­—典标签(后端转义)
    total?: number; // æ¡£æ¡ˆæ•°é‡
  }
  /** æ¡£æ¡ˆç»Ÿè®¡æ¦‚览 */
  export interface StatOverview {
    totalCount?: number; // æ¡£æ¡ˆæ€»æ•°
    inStockCount?: number; // åœ¨åº“档案数
    borrowedCount?: number; // å€Ÿå‡ºä¸­æ¡£æ¡ˆæ•°
    borrowingCount?: number; // å€Ÿå‡ºä¸­å€Ÿé˜…单数
    overdueCount?: number; // é€¾æœŸæœªè¿˜å€Ÿé˜…单数
    byCategory?: StatCategory[]; // æŒ‰åˆ†ç±»åˆ†å¸ƒ
    bySecretLevel?: StatValue[]; // æŒ‰å¯†çº§åˆ†å¸ƒ
    byCarrierType?: StatValue[]; // æŒ‰è½½ä½“分布
  }
}
/** æŸ¥è¯¢æ¡£æ¡ˆç»Ÿè®¡æ¦‚览 */
export function getArchiveStatOverview() {
  return requestClient.get<ArchiveStatApi.StatOverview>(
    '/archive/stat/overview',
  );
}
src/api/finance/reimbursement/index.ts
@@ -29,6 +29,8 @@
    applyDate: string; // ç”³è¯·æ—¥æœŸ yyyy-MM-dd
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ ID
    archiveTime?: string; // å½’档时间
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已生成档案)
    archived?: boolean; // æ˜¯å¦å·²ç”Ÿæˆæ¡£æ¡ˆ
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
@@ -91,9 +93,9 @@
  });
}
/** å½’档报销单(仅已通过可归档) */
/** å½’档报销单(仅已通过可归档),返回生成的档案编号 */
export function archiveReimbursement(id: number) {
  return requestClient.post('/finance/reimbursement/archive', null, {
  return requestClient.post<string>('/finance/reimbursement/archive', null, {
    params: { id },
  });
}
src/api/research/application/index.ts
@@ -30,6 +30,8 @@
    researchNo?: string; // ç«‹é¡¹ç¼–号,审批通过后生成
    approveTime?: string; // ç«‹é¡¹æ—¶é—´
    noticeId?: number; // å·²å‘布的立项通知公告 ID
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已归档)
    archived?: boolean; // æ˜¯å¦å·²å½’æ¡£
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
@@ -120,6 +122,13 @@
  });
}
/** å½’档到档案管理(仅已立项可归档),返回生成的档案编号 */
export function archiveApplication(id: number) {
  return requestClient.post<string>('/research/application/archive', null, {
    params: { id },
  });
}
/** æŸ¥è¯¢æŸç”³æŠ¥ä¹¦çš„专家评审明细列表 */
export function getTopicReviewList(applicationId: number) {
  return requestClient.get<ResearchApplicationApi.TopicReview[]>(
src/api/ums/member/index.ts
@@ -29,6 +29,8 @@
    joinDate?: string; // å…¥ä¼šæ—¥æœŸ
    expireDate?: string; // æœ‰æ•ˆæœŸè‡³
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ ums_member_status
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已归档)
    archived?: boolean; // æ˜¯å¦å·²å½’æ¡£
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
@@ -71,4 +73,11 @@
  return requestClient.delete('/ums/member/delete', {
    params: { id },
  });
}
/** å½’档到档案管理(仅正常状态会员可归档),返回生成的档案编号 */
export function archiveMember(id: number) {
  return requestClient.post<string>('/ums/member/archive', null, {
    params: { id },
  });
}
src/packages/constants/src/dict-enum.ts
@@ -169,6 +169,16 @@
  RESEARCH_FUND_STATUS: 'research_fund_status', // ç»è´¹æ‹¨ä»˜çŠ¶æ€
} as const;
/** ========== ARCHIVE - æ¡£æ¡ˆç®¡ç†æ¨¡å— ========== */
const ARCHIVE_DICT = {
  ARCHIVE_CATEGORY_STATUS: 'archive_category_status', // æ¡£æ¡ˆåˆ†ç±»å¯ç”¨/停用
  ARCHIVE_DOCUMENT_STATUS: 'archive_document_status', // æ¡£æ¡ˆçŠ¶æ€ï¼ˆåœ¨åº“/借出中)
  ARCHIVE_SECRET_LEVEL: 'archive_secret_level', // æ¡£æ¡ˆå¯†çº§ï¼ˆå…¬å¼€/内部/保密)
  ARCHIVE_CARRIER_TYPE: 'archive_carrier_type', // æ¡£æ¡ˆè½½ä½“(电子/实体/电子+实体)
  ARCHIVE_SOURCE_TYPE: 'archive_source_type', // æ¡£æ¡ˆæ¥æºï¼ˆæ‰‹å·¥/课题/会员/财务)
  ARCHIVE_BORROW_STATUS: 'archive_borrow_status', // å€Ÿé˜…单状态
} as const;
/** ========== AI - äººå·¥æ™ºèƒ½æ¨¡å— ========== */
const AI_DICT = {
  AI_GENERATE_MODE: 'ai_generate_mode', // AI ç”Ÿæˆæ¨¡å¼
@@ -373,6 +383,7 @@
const DICT_TYPE = {
  ...AFTERSALES_DICT,
  ...AI_DICT,
  ...ARCHIVE_DICT,
  ...BPM_DICT,
  ...CRM_DICT,
  ...ERP_DICT,
src/views/archive/borrow/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,307 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getDocumentPage } from '#/api/archive/document';
import { getSimpleUserList } from '#/api/system/user';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** å€Ÿé˜…单状态(对应字典 archive_borrow_status) */
export const BorrowStatus = {
  DRAFT: 0, // è‰ç¨¿
  APPROVING: 15, // å®¡æ‰¹ä¸­
  BORROWED: 20, // å€Ÿå‡ºä¸­
  RETURNED: 30, // å·²å½’还
  REJECTED: 35, // å·²é©³å›ž
} as const;
/** å€Ÿé˜…登记表单配置 */
export function useFormSchema(
  formType: FormType,
  documentId?: number,
): VbenFormSchema[] {
  const readonly = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'documentId',
      label: '借阅档案',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '请选择在库档案',
        api: async () => {
          const { list } = await getDocumentPage({
            pageNo: 1,
            pageSize: 100,
            status: 10,
          });
          return list.map((item) => ({
            id: item.id,
            label: item.no ? `${item.no} ${item.title}` : item.title,
          }));
        },
        labelField: 'label',
        valueField: 'id',
        disabled: readonly || !!documentId,
      },
      rules: 'selectRequired',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'borrowerUserId',
      label: '借阅人',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '请选择借阅人',
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'borrowerName',
      label: '借阅人姓名',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅人姓名',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'borrowerDept',
      label: '借阅人部门',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅人部门',
        disabled: readonly,
      },
    },
    {
      fieldName: 'borrowerPhone',
      label: '联系电话',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入联系电话',
        disabled: readonly,
      },
    },
    {
      fieldName: 'borrowDate',
      label: '借阅日期',
      component: 'DatePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择借阅日期',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'returnDeadline',
      label: '归还期限',
      component: 'DatePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择归还期限',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'borrowPurpose',
      label: '借阅目的',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入借阅目的',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入备注',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '借阅单号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅单号',
      },
    },
    {
      fieldName: 'documentNo',
      label: '档案编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案编号',
      },
    },
    {
      fieldName: 'documentTitle',
      label: '档案标题',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案标题',
      },
    },
    {
      fieldName: 'borrowerName',
      label: '借阅人',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅人姓名',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_BORROW_STATUS, 'number'),
        allowClear: true,
        placeholder: '请选择状态',
      },
    },
    {
      fieldName: 'borrowDate',
      label: '借阅日期',
      component: 'RangePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: ['开始日期', '结束日期'],
      },
    },
    {
      fieldName: 'returnDeadline',
      label: '归还期限',
      component: 'RangePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: ['开始日期', '结束日期'],
      },
    },
  ];
}
/** è¡¨æ ¼åˆ—配置 */
export function useGridColumns(): VxeTableGridOptions<ArchiveBorrowApi.Borrow>['columns'] {
  return [
    {
      field: 'no',
      title: '借阅单号',
      minWidth: 170,
    },
    {
      field: 'documentNo',
      title: '档案编号',
      minWidth: 170,
    },
    {
      field: 'documentTitle',
      title: '档案标题',
      minWidth: 200,
    },
    {
      field: 'borrowerName',
      title: '借阅人',
      minWidth: 100,
    },
    {
      field: 'borrowerDept',
      title: '部门',
      minWidth: 120,
    },
    {
      field: 'borrowDate',
      title: '借阅日期',
      minWidth: 120,
    },
    {
      field: 'returnDeadline',
      title: '归还期限',
      minWidth: 120,
    },
    {
      field: 'overdue',
      title: '逾期',
      minWidth: 110,
      slots: { default: 'overdue' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_BORROW_STATUS },
      },
    },
    {
      field: 'approveTime',
      title: '借出时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'returnTime',
      title: '归还时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 230,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/archive/borrow/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
import { getBorrow } from '#/api/archive/borrow';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'ArchiveBorrowApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ArchiveBorrowApi.Borrow>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** æ—¥æœŸæ—¶é—´å­—段空值占位 */
function timeRender(val: unknown) {
  return val ? (formatDateTime(val as string) as string) : '-';
}
/** é€¾æœŸå±•示 */
function overdueRender(_: unknown, record: ArchiveBorrowApi.Borrow) {
  return record?.overdue
    ? h(Tag, { color: 'error' }, () => `逾期 ${record.overdueDays} å¤©`)
    : h(Tag, { color: 'success' }, () => '未逾期');
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '借阅单号', field: 'no', render: textRender },
    {
      label: '状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.ARCHIVE_BORROW_STATUS, value: val }),
    },
    { label: '档案编号', field: 'documentNo', render: textRender },
    { label: '档案标题', field: 'documentTitle', render: textRender },
    { label: '借阅人', field: 'borrowerName', render: textRender },
    { label: '借阅人部门', field: 'borrowerDept', render: textRender },
    { label: '联系电话', field: 'borrowerPhone', render: textRender },
    { label: '借阅日期', field: 'borrowDate', render: textRender },
    { label: '归还期限', field: 'returnDeadline', render: textRender },
    {
      label: '逾期情况',
      field: 'overdue',
      render: (val, record) => overdueRender(val, record as ArchiveBorrowApi.Borrow),
    },
    {
      label: '借出时间',
      field: 'approveTime',
      render: timeRender,
    },
    {
      label: '归还时间',
      field: 'returnTime',
      render: timeRender,
    },
    { label: '归还确认人', field: 'returnConfirmUserName', render: textRender },
    {
      label: '借阅目的',
      field: 'borrowPurpose',
      span: 2,
      render: textRender,
    },
    {
      label: '归还备注',
      field: 'returnRemark',
      span: 2,
      render: textRender,
    },
    {
      label: '驳回原因',
      field: 'rejectReason',
      span: 2,
      render: textRender,
    },
    {
      label: '驳回时间',
      field: 'rejectTime',
      render: timeRender,
    },
    { label: '创建时间', field: 'createTime', render: timeRender },
    { label: '备注', field: 'remark', span: 2, render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getBorrow(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="rounded-lg border border-border bg-background p-4">
        <div class="mb-4 flex items-center gap-2">
          <span class="inline-block h-4 w-1 rounded bg-primary" />
          <span class="text-base font-semibold">档案借阅单信息</span>
        </div>
        <Descriptions :data="formData" />
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/archive/borrow/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,256 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteBorrow,
  getBorrowApproveProcessList,
  getBorrowPage,
  getOverdueBorrowPage,
} from '#/api/archive/borrow';
import { $t } from '#/locales';
import { BorrowStatus, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
import ReturnForm from './modules/return-form.vue';
/** å€Ÿé˜…登记 */
defineOptions({ name: 'ArchiveBorrow' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
const [ReturnModal, returnModalApi] = useVbenModal({
  connectedComponent: ReturnForm,
  destroyOnClose: true,
});
/** æ˜¯å¦åªçœ‹é€¾æœŸæœªè¿˜ */
const overdueOnly = ref(false);
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ‡æ¢ã€Œåªçœ‹é€¾æœŸã€ */
function handleToggleOverdue() {
  overdueOnly.value = !overdueOnly.value;
  gridApi.query();
}
/** ç™»è®°å€Ÿé˜… */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑借阅单 */
function handleEdit(row: ArchiveBorrowApi.Borrow) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹å€Ÿé˜…单详情 */
function handleDetail(row: ArchiveBorrowApi.Borrow) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤å€Ÿé˜…单 */
async function handleDelete(row: ArchiveBorrowApi.Borrow) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.no]),
    duration: 0,
  });
  try {
    await deleteBorrow(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ï¼Œæµç¨‹ key ä¸å†™æ­» */
async function handleSubmit(row: ArchiveBorrowApi.Borrow) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getBorrowApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning(
        '暂无可用审批流程,请先到 BPM æµç¨‹è®¾è®¡å™¨å‘布档案借阅审批流程',
      );
      return;
    }
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
  }
}
/** å½’还登记 */
function handleReturn(row: ArchiveBorrowApi.Borrow) {
  returnModalApi.setData(row).open();
}
/** æŸ¥çœ‹å®¡æ‰¹è¿›åº¦ */
function handleProcessDetail(row: ArchiveBorrowApi.Borrow) {
  if (!row.processInstanceId) {
    message.warning('该借阅单尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          const params = {
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          };
          return overdueOnly.value
            ? await getOverdueBorrowPage(params)
            : await getBorrowPage(params);
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ArchiveBorrowApi.Borrow>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <ReturnModal @success="handleRefresh" />
    <Grid table-title="借阅登记">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '登记借阅',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['archive:borrow:create'],
              onClick: handleCreate,
            },
            {
              label: overdueOnly ? '查看全部' : '只看逾期',
              type: overdueOnly ? 'primary' : 'default',
              danger: overdueOnly,
              icon: ACTION_ICON.SEARCH,
              auth: ['archive:borrow:query'],
              onClick: handleToggleOverdue,
            },
          ]"
        />
      </template>
      <template #overdue="{ row }">
        <Tag v-if="row.overdue" color="error">逾期 {{ row.overdueDays }} å¤©</Tag>
        <span v-else>-</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['archive:borrow:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:borrow:update'],
              ifShow: () =>
                row.status === BorrowStatus.DRAFT ||
                row.status === BorrowStatus.REJECTED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['archive:borrow:submit'],
              ifShow: () =>
                row.status === BorrowStatus.DRAFT ||
                row.status === BorrowStatus.REJECTED,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '归还登记',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:borrow:return'],
              ifShow: () => row.status === BorrowStatus.BORROWED,
              onClick: handleReturn.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['archive:borrow:query'],
              ifShow: () => !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['archive:borrow:delete'],
              ifShow: () => row.status === BorrowStatus.DRAFT,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/archive/borrow/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import type { FormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import {
  createBorrow,
  getBorrow,
  updateBorrow,
} from '#/api/archive/borrow';
import { useFormSchema } from '../data';
defineOptions({ name: 'ArchiveBorrowForm' });
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
/** ä»Žæ¡£æ¡ˆåˆ—表「登记借阅」带入的档案 ID */
const fixedDocumentId = ref<number>();
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return '登记借阅';
  }
  if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['借阅单']);
  }
  return '借阅单详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 110,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: [],
  showDefaultActions: false,
});
/** åªæäº¤åŽç«¯ SaveReqVO æŽ¥å—的字段 */
async function buildSubmitData() {
  const values = (await formApi.getValues()) as ArchiveBorrowApi.Borrow;
  return {
    id: values.id,
    documentId: values.documentId,
    borrowerUserId: values.borrowerUserId,
    borrowerName: values.borrowerName,
    borrowerDept: values.borrowerDept,
    borrowerPhone: values.borrowerPhone,
    borrowDate: values.borrowDate,
    borrowPurpose: values.borrowPurpose,
    returnDeadline: values.returnDeadline,
    remark: values.remark,
  } as ArchiveBorrowApi.Borrow;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = await buildSubmitData();
    try {
      await (formType.value === 'create'
        ? createBorrow(data)
        : updateBorrow(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      fixedDocumentId.value = undefined;
      return;
    }
    const data = modalApi.getData<{
      formType: FormType;
      id?: number;
      documentId?: number;
    }>();
    formType.value = data.formType;
    fixedDocumentId.value = data.documentId;
    formApi.setState({ schema: useFormSchema(formType.value, data.documentId) });
    formApi.setDisabled(formType.value === 'detail');
    if (data?.id) {
      modalApi.lock();
      try {
        const detail = await getBorrow(data.id);
        await formApi.setValues(detail, false);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
      // æ–°å»ºæ—¶å¸¦å‡ºæ¡£æ¡ˆä¸Žé»˜è®¤å€Ÿé˜…日期(今天)
      await formApi.setValues({
        documentId: data.documentId,
        borrowDate: dayjs().format('YYYY-MM-DD'),
      });
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-2/3"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/archive/borrow/modules/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { submitBorrow } from '#/api/archive/borrow';
defineOptions({ name: 'ArchiveBorrowProcessSelect' });
const emit = defineEmits(['success']);
const borrowId = ref<number>();
const processList = ref<ArchiveBorrowApi.ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
/** æµç¨‹ key åªèƒ½æ¥è‡ªåŽç«¯å®žæ—¶åˆ—表,禁止前端写死 */
const processOptions = computed(() =>
  processList.value.map((item) => ({ label: item.name, value: item.key })),
);
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    modalApi.lock();
    try {
      await submitBorrow(borrowId.value!, selectedProcessKey.value);
      await modalApi.close();
      emit('success');
      message.success('提交成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      borrowId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data = modalApi.getData<{
      id: number;
      processList: ArchiveBorrowApi.ProcessDefinition[];
    }>();
    borrowId.value = data.id;
    processList.value = data.processList || [];
    // é»˜è®¤é€‰æ‹©ç¬¬ä¸€ä¸ªæµç¨‹
    if (processList.value.length > 0) {
      selectedProcessKey.value = processList.value[0]!.key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
        v-model:value="selectedProcessKey"
        :options="processOptions"
        style="width: 240px"
        placeholder="请选择审批流程"
      />
    </div>
    <p class="text-sm text-gray-500">
      æäº¤åŽä¾æ¬¡æµè½¬ï¼šæ¡£æ¡ˆç®¡ç†å‘˜å®¡æ ¸ â†’ åˆ†ç®¡é¢†å¯¼å®¡æ‰¹
    </p>
  </Modal>
</template>
src/views/archive/borrow/modules/return-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { returnBorrow } from '#/api/archive/borrow';
defineOptions({ name: 'ArchiveBorrowReturnForm' });
const emit = defineEmits(['success']);
/** å½“前归还的借阅单 */
const current = ref<ArchiveBorrowApi.Borrow>();
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
  },
  wrapperClass: 'grid-cols-1',
  layout: 'vertical',
  schema: [
    {
      fieldName: 'returnRemark',
      label: '归还备注',
      component: 'Textarea',
      componentProps: {
        rows: 3,
        placeholder: '如归还时档案完好、是否有缺页等',
      },
    },
  ],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const values = (await formApi.getValues()) as { returnRemark?: string };
    modalApi.lock();
    try {
      await returnBorrow({
        id: current.value!.id!,
        returnRemark: values.returnRemark,
      });
      await modalApi.close();
      emit('success');
      message.success('归还登记成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      current.value = undefined;
      return;
    }
    current.value = modalApi.getData<ArchiveBorrowApi.Borrow>();
    await formApi.resetForm();
  },
});
</script>
<template>
  <Modal title="归还登记" class="w-1/2">
    <div class="mx-3 mb-3 text-sm text-gray-600">
      <p>
        å€Ÿé˜…单号:{{ current?.no }} 档案:{{ current?.documentNo }}
        {{ current?.documentTitle }}
      </p>
      <p>
        å€Ÿé˜…人:{{ current?.borrowerName }} 归还期限:{{ current?.returnDeadline }}
      </p>
    </div>
    <Form class="mx-3" />
  </Modal>
</template>
src/views/archive/category/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,192 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveCategoryApi } from '#/api/archive/category';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { getCategoryList } from '#/api/archive/category';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'parentId',
      label: '上级分类',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        api: async () => {
          const data = await getCategoryList();
          data.unshift({
            id: 0,
            name: '顶级分类',
          } as ArchiveCategoryApi.Category);
          return handleTree(data);
        },
        fieldNames: { label: 'name', value: 'id', children: 'children' },
        placeholder: '请选择上级分类',
        showSearch: true,
        treeDefaultExpandAll: true,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'name',
      label: '分类名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入分类名称',
      },
      rules: 'required',
    },
    {
      fieldName: 'code',
      label: '分类编码',
      component: 'Input',
      componentProps: {
        placeholder: '英文编码,如 member/research/finance',
      },
      rules: 'required',
    },
    {
      fieldName: 'defaultSecretLevel',
      label: '默认密级',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SECRET_LEVEL, 'number'),
        placeholder: '该分类下档案的默认密级',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'sort',
      label: '显示排序',
      component: 'InputNumber',
      componentProps: {
        min: 0,
        placeholder: '数字越小越靠前',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CATEGORY_STATUS, 'number'),
        placeholder: '请选择状态',
      },
    },
    {
      fieldName: 'description',
      label: '分类说明',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入分类说明',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入备注',
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '分类名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入分类名称',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CATEGORY_STATUS, 'number'),
        allowClear: true,
        placeholder: '请选择状态',
      },
    },
  ];
}
/** è¡¨æ ¼åˆ—配置 */
export function useGridColumns(): VxeTableGridOptions<ArchiveCategoryApi.Category>['columns'] {
  return [
    {
      field: 'name',
      title: '分类名称',
      treeNode: true,
    },
    {
      field: 'code',
      title: '分类编码',
      minWidth: 130,
    },
    {
      field: 'defaultSecretLevel',
      title: '默认密级',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_SECRET_LEVEL },
      },
    },
    {
      field: 'sort',
      title: '排序',
      minWidth: 80,
    },
    {
      field: 'description',
      title: '分类说明',
      minWidth: 180,
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_CATEGORY_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'actions',
      title: '操作',
      width: 250,
      fixed: 'right',
      slots: {
        default: 'actions',
      },
    },
  ];
}
src/views/archive/category/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveCategoryApi } from '#/api/archive/category';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteCategory,
  getCategoryList,
} from '#/api/archive/category';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æ¡£æ¡ˆåˆ†ç±»ï¼ˆä¹¦æž¶ï¼‰ */
defineOptions({ name: 'ArchiveCategory' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ‡æ¢æ ‘形展开/收缩状态 */
const isExpanded = ref(false);
function handleExpand() {
  isExpanded.value = !isExpanded.value;
  gridApi.grid.setAllTreeExpand(isExpanded.value);
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ›å»ºåˆ†ç±» */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** æ·»åŠ ä¸‹çº§åˆ†ç±» */
function handleAppend(row: ArchiveCategoryApi.Category) {
  formModalApi.setData({ parentId: row.id }).open();
}
/** ç¼–辑分类 */
function handleEdit(row: ArchiveCategoryApi.Category) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤åˆ†ç±» */
async function handleDelete(row: ArchiveCategoryApi.Category) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.name]),
    duration: 0,
  });
  try {
    await deleteCategory(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      ajax: {
        query: async (_, formValues) => {
          return await getCategoryList(formValues);
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
    treeConfig: {
      parentField: 'parentId',
      rowField: 'id',
      transform: true,
      expandAll: true,
      reserve: true,
    },
  } as VxeTableGridOptions<ArchiveCategoryApi.Category>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="档案分类(书架)">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['档案分类']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['archive:category:create'],
              onClick: handleCreate,
            },
            {
              label: isExpanded ? '收缩' : '展开',
              type: 'primary',
              onClick: handleExpand,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '新增下级',
              type: 'link',
              icon: ACTION_ICON.ADD,
              auth: ['archive:category:create'],
              onClick: handleAppend.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:category:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['archive:category:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/archive/category/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { ArchiveCategoryApi } from '#/api/archive/category';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createCategory,
  getCategory,
  updateCategory,
} from '#/api/archive/category';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'ArchiveCategoryForm' });
const emit = defineEmits(['success']);
const formData = ref<ArchiveCategoryApi.Category>();
const getTitle = computed(() => {
  if (formData.value?.id) {
    return $t('ui.actionTitle.edit', ['档案分类']);
  }
  return formData.value?.parentId
    ? '新增下级分类'
    : $t('ui.actionTitle.create', ['档案分类']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-2',
    labelWidth: 90,
  },
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as ArchiveCategoryApi.Category;
    try {
      await (formData.value?.id
        ? updateCategory({ ...data, id: formData.value.id })
        : createCategory(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    let data = modalApi.getData<ArchiveCategoryApi.Category>();
    // æ–°å¢žï¼ˆé¡¶çº§ / ä¸‹çº§ï¼‰æ—¶åªå¸¦ parentId ä½œä¸ºé»˜è®¤å€¼
    if (!data || !data.id) {
      formData.value = data ?? undefined;
      await formApi.resetForm();
      if (data?.parentId !== undefined) {
        await formApi.setValues({ parentId: data.parentId });
      }
      return;
    }
    modalApi.lock();
    try {
      data = await getCategory(data.id);
      formData.value = data;
      await formApi.setValues(data);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal class="w-2/5" :title="getTitle">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/archive/document/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,442 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveDocumentApi } from '#/api/archive/document';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { getCategoryList } from '#/api/archive/category';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** æ¡£æ¡ˆè¡¨å•配置 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  const readonly = formType === 'detail';
  // æ¡£æ¡ˆç¼–号、来源、状态、归档人由后端维护,仅编辑/详情可见
  const showBackendFields = formType !== 'create';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '档案编号',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        disabled: true,
        placeholder: '系统自动生成',
      },
    },
    {
      fieldName: 'status',
      label: '档案状态',
      component: 'Select',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_DOCUMENT_STATUS, 'number'),
        disabled: true,
      },
    },
    {
      fieldName: 'title',
      label: '档案标题',
      component: 'Input',
      componentProps: {
        allowClear: true,
        disabled: readonly,
        placeholder: '请输入档案标题',
      },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'categoryId',
      label: '档案分类',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        api: async () => handleTree(await getCategoryList({ status: 0 })),
        fieldNames: { label: 'name', value: 'id', children: 'children' },
        placeholder: '请选择档案分类',
        showSearch: true,
        treeDefaultExpandAll: true,
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'secretLevel',
      label: '密级',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SECRET_LEVEL, 'number'),
        placeholder: '请选择密级',
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'carrierType',
      label: '载体',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CARRIER_TYPE, 'number'),
        placeholder: '请选择载体',
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'archiveDate',
      label: '归档日期',
      component: 'DatePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择归档日期',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'shelfNo',
      label: '书架号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 A架',
        disabled: readonly,
      },
    },
    {
      fieldName: 'layerNo',
      label: '层号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 2层',
        disabled: readonly,
      },
    },
    {
      fieldName: 'columnNo',
      label: '列号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 3列',
        disabled: readonly,
      },
    },
    {
      fieldName: 'boxNo',
      label: '册号/档案盒号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 æ¡£æ¡ˆç›’-001',
        disabled: readonly,
      },
    },
    {
      fieldName: 'storePlace',
      label: '存放位置',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 3号库房A架2层',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'tags',
      label: '关键词',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '多个关键词用英文逗号分隔,便于检索',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'summary',
      label: '摘要',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入摘要',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'sourceType',
      label: '来源类型',
      component: 'Select',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SOURCE_TYPE, 'number'),
        disabled: true,
      },
    },
    {
      fieldName: 'sourceNo',
      label: '来源单号',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'archivistName',
      label: '归档人',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入备注',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '电子文件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        showDescription: !readonly,
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '档案编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案编号',
      },
    },
    {
      fieldName: 'title',
      label: '档案标题',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案标题',
      },
    },
    {
      fieldName: 'categoryId',
      label: '档案分类',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        api: async () => handleTree(await getCategoryList({ status: 0 })),
        fieldNames: { label: 'name', value: 'id', children: 'children' },
        placeholder: '请选择档案分类',
        showSearch: true,
        treeDefaultExpandAll: true,
      },
    },
    {
      fieldName: 'secretLevel',
      label: '密级',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SECRET_LEVEL, 'number'),
        allowClear: true,
        placeholder: '请选择密级',
      },
    },
    {
      fieldName: 'carrierType',
      label: '载体',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CARRIER_TYPE, 'number'),
        allowClear: true,
        placeholder: '请选择载体',
      },
    },
    {
      fieldName: 'sourceType',
      label: '来源类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SOURCE_TYPE, 'number'),
        allowClear: true,
        placeholder: '请选择来源类型',
      },
    },
    {
      fieldName: 'sourceNo',
      label: '来源单号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入来源单号',
      },
    },
    {
      fieldName: 'tags',
      label: '关键词',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入关键词',
      },
    },
    {
      fieldName: 'status',
      label: '档案状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_DOCUMENT_STATUS, 'number'),
        allowClear: true,
        placeholder: '请选择档案状态',
      },
    },
    {
      fieldName: 'archiveDate',
      label: '归档日期',
      component: 'RangePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: ['开始日期', '结束日期'],
      },
    },
  ];
}
/** è¡¨æ ¼åˆ—配置 */
export function useGridColumns(): VxeTableGridOptions<ArchiveDocumentApi.Document>['columns'] {
  return [
    {
      field: 'no',
      title: '档案编号',
      minWidth: 170,
    },
    {
      field: 'title',
      title: '档案标题',
      minWidth: 220,
    },
    {
      field: 'categoryName',
      title: '档案分类',
      minWidth: 120,
    },
    {
      field: 'secretLevel',
      title: '密级',
      minWidth: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_SECRET_LEVEL },
      },
    },
    {
      field: 'carrierType',
      title: '载体',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_CARRIER_TYPE },
      },
    },
    {
      field: 'sourceType',
      title: '来源',
      minWidth: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_SOURCE_TYPE },
      },
    },
    {
      field: 'sourceNo',
      title: '来源单号',
      minWidth: 160,
    },
    {
      field: 'storePlace',
      title: '存放位置',
      minWidth: 170,
    },
    {
      field: 'archiveDate',
      title: '归档日期',
      minWidth: 120,
    },
    {
      field: 'status',
      title: '档案状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_DOCUMENT_STATUS },
      },
    },
    {
      field: 'hasAttachment',
      title: '电子文件',
      minWidth: 100,
      slots: { default: 'hasAttachment' },
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/archive/document/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveDocumentApi } from '#/api/archive/document';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteDocument,
  exportDocument,
  getDocumentPage,
} from '#/api/archive/document';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æ¡£æ¡ˆç®¡ç† */
defineOptions({ name: 'ArchiveDocument' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žæ¡£æ¡ˆ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑档案 */
function handleEdit(row: ArchiveDocumentApi.Document) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹æ¡£æ¡ˆè¯¦æƒ… */
function handleDetail(row: ArchiveDocumentApi.Document) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤æ¡£æ¡ˆ */
async function handleDelete(row: ArchiveDocumentApi.Document) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.title]),
    duration: 0,
  });
  try {
    await deleteDocument(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.title]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å¯¼å‡º Excel */
async function handleExport() {
  const data = await exportDocument(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '档案.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getDocumentPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ArchiveDocumentApi.Document>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="档案管理">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['档案']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['archive:document:create'],
              onClick: handleCreate,
            },
            {
              label: '导出',
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['archive:document:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['archive:document:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:document:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['archive:document:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/archive/document/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
<script lang="ts" setup>
import type { ArchiveDocumentApi } from '#/api/archive/document';
import type { FormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createDocument,
  getDocument,
  updateDocument,
} from '#/api/archive/document';
import { useFormSchema } from '../data';
defineOptions({ name: 'ArchiveDocumentForm' });
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['档案']);
  }
  if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['档案']);
  }
  return '档案详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: [],
  showDefaultActions: false,
});
/** åªæäº¤åŽç«¯ SaveReqVO æŽ¥å—的字段,避免回传 no/status/source* ç­‰åªè¯»å­—段 */
async function buildSubmitData() {
  const values = (await formApi.getValues()) as ArchiveDocumentApi.Document;
  return {
    id: values.id,
    title: values.title,
    categoryId: values.categoryId,
    secretLevel: values.secretLevel,
    carrierType: values.carrierType,
    shelfNo: values.shelfNo,
    layerNo: values.layerNo,
    columnNo: values.columnNo,
    boxNo: values.boxNo,
    storePlace: values.storePlace,
    archiveDate: values.archiveDate,
    summary: values.summary,
    tags: values.tags,
    remark: values.remark,
    // é™„件为全量替换语义:清空全部附件时必须显式传空数组
    blobIds: values.blobIds ?? [],
  } as ArchiveDocumentApi.Document;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = await buildSubmitData();
    try {
      await (formType.value === 'create'
        ? createDocument(data)
        : updateDocument(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(formType.value) });
    formApi.setDisabled(formType.value === 'detail');
    if (data?.id) {
      modalApi.lock();
      try {
        const detail = await getDocument(data.id);
        // å·²æœ‰é™„件转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
        const blobIds = (detail.attachmentList ?? []).map((item) => ({
          uid: String(item.id),
          name: item.name || '',
          url: item.url || '',
          status: 'done',
          id: item.id,
        }));
        await formApi.setValues({ ...detail, blobIds }, false);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/archive/stat/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
import type { EChartsOption } from '@vben/plugins/echarts';
import type { ArchiveStatApi } from '#/api/archive/stat';
/** é¡¶éƒ¨ç»Ÿè®¡å¡ç‰‡ */
export interface StatCard {
  key: keyof ArchiveStatApi.StatOverview;
  label: string;
  color: string;
}
export const STAT_CARDS: StatCard[] = [
  { key: 'totalCount', label: '档案总数', color: '#1677ff' },
  { key: 'inStockCount', label: '在库档案', color: '#52c41a' },
  { key: 'borrowedCount', label: '借出中档案', color: '#faad14' },
  { key: 'overdueCount', label: '逾期未还', color: '#ff4d4f' },
];
/** é€šç”¨é¥¼å›¾é…ç½® */
export function buildPieOption(
  title: string,
  rows: { name: string; value: number }[],
): EChartsOption {
  return {
    title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
    tooltip: { trigger: 'item' },
    legend: { orient: 'vertical', left: 'left', top: 'middle' },
    series: [
      {
        name: title,
        type: 'pie',
        radius: ['40%', '65%'],
        center: ['60%', '55%'],
        data: rows,
        emphasis: {
          itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' },
        },
      },
    ],
  };
}
/** é€šç”¨æŸ±çŠ¶å›¾é…ç½® */
export function buildBarOption(
  title: string,
  rows: { name: string; value: number }[],
): EChartsOption {
  return {
    title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
    grid: { left: 20, right: 20, bottom: 20, containLabel: true },
    xAxis: {
      type: 'category',
      data: rows.map((item) => item.name),
      axisTick: { alignWithLabel: true },
    },
    yAxis: { type: 'value', minInterval: 1 },
    series: [
      {
        name: title,
        type: 'bar',
        barMaxWidth: 48,
        data: rows.map((item) => item.value),
        label: { show: true, position: 'top' },
      },
    ],
  };
}
src/views/archive/stat/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { ArchiveStatApi } from '#/api/archive/stat';
import { computed, onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Spin } from 'ant-design-vue';
import { getArchiveStatOverview } from '#/api/archive/stat';
import { buildBarOption, buildPieOption, STAT_CARDS } from './data';
/** æ¡£æ¡ˆç»Ÿè®¡ */
defineOptions({ name: 'ArchiveStat' });
const loading = ref(false);
const overview = ref<ArchiveStatApi.StatOverview>({});
const categoryChartRef = ref<EchartsUIType>();
const secretChartRef = ref<EchartsUIType>();
const carrierChartRef = ref<EchartsUIType>();
const { renderEcharts: renderCategory } = useEcharts(categoryChartRef);
const { renderEcharts: renderSecret } = useEcharts(secretChartRef);
const { renderEcharts: renderCarrier } = useEcharts(carrierChartRef);
/** å¡ç‰‡å–值 */
const cards = computed(() =>
  STAT_CARDS.map((card) => ({
    ...card,
    value: (overview.value[card.key] as number | undefined) ?? 0,
  })),
);
async function loadData() {
  loading.value = true;
  try {
    const data = await getArchiveStatOverview();
    overview.value = data;
    await Promise.all([
      renderCategory(
        buildPieOption(
          '按分类分布',
          (data.byCategory ?? []).map((item) => ({
            name: item.name ?? '未命名',
            value: item.total ?? 0,
          })),
        ),
      ),
      renderSecret(
        buildBarOption(
          '按密级分布',
          (data.bySecretLevel ?? []).map((item) => ({
            name: item.name ?? '未设置',
            value: item.total ?? 0,
          })),
        ),
      ),
      renderCarrier(
        buildPieOption(
          '按载体分布',
          (data.byCarrierType ?? []).map((item) => ({
            name: item.name ?? '未设置',
            value: item.total ?? 0,
          })),
        ),
      ),
    ]);
  } finally {
    loading.value = false;
  }
}
onMounted(loadData);
</script>
<template>
  <Page auto-content-height>
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- ç»Ÿè®¡å¡ç‰‡ -->
        <div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
          <ContentWrap
            v-for="card in cards"
            :key="card.key"
            class="flex items-center justify-between"
          >
            <div>
              <p class="text-sm text-gray-500">{{ card.label }}</p>
              <p
                class="mt-1 text-2xl font-semibold"
                :style="{ color: card.color }"
              >
                {{ card.value }}
              </p>
            </div>
            <span
              class="inline-block h-10 w-1 rounded"
              :style="{ backgroundColor: card.color }"
            />
          </ContentWrap>
        </div>
        <!-- åˆ†å¸ƒå›¾è¡¨ -->
        <div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
          <ContentWrap>
            <EchartsUI class="h-72 w-full" ref="categoryChartRef" />
          </ContentWrap>
          <ContentWrap>
            <EchartsUI class="h-72 w-full" ref="secretChartRef" />
          </ContentWrap>
        </div>
        <ContentWrap>
          <EchartsUI class="h-72 w-full" ref="carrierChartRef" />
        </ContentWrap>
      </div>
    </Spin>
  </Page>
</template>
src/views/finance/reimbursement/index.vue
@@ -110,8 +110,8 @@
    duration: 0,
  });
  try {
    await archiveReimbursement(row.id!);
    message.success('归档成功');
    const archiveNo = await archiveReimbursement(row.id!);
    message.success(`已归档并生成档案 ${archiveNo}`);
    handleRefresh();
  } finally {
    hideLoading();
src/views/research/application/index.vue
@@ -12,6 +12,7 @@
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  archiveApplication,
  deleteApplication,
  getApplicationApproveProcessList,
  getApplicationPage,
@@ -142,6 +143,18 @@
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** å½’档到档案管理(仅已立项可归档) */
async function handleArchive(row: ResearchApplicationApi.Application) {
  const hideLoading = message.loading({ content: '归档中...', duration: 0 });
  try {
    const archiveNo = await archiveApplication(row.id!);
    message.success(`归档成功,已生成档案 ${archiveNo}`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
@@ -284,6 +297,19 @@
              onClick: handlePublishNotice.bind(null, row),
            },
            {
              label: '归档',
              type: 'link',
              icon: ACTION_ICON.UPLOAD,
              auth: ['research:application:archive'],
              ifShow: () =>
                row.status === ApplicationStatus.APPROVED &&
                !row.archiveDocumentId,
              popConfirm: {
                title: `确认归档课题 ${row.researchNo || row.no} å—?归档后将在档案管理生成对应档案`,
                confirm: handleArchive.bind(null, row),
              },
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
src/views/ums/member/index.vue
@@ -13,9 +13,18 @@
import { getDictOptions } from '@vben/hooks';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMember, getMemberPage } from '#/api/ums/member';
import {
  archiveMember,
  deleteMember,
  getMemberPage,
} from '#/api/ums/member';
import { MemberType, useGridColumns, useGridFormSchema } from './data';
import {
  MemberStatus,
  MemberType,
  useGridColumns,
  useGridFormSchema,
} from './data';
import Form from './modules/form.vue';
/** ä¼šå‘˜åˆ—表 */
@@ -64,6 +73,18 @@
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({ records }: { records: UmsMemberApi.Member[] }) {
  checkedIds.value = records.map((item) => item.id!);
}
/** å½’档到档案管理(仅正常状态会员可归档) */
async function handleArchive(row: UmsMemberApi.Member) {
  const hideLoading = message.loading({ content: '归档中...', duration: 0 });
  try {
    const archiveNo = await archiveMember(row.id!);
    message.success(`归档成功,已生成档案 ${archiveNo}`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** ç­‰çº§å­—典映射:根据会员类型显示对应的等级名称 */
@@ -165,6 +186,18 @@
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '归档',
              type: 'link',
              icon: ACTION_ICON.UPLOAD,
              auth: ['ums:member:archive'],
              ifShow: () =>
                row.status === MemberStatus.NORMAL && !row.archiveDocumentId,
              popConfirm: {
                title: `确认归档会员 ${row.name} å—?归档后将在档案管理生成对应档案`,
                confirm: handleArchive.bind(null, row),
              },
            },
            {
              label: '删除',
              type: 'link',
              danger: true,