liu
2 天以前 fe2d6627b8f1002af7a31b08f6f43bec034d04ee
feat: 完善课题、会员、财务、企微页面并新增资产/用车/采购

- 财务报销:发票 OCR 上传识别、发票类型、代发起
- 课题管理:联系电话校验、成员子表、成员统计导出与群发
- 会员管理:会费标识、我的资料、资料变更审核
- 资产管理:资产台账、领用与归还
- 用车管理:用车申请与审批流程选择
- 采购管理:采购单与商品明细
- 企业微信:通知多模板与推送时选择模板、活动 5 种模板与采集评价

Co-Authored-By: Claude <noreply@anthropic.com>
已修改24个文件
已添加29个文件
5274 ■■■■■ 文件已修改
src/api/asset/item/index.ts 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/asset/requisition/index.ts 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/finance/reimbursement/ai/index.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/finance/reimbursement/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/purchase/order/index.ts 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/application/index.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/member/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/member/profile.ts 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/vehicle/application/index.ts 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/activity.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/notice.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/upload/image-upload.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/asset/item/data.ts 212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/asset/item/index.vue 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/asset/item/modules/form.vue 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/asset/requisition/data.ts 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/asset/requisition/index.vue 161 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/asset/requisition/modules/form.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/asset/requisition/modules/return-form.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/data.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/detail/approve/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/modules/form.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/modules/item-form.vue 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/modules/ocr-upload.vue 283 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/purchase/order/data.ts 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/purchase/order/index.vue 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/purchase/order/modules/form.vue 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/purchase/order/modules/item-table.vue 215 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/data.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/detail/approve/index.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/form.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/member-statistics-modal.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/member-table.vue 239 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/change/index.vue 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/change/modules/reject-modal.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/data.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/profile/data.ts 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/profile/index.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/vehicle/application/data.ts 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/vehicle/application/index.vue 225 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/vehicle/application/modules/form.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/vehicle/application/modules/process-select-modal.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/data.ts 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/field-editor.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/form.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/push-modal.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/signup-list.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/data.ts 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/modules/form.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/modules/push-modal.vue 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/asset/item/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AssetItemApi {
  /** èµ„产分类 */
  export const Category = {
    CONSUMABLE: 1,
    OFFICE: 2,
    FIXED: 3,
  } as const;
  /** èµ„产台账 */
  export interface AssetItem {
    id?: number;
    assetNo?: string;
    name: string;
    category: number;
    categoryName?: string;
    spec?: string;
    unit?: string;
    quantity: number;
    unitPrice?: number;
    totalAmount?: number;
    purchaseDate?: string;
    custodianId?: number;
    custodianName?: string;
    location?: string;
    remark?: string;
    createTime?: string;
  }
}
/** æŸ¥è¯¢èµ„产分页 */
export function getAssetItemPage(params: PageParam) {
  return requestClient.get<PageResult<AssetItemApi.AssetItem>>(
    '/asset/item/page',
    { params },
  );
}
/** æŸ¥è¯¢èµ„产详情 */
export function getAssetItem(id: number) {
  return requestClient.get<AssetItemApi.AssetItem>(`/asset/item/get?id=${id}`);
}
/** æŸ¥è¯¢èµ„产精简列表 */
export function getAssetItemSimpleList() {
  return requestClient.get<AssetItemApi.AssetItem[]>(
    '/asset/item/simple-list',
  );
}
/** æ–°å¢žèµ„产 */
export function createAssetItem(data: AssetItemApi.AssetItem) {
  return requestClient.post('/asset/item/create', data);
}
/** ä¿®æ”¹èµ„产 */
export function updateAssetItem(data: AssetItemApi.AssetItem) {
  return requestClient.put('/asset/item/update', data);
}
/** åˆ é™¤èµ„产 */
export function deleteAssetItem(id: number) {
  return requestClient.delete('/asset/item/delete', { params: { id } });
}
src/api/asset/requisition/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AssetRequisitionApi {
  /** å½’还状态 */
  export const ReturnStatus = {
    NOT_RETURNED: 1,
    PARTIAL: 2,
    RETURNED: 3,
  } as const;
  /** èµ„产领用 */
  export interface AssetRequisition {
    id?: number;
    no?: string;
    assetId?: number;
    assetNo?: string;
    assetName?: string;
    category?: number;
    categoryName?: string;
    custodianId?: number;
    custodianName?: string;
    recipientId?: number;
    recipientName?: string;
    requisitionTime?: string;
    quantity?: number;
    purpose?: string;
    expectedReturnDate?: string;
    returnedQuantity?: number;
    remainingQuantity?: number;
    returnTime?: string;
    returnStatus?: number;
    returnStatusName?: string;
    remark?: string;
    createTime?: string;
  }
  /** å½’还请求 */
  export interface ReturnReq {
    id: number;
    returnQuantity: number;
    returnTime?: string;
    remark?: string;
  }
}
/** æŸ¥è¯¢é¢†ç”¨åˆ†é¡µ */
export function getAssetRequisitionPage(params: PageParam) {
  return requestClient.get<PageResult<AssetRequisitionApi.AssetRequisition>>(
    '/asset/requisition/page',
    { params },
  );
}
/** æŸ¥è¯¢é¢†ç”¨è¯¦æƒ… */
export function getAssetRequisition(id: number) {
  return requestClient.get<AssetRequisitionApi.AssetRequisition>(
    `/asset/requisition/get?id=${id}`,
  );
}
/** æ–°å¢žé¢†ç”¨ */
export function createAssetRequisition(
  data: AssetRequisitionApi.AssetRequisition,
) {
  return requestClient.post('/asset/requisition/create', data);
}
/** ä¿®æ”¹é¢†ç”¨ï¼ˆä»…领用人、用途、预计归还日期、备注) */
export function updateAssetRequisition(
  data: AssetRequisitionApi.AssetRequisition,
) {
  return requestClient.put('/asset/requisition/update', data);
}
/** å½’还资产 */
export function returnAssetRequisition(data: AssetRequisitionApi.ReturnReq) {
  return requestClient.put('/asset/requisition/return', data);
}
/** åˆ é™¤é¢†ç”¨ */
export function deleteAssetRequisition(id: number) {
  return requestClient.delete('/asset/requisition/delete', {
    params: { id },
  });
}
src/api/finance/reimbursement/ai/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import type { FinanceReimbursementApi } from '../index';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace FinanceReimbursementAiApi {
  /** æŠ¥é”€å‘票 OCR è¯†åˆ«ç»“æžœ */
  export interface OcrResultVO {
    /** å‘票文件 Blob ç¼–号 */
    blobId?: number;
    /** å‘票号码 */
    invoiceNo?: string;
    /** å‘票代码(数电票可能为空) */
    invoiceCode?: string;
    /** å‘票类型 */
    invoiceType?: string;
    /** è´­ä¹°æ–¹åç§°ï¼ˆå‘票抬头) */
    invoiceTitle?: string;
    /** é”€å”®æ–¹åç§°ï¼ˆå¼€ç¥¨æ–¹ï¼‰ */
    sellerName?: string;
    /** ä»·ç¨Žåˆè®¡é‡‘额,单位:元 */
    invoiceAmount?: number;
    /** ç¨Žé¢åˆè®¡ï¼Œå•位:元 */
    taxAmount?: number;
    /** å¼€ç¥¨æ—¥æœŸï¼Œyyyy-MM-dd */
    invoiceDate?: string;
    /** å¤‡æ³¨ */
    remark?: string;
    /** è¯†åˆ«å¤±è´¥æ—¶çš„错误提示(成功时为空) */
    rawText?: string;
  }
  /** OCR ç¡®è®¤åŽå›žå¡«ç»™æŠ¥é”€è¡¨å•的数据 */
  export interface OcrImportPayload {
    /** è¯†åˆ«æˆåŠŸçš„å‘ç¥¨æ˜Žç»†è¡Œ */
    items: FinanceReimbursementApi.ReimbursementItem[];
    /** æœ¬æ¬¡ä¸Šä¼ çš„发票文件,需一并保存为报销单附件 */
    blobs: SystemStorageApi.StorageBlob[];
  }
}
/** æ‰¹é‡è¯†åˆ«æŠ¥é”€å‘票文件 */
export function ocrReimbursementInvoices(blobIds: number[]) {
  return requestClient.post<FinanceReimbursementAiApi.OcrResultVO[]>(
    '/finance/reimbursement/ai/ocr',
    { blobIds },
  );
}
src/api/finance/reimbursement/index.ts
@@ -10,6 +10,7 @@
    id?: number;
    reimbursementId?: number;
    expenseType: number; // è´¹ç”¨ç±»åž‹ï¼Œå­—å…¸ finance_expense_type
    invoiceType?: string; // å‘票类型:增值税专用发票/增值税普通发票/电子发票/收据/其他
    invoiceNo?: string; // å‘票号码
    invoiceAmount: number; // å‘票金额
    invoiceDate?: string; // å‘票日期 yyyy-MM-dd
src/api/purchase/order/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace PurchaseOrderApi {
  /** é‡‡è´­å•†å“æ˜Žç»† */
  export interface OrderItem {
    id?: number;
    orderId?: number;
    itemName: string;
    spec?: string;
    quantity: number;
    unitPrice: number;
    amount?: number;
    sortNo?: number;
  }
  /** é‡‡è´­å• */
  export interface Order {
    id?: number;
    no?: string;
    purchaseDate: string;
    buyerId?: number;
    buyerName: string;
    totalAmount?: number;
    status?: number;
    remark?: string;
    items?: OrderItem[];
    createTime?: string;
  }
}
/** æŸ¥è¯¢é‡‡è´­å•分页 */
export function getPurchaseOrderPage(params: PageParam) {
  return requestClient.get<PageResult<PurchaseOrderApi.Order>>(
    '/purchase/order/page',
    { params },
  );
}
/** æŸ¥è¯¢é‡‡è´­å•详情(含明细) */
export function getPurchaseOrder(id: number) {
  return requestClient.get<PurchaseOrderApi.Order>(
    `/purchase/order/get?id=${id}`,
  );
}
/** æ–°å¢žé‡‡è´­å• */
export function createPurchaseOrder(data: PurchaseOrderApi.Order) {
  return requestClient.post('/purchase/order/create', data);
}
/** ä¿®æ”¹é‡‡è´­å• */
export function updatePurchaseOrder(data: PurchaseOrderApi.Order) {
  return requestClient.put('/purchase/order/update', data);
}
/** åˆ é™¤é‡‡è´­å• */
export function deletePurchaseOrder(id: number) {
  return requestClient.delete('/purchase/order/delete', { params: { id } });
}
src/api/research/application/index.ts
@@ -5,6 +5,29 @@
import { requestClient } from '#/api/request';
export namespace ResearchApplicationApi {
  /** è¯¾é¢˜ç”³æŠ¥ä¹¦æˆå‘˜ï¼ˆå­è¡¨ï¼‰ */
  export interface ApplicationMember {
    id?: number;
    applicationId?: number;
    name: string; // æˆå‘˜å§“名
    contactPhone?: string; // è”系电话
    workUnit?: string; // æ‰€å±žå•位
    jobTitle?: string; // èŒç§°/职务
    memberRole?: string; // æˆå‘˜è§’色:主持人/核心成员/参与人
    sortNo?: number; // æŽ’序号
  }
  /** æˆå‘˜ç»´åº¦ç»Ÿè®¡ */
  export interface MemberStatistics {
    name: string; // æˆå‘˜å§“名
    contactPhone?: string; // è”系电话
    workUnit?: string; // æ‰€å±žå•位
    jobTitle?: string; // èŒç§°/职务
    memberRoles?: string; // æˆå‘˜è§’色(多个以顿号分隔)
    projectCount?: number; // å‚与课题数
    projectTitles?: string; // å‚与课题名称(分号分隔)
  }
  /** è¯¾é¢˜ç”³æŠ¥ä¹¦ */
  export interface Application {
    id?: number;
@@ -20,7 +43,8 @@
    jobTitle?: string; // èŒç§°
    contactPhone?: string; // è”系电话
    contactEmail?: string; // è”系邮箱
    teamMembers?: string; // è¯¾é¢˜ç»„成员(每行一条:姓名|单位|职称)
    teamMembers?: string; // è¯¾é¢˜ç»„成员(旧版文本,仅历史数据展示)
    members?: ApplicationMember[]; // è¯¾é¢˜ç»„成员子表
    budgetAmount?: number; // ç”³è¯·ç»è´¹ï¼ˆå…ƒï¼‰
    planContent?: string; // ç”³æŠ¥ä¹¦æ­£æ–‡
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ research_application_status
@@ -129,6 +153,28 @@
  });
}
/** æŒ‰æˆå‘˜ç»´åº¦ç»Ÿè®¡è¯¾é¢˜å‚与情况 */
export function getMemberStatistics(params: {
  keyword?: string;
  status?: number;
}) {
  return requestClient.get<ResearchApplicationApi.MemberStatistics[]>(
    '/research/application/member-statistics',
    { params },
  );
}
/** å¯¼å‡ºæˆå‘˜ç»´åº¦ç»Ÿè®¡ Excel */
export function exportMemberStatistics(params: {
  keyword?: string;
  status?: number;
}) {
  return requestClient.download(
    '/research/application/member-statistics/export-excel',
    { params },
  );
}
/** æŸ¥è¯¢æŸç”³æŠ¥ä¹¦çš„专家评审明细列表 */
export function getTopicReviewList(applicationId: number) {
  return requestClient.get<ResearchApplicationApi.TopicReview[]>(
src/api/ums/member/index.ts
@@ -28,6 +28,8 @@
    industry?: string; // è¡Œä¸šï¼Œå­—å…¸ ums_industry
    joinDate?: string; // å…¥ä¼šæ—¥æœŸ
    expireDate?: string; // æœ‰æ•ˆæœŸè‡³
    feePaid?: boolean; // æ˜¯å¦ç¼´çº³ä¼šè´¹
    feeYear?: number; // ç¼´è´¹å¹´åº¦ï¼ˆå¦‚ 2026)
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ ums_member_status
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已归档)
    archived?: boolean; // æ˜¯å¦å·²å½’æ¡£
src/api/ums/member/profile.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
import type { PageParam, PageResult } from '@vben/request';
import type { UmsMemberApi } from './index';
import { requestClient } from '#/api/request';
export namespace UmsMemberProfileApi {
  /** èµ„料变更申请 */
  export interface ChangeRecord {
    id?: number;
    memberId?: number;
    memberNo?: string;
    memberName?: string;
    userId?: number;
    status?: number; // 0待审核 10已通过 20已驳回
    contactUser?: string;
    contactPhone?: string;
    contactEmail?: string;
    workUnit?: string;
    title?: string;
    researchDirection?: string;
    unitIntro?: string;
    legalPerson?: string;
    organizationCode?: string;
    regionCode?: string;
    industry?: string;
    changeSummary?: string;
    auditUserId?: number;
    auditTime?: string;
    auditRemark?: string;
    createTime?: string;
  }
  /** æäº¤èµ„料变更申请 */
  export interface ApplyReq {
    contactUser?: string;
    contactPhone?: string;
    contactEmail?: string;
    workUnit?: string;
    title?: string;
    researchDirection?: string;
    unitIntro?: string;
    legalPerson?: string;
    organizationCode?: string;
    regionCode?: string;
    industry?: string;
  }
}
/** èŽ·å¾—å½“å‰ç™»å½•ä¼šå‘˜çš„ä¸ªäººèµ„æ–™ */
export function getMyProfile() {
  return requestClient.get<UmsMemberApi.Member>('/ums/member/profile/get');
}
/** èŽ·å¾—å½“å‰ä¼šå‘˜æœ€è¿‘ä¸€æ¬¡èµ„æ–™å˜æ›´ç”³è¯· */
export function getMyLatestChange() {
  return requestClient.get<UmsMemberProfileApi.ChangeRecord | null>(
    '/ums/member/profile/change/latest',
  );
}
/** æäº¤èµ„料变更申请 */
export function applyProfileChange(data: UmsMemberProfileApi.ApplyReq) {
  return requestClient.post<number>('/ums/member/profile/change/apply', data);
}
/** ç®¡ç†å‘˜åˆ†é¡µæŸ¥è¯¢èµ„料变更申请 */
export function getProfileChangePage(
  params: PageParam & {
    memberNo?: string;
    memberName?: string;
    status?: number;
  },
) {
  return requestClient.get<PageResult<UmsMemberProfileApi.ChangeRecord>>(
    '/ums/member/profile/change/page',
    { params },
  );
}
/** å®¡æ ¸é€šè¿‡ */
export function approveProfileChange(id: number) {
  return requestClient.put('/ums/member/profile/change/approve', null, {
    params: { id },
  });
}
/** å®¡æ ¸é©³å›ž */
export function rejectProfileChange(id: number, remark: string) {
  return requestClient.put('/ums/member/profile/change/reject', null, {
    params: { id, remark },
  });
}
src/api/vehicle/application/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace VehicleApplicationApi {
  /** ç”¨è½¦ç”³è¯·çŠ¶æ€ */
  export const Status = {
    DRAFT: 0,
    APPROVING: 15,
    APPROVED: 20,
    REJECTED: 35,
  } as const;
  /** ç”¨è½¦ç”³è¯· */
  export interface Application {
    id?: number;
    no?: string;
    applicantId?: number;
    applicantName?: string;
    useStartTime?: string;
    useEndTime?: string;
    departure?: string;
    destination?: string;
    passengers?: number;
    reason?: string;
    vehicleNo?: string;
    status?: number;
    statusName?: string;
    processInstanceId?: string;
    approveTime?: string;
    remark?: string;
    createTime?: string;
  }
  /** å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ProcessDefinition {
    id: string;
    key: string;
    name: string;
  }
}
/** æŸ¥è¯¢ç”¨è½¦ç”³è¯·åˆ†é¡µ */
export function getVehicleApplicationPage(params: PageParam) {
  return requestClient.get<PageResult<VehicleApplicationApi.Application>>(
    '/vehicle/application/page',
    { params },
  );
}
/** æŸ¥è¯¢ç”¨è½¦ç”³è¯·è¯¦æƒ… */
export function getVehicleApplication(id: number) {
  return requestClient.get<VehicleApplicationApi.Application>(
    `/vehicle/application/get?id=${id}`,
  );
}
/** æ–°å¢žç”¨è½¦ç”³è¯· */
export function createVehicleApplication(
  data: VehicleApplicationApi.Application,
) {
  return requestClient.post('/vehicle/application/create', data);
}
/** ä¿®æ”¹ç”¨è½¦ç”³è¯· */
export function updateVehicleApplication(
  data: VehicleApplicationApi.Application,
) {
  return requestClient.put('/vehicle/application/update', data);
}
/** åˆ é™¤ç”¨è½¦ç”³è¯· */
export function deleteVehicleApplication(id: number) {
  return requestClient.delete('/vehicle/application/delete', {
    params: { id },
  });
}
/** æäº¤ç”¨è½¦å®¡æ‰¹ */
export function submitVehicleApplication(
  id: number,
  processDefinitionKey: string,
) {
  return requestClient.post('/vehicle/application/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–ç”¨è½¦å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getVehicleApproveProcessList() {
  return requestClient.get<VehicleApplicationApi.ProcessDefinition[]>(
    '/vehicle/application/approve-process-list',
  );
}
src/api/wxcp/activity.ts
@@ -2,6 +2,20 @@
import { requestClient } from '#/api/request';
/** æ´»åŠ¨æ¨¡æ¿ç±»åž‹ */
export const ACTIVITY_TYPE = {
  /** çº¯é€šçŸ¥ï¼šåªæŽ¨ä¸€æ¡æ–‡æœ¬ï¼Œæ— è¡¨å• */
  TEXT: 1,
  /** å•条消息:推一张可点击卡片 */
  MESSAGE: 2,
  /** å›¾ç‰‡é€šçŸ¥ï¼šä¸Šä¼ å›¾ç‰‡åŽæŽ¨é€å›¾ç‰‡æ¶ˆæ¯ */
  IMAGE: 3,
  /** é™„件通知:上传文件后推送文件消息 */
  FILE: 4,
  /** æ•°æ®é‡‡é›†/评价:报名页 + è‡ªå®šä¹‰å­—段 + è¯„ä»· */
  FORM: 5,
} as const;
export namespace WxCpActivityApi {
  /** æ´»åŠ¨çŠ¶æ€ï¼š0草稿 1已发布 2已结束 3已取消 */
  export const ACTIVITY_STATUS = {
@@ -18,11 +32,41 @@
    USER: 2,
  } as const;
  /** é‡‡é›†å­—段 */
  export interface Field {
    id?: number;
    fieldLabel?: string;
    /** text / textarea / number / date / select / radio / checkbox / rating */
    fieldType?: string;
    /** å¤šä¸ªé€‰é¡¹ç”¨è‹±æ–‡é€—号分隔 */
    fieldOptions?: string;
    required?: boolean;
    placeholder?: string;
    sortNo?: number;
  }
  /** æ´»åЍ */
  export interface Activity {
    id?: number;
    title?: string;
    /** æ¨¡æ¿ç±»åž‹ï¼Œè§ ACTIVITY_TYPE */
    activityType?: number;
    activityTypeName?: string;
    content?: string;
    /** å¡ç‰‡æŒ‰é’®æ–‡æ¡ˆ */
    buttonText?: string;
    /** æ˜¯å¦å¼€å¯è¯„价(数据采集模板) */
    ratingEnabled?: boolean;
    ratingTitle?: string;
    ratingMax?: number;
    /** å›¾ç‰‡é€šçŸ¥çš„图片 blobId */
    imageBlobIds?: number[];
    imageAttachmentList?: SignupAttachment[];
    /** é™„件通知的附件 blobId */
    fileBlobIds?: number[];
    fileAttachmentList?: SignupAttachment[];
    /** æ•°æ®é‡‡é›†æ¨¡æ¿çš„自定义字段 */
    fieldList?: Field[];
    /**
     * å°é¢å›¾æ–‡ä»¶ç¼–号(最多 1 å¼ ï¼‰
     *
@@ -32,6 +76,16 @@
    blobIds?: number[];
    /** å°é¢å›¾é™„件列表(后端返回,最多 1 å¼ ï¼‰ */
    attachmentList?: SignupAttachment[];
    /**
     * è¯¦æƒ…图片文件编号(最多 5 å¼ ï¼‰
     *
     * ä¸Žå°é¢åŒæ ·èµ°é™„件中间表,但 recordType æ˜¯ wxcp_activity_detail。
     * æŠ¥åé¡µåœ¨ã€Œæ´»åŠ¨è¯¦æƒ…ã€æ–‡å­—ä¸‹æ–¹æŒ‰åŽŸå›¾æ¯”ä¾‹å®Œæ•´å±•ç¤ºï¼ˆä¸è£å‰ªï¼‰ï¼Œ
     * äºŒç»´ç /海报这类被裁就失效的图要放这里,不要放封面。
     */
    detailBlobIds?: number[];
    /** è¯¦æƒ…图片附件列表(后端返回,最多 5 å¼ ï¼‰ */
    detailAttachmentList?: SignupAttachment[];
    location?: string;
    startTime?: string;
    endTime?: string;
@@ -69,6 +123,10 @@
    mobile?: string;
    deptName?: string;
    content?: string;
    /** è¯„价星级 */
    ratingScore?: number;
    /** è‡ªå®šä¹‰é‡‡é›†å­—段值 */
    fieldValues?: { fieldId?: number; fieldLabel?: string; fieldType?: string; fieldValue?: string }[];
    /** çŠ¶æ€ï¼š0已报名 1已取消 2已签到 */
    status?: number;
    statusName?: string;
src/api/wxcp/notice.ts
@@ -15,6 +15,14 @@
    id?: number;
    title?: string;
    content?: string;
    noticeType?: number;
    noticeTypeName?: string;
    summary?: string;
    linkUrl?: string;
    linkBtnText?: string;
    coverUrl?: string;
    fileBlobId?: number;
    fileName?: string;
    status?: number;
    statusName?: string;
    pushScope?: number;
@@ -85,6 +93,13 @@
  id: number;
  pushScope?: number;
  pushTarget?: string;
  noticeType?: number;
  summary?: string;
  linkUrl?: string;
  linkBtnText?: string;
  coverUrl?: string;
  fileBlobId?: number;
  fileName?: string;
}) {
  return requestClient.post<{
    success: boolean;
src/components/upload/image-upload.vue
@@ -104,6 +104,10 @@
          return null;
        })
        .filter(Boolean) as UploadProps['fileList'];
    } else {
      // v ä¸ºç©ºï¼ˆnull/undefined)时必须清空列表:否则切换记录时(例如编辑过带封面的活动
      // å†ç‚¹æ–°å»ºã€æˆ–详情旋转到另一条)会残留上一条的图片,看起来是「图还在但换不了」
      fileList.value = [];
    }
    if (!isFirstRender.value) {
      emit('change', value);
@@ -169,8 +173,11 @@
 * @returns æ˜¯å¦å…è®¸ä¸Šä¼ 
 */
async function beforeUpload(file: File) {
  // æ£€æŸ¥æ–‡ä»¶æ•°é‡é™åˆ¶
  if (fileList.value!.length >= props.maxNumber) {
  // æ£€æŸ¥æ–‡ä»¶æ•°é‡é™åˆ¶ã€‚
  // maxNumber=1 æ—¶è¿™é‡Œå¿…须放行,否则「编辑」类场景(进表单时已有 1 å¼ å›žæ˜¾å›¾ï¼‰
  // ä¼šæ°¸è¿œæ¢ä¸äº†å›¾ï¼šantd çš„ onInternalChange åœ¨ maxCount===1 ä¸‹ä¼š slice(-1) åªç•™æœ€æ–°ä¸€å¼ ï¼Œ
  // æ–°å›¾å¤©ç„¶è¦†ç›–旧图,不需要在 beforeUpload é‡Œæ‹¦ã€‚
  if (fileList.value!.length >= props.maxNumber && props.maxNumber !== 1) {
    message.error($t('ui.upload.maxNumber', [props.maxNumber]));
    return Upload.LIST_IGNORE;
  }
@@ -322,8 +329,11 @@
      @preview="handlePreview"
      @remove="handleRemove"
    >
      <!-- maxNumber=1 æ—¶å³ä½¿å·²æœ‰ä¸€å¼ ä¹Ÿè¦ä¿ç•™ä¸Šä¼ å…¥å£ï¼ˆç”¨äºŽã€Œæ¢ä¸€å¼ ã€ï¼‰ï¼š
           æ’槽一旦为空,antd ä¼šç”¨ appendActionVisible=false æŠŠæ•´ä¸ªä¸Šä¼ æŒ‰é’® v-show æŽ‰ï¼Œ
           ç¼–辑场景下就彻底没有可点的上传入口了 -->
      <div
        v-if="fileList && fileList.length < maxNumber"
        v-if="fileList && (fileList.length < maxNumber || maxNumber === 1)"
        class="flex flex-col items-center justify-center"
      >
        <IconifyIcon icon="lucide:cloud-upload" />
src/views/asset/item/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,212 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** èµ„产分类选项 */
export const AssetCategoryOptions = [
  { label: '易耗品', value: 1 },
  { label: '办公资产', value: 2 },
  { label: '固定资产', value: 3 },
];
/** èµ„产分类名称 */
export function getCategoryName(category?: number) {
  return (
    AssetCategoryOptions.find((item) => item.value === category)?.label ?? '-'
  );
}
/** æ–°å¢ž/修改表单 */
export function useFormSchema(formType: 'create' | 'edit' | 'detail') {
  const disabled = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'assetNo',
      label: '资产编号',
      component: 'Input',
      componentProps: {
        placeholder: '留空则由系统自动生成',
        maxlength: 32,
        disabled,
      },
    },
    {
      fieldName: 'name',
      label: '资产名称',
      component: 'Input',
      componentProps: {
        placeholder: '如:激光打印机 / A4 å¤å°çº¸',
        maxlength: 128,
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'category',
      label: '资产分类',
      component: 'Select',
      componentProps: {
        options: AssetCategoryOptions,
        placeholder: '请选择资产分类',
        allowClear: true,
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'spec',
      label: '规格型号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入规格型号',
        maxlength: 128,
        disabled,
      },
    },
    {
      fieldName: 'unit',
      label: '计量单位',
      component: 'Input',
      componentProps: {
        placeholder: '如:台 / ä¸ª / ç®±',
        maxlength: 16,
        disabled,
      },
    },
    {
      fieldName: 'quantity',
      label: '库存数量',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入当前库存数量',
        min: 0,
        precision: 0,
        class: '!w-full',
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'unitPrice',
      label: '单价(元)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入单价',
        min: 0,
        precision: 2,
        class: '!w-full',
        disabled,
      },
    },
    {
      fieldName: 'purchaseDate',
      label: '购入日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择购入日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
        disabled,
      },
    },
    {
      fieldName: 'custodianName',
      label: '保管人(管理人)',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产保管人姓名',
        maxlength: 64,
        disabled,
      },
    },
    {
      fieldName: 'location',
      label: '存放地点',
      component: 'Input',
      componentProps: {
        placeholder: '请输入存放地点',
        maxlength: 128,
        disabled,
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled,
      },
      formItemClass: 'col-span-2',
    },
  ] as VbenFormSchema[];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'assetNo',
      label: '资产编号',
      component: 'Input',
      componentProps: { placeholder: '请输入资产编号', allowClear: true },
    },
    {
      fieldName: 'name',
      label: '资产名称',
      component: 'Input',
      componentProps: { placeholder: '请输入资产名称', allowClear: true },
    },
    {
      fieldName: 'category',
      label: '资产分类',
      component: 'Select',
      componentProps: {
        options: AssetCategoryOptions,
        placeholder: '请选择资产分类',
        allowClear: true,
      },
    },
    {
      fieldName: 'custodianName',
      label: '保管人',
      component: 'Input',
      componentProps: { placeholder: '请输入保管人', allowClear: true },
    },
  ];
}
/** åˆ—表列 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    { field: 'assetNo', title: '资产编号', width: 180, fixed: 'left' },
    { field: 'name', title: '资产名称', minWidth: 150 },
    {
      field: 'category',
      title: '资产分类',
      width: 110,
      slots: { default: 'category' },
    },
    { field: 'spec', title: '规格型号', minWidth: 140 },
    { field: 'unit', title: '单位', width: 70 },
    { field: 'quantity', title: '库存数量', width: 100 },
    { field: 'unitPrice', title: '单价(元)', width: 110 },
    { field: 'totalAmount', title: '总价值(元)', width: 120 },
    { field: 'custodianName', title: '保管人', width: 110 },
    { field: 'location', title: '存放地点', minWidth: 140 },
    {
      title: '操作',
      width: 180,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/asset/item/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AssetItemApi } from '#/api/asset/item';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteAssetItem,
  getAssetItemPage,
} from '#/api/asset/item';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'AssetItem' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: AssetItemApi.AssetItem) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
function handleDetail(row: AssetItemApi.AssetItem) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deleteAssetItem(id)));
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({ records }: { records: AssetItemApi.AssetItem[] }) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getAssetItemPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<AssetItemApi.AssetItem>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</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: ['asset:item:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['asset:item:delete'],
              popConfirm: {
                title: '是否删除所选资产?(存在未归还领用记录时不可删除)',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #category="{ row }">
        <Tag v-if="row.category === 1" color="default">易耗品</Tag>
        <Tag v-else-if="row.category === 2" color="processing">办公资产</Tag>
        <Tag v-else color="gold">固定资产</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['asset:item:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['asset:item:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['asset:item:delete'],
              popConfirm: {
                title: `确认删除资产「${row.name}」吗?`,
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/asset/item/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
<script lang="ts" setup>
import type { AssetItemApi } from '#/api/asset/item';
import { 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 {
  createAssetItem,
  getAssetItem,
  updateAssetItem,
} from '#/api/asset/item';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
type FormType = 'create' | 'detail' | 'edit';
const formType = ref<FormType>('create');
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema('create'),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as AssetItemApi.AssetItem;
    try {
      await (formType.value === 'create'
        ? createAssetItem(data)
        : updateAssetItem(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.setDisabled(data.formType === 'detail');
    formApi.updateSchema(useFormSchema(data.formType));
    if (!data.id) {
      await formApi.setValues(
        { category: 2, quantity: 1, unit: '个' },
        false,
      );
      return;
    }
    modalApi.lock();
    try {
      const item = await getAssetItem(data.id);
      await formApi.setValues(item, false);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="
      formType === 'create'
        ? $t('ui.actionTitle.create', ['资产'])
        : formType === 'edit'
          ? $t('ui.actionTitle.edit', ['资产'])
          : '资产详情'
    "
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/asset/requisition/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,206 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getAssetItemSimpleList } from '#/api/asset/item';
import { AssetCategoryOptions } from '../item/data';
/** å½’还状态选项 */
export const ReturnStatusOptions = [
  { label: '未归还', value: 1 },
  { label: '部分归还', value: 2 },
  { label: '已归还', value: 3 },
];
export function getCategoryName(category?: number) {
  return (
    AssetCategoryOptions.find((item) => item.value === category)?.label ?? '-'
  );
}
/** æ–°å¢ž/修改表单 */
export function useFormSchema(formType: 'create' | 'edit' | 'detail') {
  const disabled = formType === 'detail';
  const editDisabled = formType !== 'create';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'assetId',
      label: '领用资产',
      component: 'ApiSelect',
      componentProps: {
        api: getAssetItemSimpleList,
        labelField: 'name',
        valueField: 'id',
        placeholder: '请选择领用资产',
        allowClear: true,
        showSearch: true,
        disabled: disabled || editDisabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'recipientName',
      label: '领用人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入领用人姓名',
        maxlength: 64,
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'requisitionTime',
      label: '领用时间',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择领用时间',
        style: { width: '100%' },
        disabled: disabled || editDisabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'quantity',
      label: '领用数量',
      component: 'InputNumber',
      componentProps: {
        min: 1,
        precision: 0,
        placeholder: '请输入领用数量',
        class: '!w-full',
        disabled: disabled || editDisabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'expectedReturnDate',
      label: '预计归还日期',
      component: 'DatePicker',
      componentProps: {
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择预计归还日期',
        style: { width: '100%' },
        disabled,
      },
    },
    {
      fieldName: 'purpose',
      label: '用途',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入领用用途',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled,
      },
      formItemClass: 'col-span-2',
    },
  ] as VbenFormSchema[];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '领用单号',
      component: 'Input',
      componentProps: { placeholder: '请输入领用单号', allowClear: true },
    },
    {
      fieldName: 'assetName',
      label: '资产名称',
      component: 'Input',
      componentProps: { placeholder: '请输入资产名称', allowClear: true },
    },
    {
      fieldName: 'recipientName',
      label: '领用人',
      component: 'Input',
      componentProps: { placeholder: '请输入领用人', allowClear: true },
    },
    {
      fieldName: 'category',
      label: '资产分类',
      component: 'Select',
      componentProps: {
        options: AssetCategoryOptions,
        placeholder: '请选择资产分类',
        allowClear: true,
      },
    },
    {
      fieldName: 'returnStatus',
      label: '归还状态',
      component: 'Select',
      componentProps: {
        options: ReturnStatusOptions,
        placeholder: '请选择归还状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表列 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    { field: 'no', title: '领用单号', width: 175, fixed: 'left' },
    { field: 'assetNo', title: '资产编号', width: 175 },
    { field: 'assetName', title: '资产名称', minWidth: 140 },
    {
      field: 'category',
      title: '资产分类',
      width: 105,
      slots: { default: 'category' },
    },
    { field: 'custodianName', title: '保管人', width: 100 },
    { field: 'recipientName', title: '领用人', width: 100 },
    {
      field: 'requisitionTime',
      title: '领用时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    { field: 'quantity', title: '领用数量', width: 95 },
    { field: 'returnedQuantity', title: '已归还', width: 90 },
    { field: 'remainingQuantity', title: '未归还', width: 90 },
    {
      field: 'expectedReturnDate',
      title: '预计归还',
      width: 115,
      formatter: 'formatDate',
    },
    { field: 'purpose', title: '用途', minWidth: 150, showOverflow: 'tooltip' },
    {
      field: 'returnStatus',
      title: '归还状态',
      width: 100,
      slots: { default: 'returnStatus' },
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/asset/requisition/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,161 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AssetRequisitionApi } from '#/api/asset/requisition';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteAssetRequisition,
  getAssetRequisitionPage,
} from '#/api/asset/requisition';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ReturnForm from './modules/return-form.vue';
defineOptions({ name: 'AssetRequisition' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ReturnModal, returnModalApi] = useVbenModal({
  connectedComponent: ReturnForm,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: AssetRequisitionApi.AssetRequisition) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
function handleDetail(row: AssetRequisitionApi.AssetRequisition) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
function handleReturn(row: AssetRequisitionApi.AssetRequisition) {
  returnModalApi.setData(row).open();
}
async function handleDelete(row: AssetRequisitionApi.AssetRequisition) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await deleteAssetRequisition(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getAssetRequisitionPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<AssetRequisitionApi.AssetRequisition>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ReturnModal @success="handleRefresh" />
    <Grid table-title="资产领用管理">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增领用',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['asset:requisition:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #category="{ row }">
        <Tag v-if="row.category === 1" color="default">易耗品</Tag>
        <Tag v-else-if="row.category === 2" color="processing">办公资产</Tag>
        <Tag v-else color="gold">固定资产</Tag>
      </template>
      <template #returnStatus="{ row }">
        <Tag v-if="row.returnStatus === 1" color="warning">未归还</Tag>
        <Tag v-else-if="row.returnStatus === 2" color="processing">部分归还</Tag>
        <Tag v-else color="success">已归还</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['asset:requisition:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '归还',
              type: 'link',
              icon: ACTION_ICON.UPLOAD,
              auth: ['asset:requisition:return'],
              ifShow: () => (row.remainingQuantity ?? 0) > 0,
              onClick: handleReturn.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['asset:requisition:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['asset:requisition:delete'],
              ifShow: () => (row.returnedQuantity ?? 0) === 0,
              popConfirm: {
                title: `确认删除领用记录「${row.no}」吗?删除后未归还数量将退回库存`,
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/asset/requisition/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
<script lang="ts" setup>
import type { AssetRequisitionApi } from '#/api/asset/requisition';
import { 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 {
  createAssetRequisition,
  getAssetRequisition,
  updateAssetRequisition,
} from '#/api/asset/requisition';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
type FormType = 'create' | 'detail' | 'edit';
const formType = ref<FormType>('create');
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema('create'),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data =
      (await formApi.getValues()) as AssetRequisitionApi.AssetRequisition;
    try {
      await (formType.value === 'create'
        ? createAssetRequisition(data)
        : updateAssetRequisition(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.setDisabled(data.formType === 'detail');
    formApi.updateSchema(useFormSchema(data.formType));
    if (!data.id) {
      await formApi.setValues(
        { requisitionTime: dayjs().format('YYYY-MM-DD HH:mm:ss') },
        false,
      );
      return;
    }
    modalApi.lock();
    try {
      const requisition = await getAssetRequisition(data.id);
      await formApi.setValues(requisition, false);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="
      formType === 'create'
        ? $t('ui.actionTitle.create', ['资产领用'])
        : formType === 'edit'
          ? $t('ui.actionTitle.edit', ['资产领用'])
          : '资产领用详情'
    "
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/asset/requisition/modules/return-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { AssetRequisitionApi } from '#/api/asset/requisition';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import { returnAssetRequisition } from '#/api/asset/requisition';
const emit = defineEmits(['success']);
const current = ref<AssetRequisitionApi.AssetRequisition>();
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    labelWidth: 110,
  },
  wrapperClass: 'grid-cols-1',
  layout: 'vertical',
  schema: [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'returnQuantity',
      label: '本次归还数量',
      component: 'InputNumber',
      componentProps: {
        min: 1,
        precision: 0,
        class: '!w-full',
        placeholder: '请输入本次归还数量',
      },
      rules: 'required',
    },
    {
      fieldName: 'returnTime',
      label: '归还时间',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注(选填)',
        autoSize: { minRows: 2, maxRows: 3 },
      },
    },
  ],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const values =
      (await formApi.getValues()) as AssetRequisitionApi.ReturnReq;
    try {
      await returnAssetRequisition(values);
      await modalApi.close();
      emit('success');
      message.success('归还成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      current.value = undefined;
      return;
    }
    const data = modalApi.getData<AssetRequisitionApi.AssetRequisition>();
    current.value = data;
    await formApi.setValues(
      {
        id: data.id,
        returnQuantity: data.remainingQuantity ?? 1,
        returnTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
      },
      false,
    );
  },
});
</script>
<template>
  <Modal title="资产归还" class="w-1/2" :close-on-click-modal="false">
    <div v-if="current" class="mx-3 mb-3 rounded bg-gray-50 p-3 text-sm">
      <div>资产:{{ current.assetName }}({{ current.assetNo }})</div>
      <div>领用人:{{ current.recipientName }}</div>
      <div>
        é¢†ç”¨ {{ current.quantity }},已归还 {{ current.returnedQuantity }},未归还
        {{ current.remainingQuantity }}
      </div>
    </div>
    <Form class="mx-3" />
  </Modal>
</template>
src/views/finance/reimbursement/data.ts
@@ -11,6 +11,15 @@
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** å‘票类型选项(固定枚举,对应后台 finance_reimbursement_item.invoice_type) */
export const INVOICE_TYPE_OPTIONS = [
  { label: '增值税专用发票', value: '增值税专用发票' },
  { label: '增值税普通发票', value: '增值税普通发票' },
  { label: '电子发票', value: '电子发票' },
  { label: '收据', value: '收据' },
  { label: '其他', value: '其他' },
];
/** æŠ¥é”€å•状态(对应字典 finance_reimbursement_status) */
export const ReimbursementStatus = {
  DRAFT: 0, // è‰ç¨¿
@@ -39,6 +48,52 @@
        placeholder: '留空则由系统自动生成',
        maxlength: 36,
        disabled: formType === 'detail',
      },
    },
    {
      // æœ¬äººå‘起:申请人只读,默认当前登录用户
      fieldName: 'applicantName',
      label: '申请人',
      component: 'Input',
      componentProps: {
        placeholder: '默认为当前登录用户',
        disabled: true,
      },
      dependencies: {
        triggerFields: ['proxyFlag'],
        show: (values) => !values.proxyFlag,
      },
    },
    {
      // ä»£ä»–人发起:选择实际申请人,代录人由后端创建人字段记录
      fieldName: 'applicantId',
      label: '实际申请人',
      component: 'ApiSelect',
      componentProps: {
        placeholder: '请选择实际申请人',
        allowClear: true,
        showSearch: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['proxyFlag'],
        show: (values) => !!values.proxyFlag,
      },
    },
    {
      fieldName: 'proxyFlag',
      label: '代他人发起',
      component: 'Switch',
      componentProps: {
        checkedChildren: '代发起',
        unCheckedChildren: '本人',
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'detail',
      },
    },
    {
@@ -121,6 +176,12 @@
      slots: { default: 'expenseType' },
    },
    {
      field: 'invoiceType',
      title: '发票类型',
      minWidth: 150,
      slots: { default: 'invoiceType' },
    },
    {
      field: 'invoiceNo',
      title: '发票号码',
      minWidth: 160,
@@ -169,6 +230,7 @@
): FinanceReimbursementApi.ReimbursementItem[] {
  return (items ?? []).map((item) => ({
    expenseType: item.expenseType,
    invoiceType: item.invoiceType,
    invoiceNo: item.invoiceNo,
    invoiceAmount: item.invoiceAmount,
    invoiceDate: item.invoiceDate,
src/views/finance/reimbursement/detail/approve/index.vue
@@ -42,6 +42,12 @@
      },
    },
    {
      field: 'invoiceType',
      title: '发票类型',
      minWidth: 130,
      formatter: ({ cellValue }) => textRender(cellValue),
    },
    {
      field: 'invoiceNo',
      title: '发票号码',
      minWidth: 160,
src/views/finance/reimbursement/modules/form.vue
@@ -1,11 +1,13 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import type { SystemStorageApi } from '#/api/system/storage';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { useUserStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
@@ -21,6 +23,14 @@
import ItemForm from './item-form.vue';
const emit = defineEmits(['success']);
const userStore = useUserStore();
/** å½“前登录用户 ID(用于本人发起时回填申请人) */
const currentUserId = computed(() => {
  const id = Number(userStore.userInfo?.userId);
  return Number.isFinite(id) && id > 0 ? id : undefined;
});
const formData = ref<FinanceReimbursementApi.Reimbursement>({
  title: '',
@@ -74,6 +84,47 @@
  formApi.setValues({ totalAmount }, false);
}
/**
 * OCR è¯†åˆ«æ—¶ä¸Šä¼ çš„发票文件同步到「发票附件」,保证识别过的发票随单保存。
 * æŒ‰ blobId åŽ»é‡ï¼Œé¿å…åŒä¸€å¼ å‘ç¥¨è¢«é‡å¤è¯†åˆ«æ—¶å‡ºçŽ°é‡å¤é™„ä»¶ã€‚
 */
async function handleOcrBlobs(blobs: SystemStorageApi.StorageBlob[]) {
  if (blobs.length === 0) {
    return;
  }
  const values = await formApi.getValues();
  const existing = (values.blobIds ?? []) as Array<
    | number
    | { id?: number; name?: string; status?: string; uid?: string; url?: string }
  >;
  const existingFiles = existing.map((item) =>
    typeof item === 'number'
      ? { uid: String(item), status: 'done', id: item }
      : item,
  );
  const existingIds = new Set(
    existingFiles
      .map((item) => item.id)
      .filter((id): id is number => typeof id === 'number'),
  );
  const appended = blobs
    .filter((blob) => !existingIds.has(blob.id))
    .map((blob) => ({
      uid: String(blob.id),
      name: blob.name || blob.originalFilename || '',
      url: blob.url || blob.previewURL || '',
      status: 'done',
      id: blob.id,
    }));
  if (appended.length === 0) {
    return;
  }
  await formApi.setValues(
    { blobIds: [...existingFiles, ...appended] },
    false,
  );
}
/** åˆ›å»ºæˆ–更新报销单 */
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
@@ -91,12 +142,24 @@
      return;
    }
    modalApi.lock();
    const data =
      (await formApi.getValues()) as FinanceReimbursementApi.Reimbursement;
    const data = (await formApi.getValues()) as FinanceReimbursementApi.Reimbursement & {
      proxyFlag?: boolean;
    };
    // ä»£ä»–人发起必须选择实际申请人;本人发起时以当前登录用户为准
    if (data.proxyFlag) {
      if (!data.applicantId) {
        message.error('代他人发起时,请选择实际申请人');
        return;
      }
    } else {
      data.applicantId = currentUserId.value;
    }
    // proxyFlag ä»…用于前端交互,不提交给后端
    delete data.proxyFlag;
    // å‘票附件为全量替换语义:清空全部发票时必须显式传空数组,
    // ä¼  null åŽç«¯ä¼šè§†ä¸ºã€Œä¸åŠ¨é™„ä»¶ã€ï¼Œå¯¼è‡´åˆ é™¤æ“ä½œä¸ç”Ÿæ•ˆ
    data.blobIds = data.blobIds ?? [];
    modalApi.lock();
    try {
      await (formType.value === 'create'
        ? createReimbursement(data)
@@ -120,9 +183,14 @@
    formApi.updateSchema(useFormSchema(formType.value));
    if (!data || !data.id) {
      initialItems.value = [];
      // æ–°å»ºæ—¶é»˜è®¤ç”³è¯·æ—¥æœŸä¸ºä»Šå¤©
      // æ–°å»ºæ—¶é»˜è®¤æœ¬äººå‘起:申请人展示当前登录用户,申请日期为今天
      await formApi.setValues(
        { applyDate: dayjs().format('YYYY-MM-DD') },
        {
          applicantId: currentUserId.value,
          applicantName: userStore.userInfo?.nickname ?? '',
          proxyFlag: false,
          applyDate: dayjs().format('YYYY-MM-DD'),
        },
        false,
      );
      return;
@@ -140,9 +208,15 @@
        status: 'done',
        id: item.id,
      }));
      // å®žé™…申请人与当前登录用户不一致时,视为代他人发起,回显申请人选择器
      const isProxy =
        formType.value === 'edit' &&
        formData.value.applicantId != null &&
        formData.value.applicantId !== currentUserId.value;
      await formApi.setValues(
        {
          ...formData.value,
          proxyFlag: isProxy,
          // è¡¨å•里的 items åªç”¨äºŽæäº¤ï¼Œå¿…须剥掉行 id(后端「先删后增」会与软删行主键冲突)
          items: toSubmitItems(formData.value.items),
          blobIds,
@@ -170,6 +244,7 @@
          :items="initialItems"
          :disabled="formType === 'detail'"
          class="w-full"
          @ocr-blobs="handleOcrBlobs"
          @update:items="handleUpdateItems"
          @update:total-amount="handleUpdateTotalAmount"
        />
src/views/finance/reimbursement/modules/item-form.vue
@@ -1,21 +1,35 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinanceReimbursementAiApi } from '#/api/finance/reimbursement/ai';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import type { SystemStorageApi } from '#/api/system/storage';
import type { DefaultOptionType } from 'ant-design-vue/es/select';
import { nextTick, watch } from 'vue';
import { nextTick, ref, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { erpPriceInputFormatter } from '@vben/utils';
import { Button, DatePicker, Input, InputNumber, Select } from 'ant-design-vue';
import {
  Button,
  DatePicker,
  Input,
  InputNumber,
  Select,
  message,
} from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { $t } from '#/locales';
import { toSubmitItems, useItemColumns } from '../data';
import {
  INVOICE_TYPE_OPTIONS,
  toSubmitItems,
  useItemColumns,
} from '../data';
import OcrUpload from './ocr-upload.vue';
const props = withDefaults(
  defineProps<{
@@ -28,7 +42,13 @@
  },
);
const emit = defineEmits(['update:items', 'update:totalAmount']);
const emit = defineEmits<{
  'ocr-blobs': [blobs: SystemStorageApi.StorageBlob[]];
  'update:items': [items: FinanceReimbursementApi.ReimbursementItem[]];
  'update:totalAmount': [totalAmount: number];
}>();
const ocrUploadRef = ref<InstanceType<typeof OcrUpload>>();
const expenseTypeOptions = getDictOptions(
  DICT_TYPE.FINANCE_EXPENSE_TYPE,
@@ -101,6 +121,22 @@
  recalc();
}
/** OCR è¯†åˆ«ç»“果:追加为明细行,并把发票文件同步给主表附件 */
async function handleOcrSuccess(
  payload: FinanceReimbursementAiApi.OcrImportPayload,
) {
  for (const item of payload.items) {
    await gridApi.grid.insertAt(item, -1);
  }
  recalc();
  message.success(
    `已填入 ${payload.items.length} æ¡å‘票明细,请补充费用类型后提交`,
  );
  if (payload.blobs.length > 0) {
    emit('ocr-blobs', payload.blobs);
  }
}
/** åˆ é™¤æ˜Žç»†è¡Œ */
async function handleDelete(row: FinanceReimbursementApi.ReimbursementItem) {
  await gridApi.grid.remove(row);
@@ -146,6 +182,7 @@
</script>
<template>
  <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
  <Grid class="w-full">
    <template #expenseType="{ row }">
      <Select
@@ -153,6 +190,16 @@
        :disabled="disabled"
        :options="expenseTypeOptions"
        placeholder="请选择费用类型"
        allow-clear
        @change="() => recalc()"
      />
    </template>
    <template #invoiceType="{ row }">
      <Select
        v-model:value="row.invoiceType"
        :disabled="disabled"
        :options="INVOICE_TYPE_OPTIONS"
        placeholder="请选择发票类型"
        allow-clear
        @change="() => recalc()"
      />
@@ -217,7 +264,8 @@
      />
    </template>
  </Grid>
  <div v-if="!disabled" class="-mt-4 flex justify-center">
  <div v-if="!disabled" class="-mt-4 flex justify-center gap-2">
    <Button @click="ocrUploadRef?.open()">OCR è¯†åˆ«å‘票</Button>
    <Button type="primary" ghost @click="handleAdd"> æ·»åŠ æŠ¥é”€æ˜Žç»† </Button>
  </div>
</template>
src/views/finance/reimbursement/modules/ocr-upload.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,283 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { FinanceReimbursementAiApi } from '#/api/finance/reimbursement/ai';
import type { SystemStorageApi } from '#/api/system/storage';
import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
  Alert,
  Button,
  Modal,
  Table,
  Tag,
  Upload,
  message,
} from 'ant-design-vue';
import { ocrReimbursementInvoices } from '#/api/finance/reimbursement/ai';
import { uploadFile } from '#/api/system/storage';
import { INVOICE_TYPE_OPTIONS } from '../data';
defineOptions({ name: 'FinanceReimbursementOcrUpload' });
const emit = defineEmits<{
  success: [payload: FinanceReimbursementAiApi.OcrImportPayload];
}>();
/** å·²ä¸Šä¼ æ–‡ä»¶ï¼šuid -> blob,用于删除文件和确认回填 */
const blobMap = new Map<string, SystemStorageApi.StorageBlob>();
/** blobId -> æ–‡ä»¶åï¼Œç”¨äºŽå±•示识别失败原因 */
const blobNameMap = new Map<number, string>();
const open = ref(false);
const step = ref(0); // 0=上传, 1=识别中, 2=预览结果
const fileList = ref<UploadFile[]>([]);
const blobCount = ref(0);
const uploading = ref(0);
const results = ref<FinanceReimbursementAiApi.OcrResultVO[]>([]);
const columns = [
  { title: '发票号码', dataIndex: 'invoiceNo', key: 'invoiceNo', width: 170 },
  { title: '发票类型', dataIndex: 'invoiceType', key: 'invoiceType', width: 160 },
  { title: '开票日期', dataIndex: 'invoiceDate', key: 'invoiceDate', width: 110 },
  { title: '金额(元)', dataIndex: 'invoiceAmount', key: 'invoiceAmount', width: 110 },
  { title: '销售方', dataIndex: 'sellerName', key: 'sellerName' },
  { title: '状态', key: 'status', width: 80 },
];
/** è¯†åˆ«æˆåŠŸçš„å‘ç¥¨ */
const successResults = computed(() =>
  results.value.filter((item) => !item.rawText),
);
/** è¯†åˆ«å¤±è´¥çš„æ–‡ä»¶åŠåŽŸå›  */
const failedMessages = computed(() =>
  results.value
    .filter((item) => item.rawText)
    .map(
      (item) =>
        `${blobNameMap.get(item.blobId ?? -1) ?? '文件'}:${item.rawText}`,
    ),
);
function handleOpen() {
  handleReset();
  open.value = true;
}
/** è‡ªå®šä¹‰ä¸Šä¼ ï¼šåªè´Ÿè´£æŠŠæ–‡ä»¶ä¼ åˆ°å­˜å‚¨ï¼Œè¯†åˆ«ç”±ã€Œå¼€å§‹è¯†åˆ«ã€ç»Ÿä¸€è§¦å‘ */
const handleUpload: UploadProps['customRequest'] = async (options) => {
  const { file, onError, onSuccess } = options;
  uploading.value += 1;
  try {
    const result = await uploadFile([file as File]);
    const blob = result?.[0];
    if (!blob?.id) {
      throw new Error('文件上传失败');
    }
    const uid = String((file as File & { uid?: string }).uid ?? blob.id);
    blobMap.set(uid, blob);
    blobNameMap.set(blob.id, blob.originalFilename || blob.name || '');
    blobCount.value = blobMap.size;
    onSuccess?.(blob);
  } catch (error) {
    onError?.(error as Error);
  } finally {
    uploading.value -= 1;
  }
};
function handleRemove(file: UploadFile) {
  blobMap.delete(String(file.uid));
  blobCount.value = blobMap.size;
  if (step.value === 2) {
    handleReset();
  }
}
/** å¼€å§‹æ‰¹é‡è¯†åˆ« */
async function handleStart() {
  const blobs = [...blobMap.values()];
  if (blobs.length === 0) {
    message.warning('请先上传发票文件');
    return;
  }
  step.value = 1;
  try {
    results.value =
      (await ocrReimbursementInvoices(blobs.map((blob) => blob.id))) ?? [];
    if (successResults.value.length === 0) {
      step.value = 0;
      message.error(
        failedMessages.value[0] ?? '未识别到发票数据,请手动录入或重新上传',
      );
      return;
    }
    step.value = 2;
  } catch {
    step.value = 0;
  }
}
/** æŠŠ OCR è¿”回的发票类型归一化为下拉枚举值 */
function normalizeInvoiceType(raw?: string) {
  if (!raw) {
    return undefined;
  }
  if (raw.includes('收据')) {
    return '收据';
  }
  if (raw.includes('专用')) {
    return '增值税专用发票';
  }
  // ã€Œå¢žå€¼ç¨Žç”µå­æ™®é€šå‘票」「电子发票(普通发票)」统一归为电子发票
  if (raw.includes('电子')) {
    return '电子发票';
  }
  if (raw.includes('普通')) {
    return '增值税普通发票';
  }
  return INVOICE_TYPE_OPTIONS[INVOICE_TYPE_OPTIONS.length - 1]?.value;
}
/** ç¡®è®¤è¯†åˆ«ç»“果,回填明细与附件 */
function handleConfirm() {
  const items = successResults.value.map((item) => ({
    invoiceType: normalizeInvoiceType(item.invoiceType),
    invoiceNo: item.invoiceNo,
    invoiceAmount: item.invoiceAmount,
    invoiceDate: item.invoiceDate,
    // é”€å”®æ–¹å³æŠ¥é”€æ˜Žç»†ä¸­çš„开票方
    drawer: item.sellerName,
  })) as unknown as FinanceReimbursementAiApi.OcrImportPayload['items'];
  emit('success', { items, blobs: [...blobMap.values()] });
  handleClose();
}
function handleReset() {
  step.value = 0;
  fileList.value = [];
  blobMap.clear();
  blobNameMap.clear();
  blobCount.value = 0;
  results.value = [];
}
function handleClose() {
  open.value = false;
  handleReset();
}
defineExpose({ open: handleOpen });
</script>
<template>
  <Modal
    v-model:open="open"
    title="OCR è¯†åˆ«å‘票"
    width="760px"
    :footer="null"
    @cancel="handleClose"
  >
    <!-- Step 0: ä¸Šä¼  -->
    <template v-if="step === 0">
      <Upload.Dragger
        v-model:file-list="fileList"
        multiple
        :max-count="20"
        :custom-request="handleUpload"
        accept=".pdf,.png,.jpg,.jpeg,.gif,.bmp"
        @remove="handleRemove"
      >
        <p class="text-4xl text-gray-400">
          <IconifyIcon icon="ant-design:inbox-outlined" />
        </p>
        <p class="text-base text-gray-500">
          ç‚¹å‡»æˆ–拖拽发票图片 / PDF åˆ°æ­¤åŒºåŸŸä¸Šä¼ 
        </p>
        <p class="text-sm text-gray-400">
          æ”¯æŒ JPG、PNG、BMP、GIF、PDF,最多 20 ä¸ªæ–‡ä»¶ï¼›æ‰‹æœºç«¯å¯ç›´æŽ¥æ‹ç…§ä¸Šä¼ 
        </p>
      </Upload.Dragger>
      <Alert
        class="mt-3"
        type="info"
        show-icon
        message="识别结果仅用于回填,发票号码、金额、日期、销售方请在明细中核对后再提交。"
      />
      <div class="mt-4 flex justify-end gap-2">
        <Button @click="handleClose">取消</Button>
        <Button
          type="primary"
          :disabled="uploading > 0 || blobCount === 0"
          :loading="uploading > 0"
          @click="handleStart"
        >
          {{ uploading > 0 ? '文件上传中...' : `开始识别(${blobCount})` }}
        </Button>
      </div>
    </template>
    <!-- Step 1: è¯†åˆ«ä¸­ -->
    <template v-else-if="step === 1">
      <div class="flex flex-col items-center gap-4 py-12">
        <a-spin size="large" />
        <span class="text-base text-gray-500">
          æ­£åœ¨æ‰¹é‡è¯†åˆ« {{ blobCount }} ä¸ªå‘票文件,请稍候...
        </span>
      </div>
    </template>
    <!-- Step 2: é¢„览结果 -->
    <template v-else-if="step === 2">
      <Alert
        v-if="failedMessages.length > 0"
        class="mb-3"
        type="warning"
        show-icon
        :message="`${failedMessages.length} ä¸ªæ–‡ä»¶æœªè¯†åˆ«æˆåŠŸï¼Œå¤±è´¥æ–‡ä»¶ä»ä¼šä½œä¸ºå‘ç¥¨é™„ä»¶ä¿å­˜`"
        :description="failedMessages.join(';')"
      />
      <Table
        :columns="columns"
        :data-source="results"
        :pagination="false"
        bordered
        size="small"
        :scroll="{ y: 320 }"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'status'">
            <Tag v-if="record.rawText" color="red">失败</Tag>
            <Tag v-else color="green">成功</Tag>
          </template>
          <template v-else-if="column.key === 'invoiceAmount'">
            {{ record.invoiceAmount ?? '-' }}
          </template>
        </template>
      </Table>
      <Alert
        class="mt-3"
        type="info"
        show-icon
        message="确认后,识别成功的发票将追加为报销明细行;费用类型需手动选择,金额等信息可继续修改。"
      />
      <div class="mt-4 flex justify-end gap-2">
        <Button @click="handleReset">重新上传</Button>
        <Button
          type="primary"
          :disabled="successResults.length === 0"
          @click="handleConfirm"
        >
          ç¡®è®¤å¹¶å¡«å…¥ï¼ˆ{{ successResults.length }})
        </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/purchase/order/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { PurchaseOrderApi } from '#/api/purchase/order';
/** æ–°å¢ž/修改表单 */
export function useFormSchema(formType: 'create' | 'edit' | 'detail') {
  const disabled = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'purchaseDate',
      label: '采购时间',
      component: 'DatePicker',
      componentProps: {
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择采购时间',
        style: { width: '100%' },
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'buyerName',
      label: '采购人(经办人)',
      component: 'Input',
      componentProps: {
        placeholder: '请输入采购人(经办人)姓名',
        maxlength: 64,
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'items',
      label: '采购商品明细',
      component: 'Input',
      formItemClass: 'col-span-2',
    },
  ] as VbenFormSchema[];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '采购单号',
      component: 'Input',
      componentProps: { placeholder: '请输入采购单号', allowClear: true },
    },
    {
      fieldName: 'buyerName',
      label: '采购人',
      component: 'Input',
      componentProps: { placeholder: '请输入采购人', allowClear: true },
    },
  ];
}
/** åˆ—表列 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    { field: 'no', title: '采购单号', width: 180, fixed: 'left' },
    {
      field: 'purchaseDate',
      title: '采购时间',
      width: 120,
      formatter: 'formatDate',
    },
    { field: 'buyerName', title: '采购人(经办人)', minWidth: 140 },
    { field: 'totalAmount', title: '采购金额(元)', width: 140 },
    { field: 'remark', title: '备注', minWidth: 180, showOverflow: 'tooltip' },
    {
      title: '操作',
      width: 190,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** æ˜Žç»†è¡Œæäº¤å½¢æ€ï¼šåŽ»æŽ‰ id ä¸Ž orderId,避免软删主键冲突 */
export function toSubmitItems(
  items?: PurchaseOrderApi.OrderItem[],
): PurchaseOrderApi.OrderItem[] {
  return (items ?? []).map((item) => ({
    itemName: item.itemName,
    spec: item.spec,
    quantity: item.quantity,
    unitPrice: item.unitPrice,
    amount: item.amount,
    sortNo: item.sortNo,
  }));
}
src/views/purchase/order/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,159 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { PurchaseOrderApi } from '#/api/purchase/order';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deletePurchaseOrder,
  getPurchaseOrderPage,
} from '#/api/purchase/order';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'PurchaseOrder' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: PurchaseOrderApi.Order) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
function handleDetail(row: PurchaseOrderApi.Order) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deletePurchaseOrder(id)));
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: PurchaseOrderApi.Order[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getPurchaseOrderPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<PurchaseOrderApi.Order>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="采购单">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增采购单',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['purchase:order:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['purchase:order:delete'],
              popConfirm: {
                title: '是否删除所选采购单?',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['purchase:order:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['purchase:order:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['purchase:order:delete'],
              popConfirm: {
                title: `确认删除采购单「${row.no}」吗?`,
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/purchase/order/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,131 @@
<script lang="ts" setup>
import type { PurchaseOrderApi } from '#/api/purchase/order';
import { 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 {
  createPurchaseOrder,
  getPurchaseOrder,
  updatePurchaseOrder,
} from '#/api/purchase/order';
import { toSubmitItems, useFormSchema } from '../data';
import ItemTable from './item-table.vue';
const emit = defineEmits(['success']);
type FormType = 'create' | 'detail' | 'edit';
const formType = ref<FormType>('create');
const itemTableRef = ref<InstanceType<typeof ItemTable>>();
const initialItems = ref<PurchaseOrderApi.OrderItem[]>([]);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    labelWidth: 130,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema('create'),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    const itemTable = Array.isArray(itemTableRef.value)
      ? itemTableRef.value[0]
      : itemTableRef.value;
    try {
      itemTable?.validate();
    } catch (error: any) {
      message.error(error.message || '采购商品明细验证失败');
      return;
    }
    const items = itemTable?.getItems() ?? [];
    modalApi.lock();
    const data = (await formApi.getValues()) as PurchaseOrderApi.Order;
    data.items = items;
    try {
      await (formType.value === 'create'
        ? createPurchaseOrder(data)
        : updatePurchaseOrder(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      initialItems.value = [];
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(data.formType === 'detail');
    formApi.updateSchema(useFormSchema(data.formType));
    if (!data.id) {
      initialItems.value = [];
      await formApi.setValues(
        { purchaseDate: dayjs().format('YYYY-MM-DD') },
        false,
      );
      return;
    }
    modalApi.lock();
    try {
      const order = await getPurchaseOrder(data.id);
      initialItems.value = order.items ?? [];
      await formApi.setValues(
        {
          ...order,
          items: toSubmitItems(order.items),
        },
        false,
      );
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="
      formType === 'create'
        ? $t('ui.actionTitle.create', ['采购单'])
        : formType === 'edit'
          ? $t('ui.actionTitle.edit', ['采购单'])
          : '采购单详情'
    "
    class="w-4/5"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3">
      <template #items>
        <ItemTable
          ref="itemTableRef"
          :items="initialItems"
          :disabled="formType === 'detail'"
          class="w-full"
          @update:items="(items) => formApi.setValues({ items }, false)"
        />
      </template>
    </Form>
  </Modal>
</template>
src/views/purchase/order/modules/item-table.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,215 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { PurchaseOrderApi } from '#/api/purchase/order';
import { nextTick, watch } from 'vue';
import { Button, Input, InputNumber } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { $t } from '#/locales';
import { toSubmitItems } from '../data';
const props = withDefaults(
  defineProps<{
    items?: PurchaseOrderApi.OrderItem[];
    disabled?: boolean;
  }>(),
  {
    items: () => [],
    disabled: false,
  },
);
const emit = defineEmits<{
  'update:items': [items: PurchaseOrderApi.OrderItem[]];
}>();
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { type: 'seq', title: '序号', width: 55, fixed: 'left' },
      {
        field: 'itemName',
        title: '商品名称',
        minWidth: 160,
        slots: { default: 'itemName' },
      },
      {
        field: 'spec',
        title: '规格型号',
        minWidth: 150,
        slots: { default: 'spec' },
      },
      {
        field: 'quantity',
        title: '数量',
        width: 120,
        slots: { default: 'quantity' },
      },
      {
        field: 'unitPrice',
        title: '单价(元)',
        width: 130,
        slots: { default: 'unitPrice' },
      },
      {
        field: 'amount',
        title: '金额(元)',
        width: 130,
        slots: { default: 'amount' },
      },
      {
        title: '操作',
        width: 80,
        fixed: 'right',
        slots: { default: 'actions' },
      },
    ],
    data: [],
    minHeight: 180,
    autoResize: true,
    border: true,
    rowConfig: { keyField: 'id', isHover: true },
    pagerConfig: { enabled: false },
    toolbarConfig: { enabled: false },
  } as VxeTableGridOptions<PurchaseOrderApi.OrderItem>,
});
function calcAmount(row: PurchaseOrderApi.OrderItem) {
  const quantity = Number(row.quantity ?? 0);
  const unitPrice = Number(row.unitPrice ?? 0);
  return Math.round(quantity * unitPrice * 100) / 100;
}
/** è¯»å–当前全部明细行(提交形态) */
function collectItems(): PurchaseOrderApi.OrderItem[] {
  const rows =
    (gridApi.grid.getData() as PurchaseOrderApi.OrderItem[]) ?? [];
  const removed =
    (gridApi.grid.getRemoveRecords() as PurchaseOrderApi.OrderItem[]) ?? [];
  const inserted =
    (gridApi.grid.getInsertRecords() as PurchaseOrderApi.OrderItem[]) ?? [];
  const isRemoved = (row: PurchaseOrderApi.OrderItem) =>
    removed.some(
      (item) => item === row || (row.id != null && item.id === row.id),
    );
  return toSubmitItems([
    ...rows.filter((row) => !isRemoved(row)),
    ...inserted,
  ]).map((item) => ({ ...item, amount: calcAmount(item as PurchaseOrderApi.OrderItem) }));
}
function recalc() {
  emit('update:items', collectItems());
}
async function handleAdd() {
  await gridApi.grid.insertAt(
    { quantity: 1, unitPrice: 0, sortNo: collectItems().length + 1 } as unknown as PurchaseOrderApi.OrderItem,
    -1,
  );
  recalc();
}
async function handleDelete(row: PurchaseOrderApi.OrderItem) {
  await gridApi.grid.remove(row);
  recalc();
}
/** æ ¡éªŒæ˜Žç»†ï¼šå•†å“åç§°å¿…填,数量>0,单价≥0 */
function validate() {
  const rows = collectItems();
  if (rows.length === 0) {
    throw new Error('采购单至少需要 1 è¡Œé‡‡è´­å•†å“æ˜Žç»†');
  }
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i]!;
    if (!row.itemName || String(row.itemName).trim() === '') {
      throw new Error(`第 ${i + 1} è¡Œé‡‡è´­å•†å“ï¼šå•†å“åç§°ä¸èƒ½ä¸ºç©º`);
    }
    if (!row.quantity || Number(row.quantity) <= 0) {
      throw new Error(`第 ${i + 1} è¡Œé‡‡è´­å•†å“ï¼šæ•°é‡å¿…须大于 0`);
    }
    if (row.unitPrice == null || Number(row.unitPrice) < 0) {
      throw new Error(`第 ${i + 1} è¡Œé‡‡è´­å•†å“ï¼šå•价不能小于 0`);
    }
  }
}
defineExpose({ validate, getItems: collectItems });
watch(
  () => props.items,
  async (items) => {
    if (!items) {
      return;
    }
    await nextTick();
    await gridApi.grid.loadData([...items]);
  },
  { immediate: true },
);
</script>
<template>
  <Grid class="w-full">
    <template #itemName="{ row }">
      <Input
        v-model:value="row.itemName"
        :disabled="disabled"
        placeholder="请输入商品名称"
        @change="() => recalc()"
      />
    </template>
    <template #spec="{ row }">
      <Input
        v-model:value="row.spec"
        :disabled="disabled"
        placeholder="请输入规格型号"
        @change="() => recalc()"
      />
    </template>
    <template #quantity="{ row }">
      <InputNumber
        v-model:value="row.quantity"
        :disabled="disabled"
        :min="0.01"
        :precision="2"
        class="!w-full"
        @change="() => recalc()"
      />
    </template>
    <template #unitPrice="{ row }">
      <InputNumber
        v-model:value="row.unitPrice"
        :disabled="disabled"
        :min="0"
        :precision="2"
        class="!w-full"
        @change="() => recalc()"
      />
    </template>
    <template #amount="{ row }">
      <span>{{ calcAmount(row).toFixed(2) }}</span>
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.delete'),
            danger: true,
            type: 'link',
            icon: ACTION_ICON.DELETE,
            disabled,
            onClick: handleDelete.bind(null, row),
          },
        ]"
      />
    </template>
  </Grid>
  <div v-if="!disabled" class="-mt-4 flex justify-center">
    <Button type="primary" ghost @click="handleAdd"> æ·»åŠ å•†å“æ˜Žç»† </Button>
  </div>
</template>
src/views/research/application/data.ts
@@ -2,7 +2,10 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchApplicationApi } from '#/api/research/application';
import type { ResearchGuidelineApi } from '#/api/research/guideline';
import { z } from '#/adapter/form';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -16,6 +19,30 @@
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** è¯¾é¢˜ç»„成员角色选项 */
export const MEMBER_ROLE_OPTIONS = [
  { label: '主持人', value: '主持人' },
  { label: '核心成员', value: '核心成员' },
  { label: '参与人', value: '参与人' },
];
/**
 * æˆå‘˜è¡ŒæŠ•影为可提交对象:只保留后端接受的字段,
 * ä¸å¸¦ id / applicationId,避免与「先删后增」的软删行主键冲突。
 */
export function toSubmitMembers(
  members?: ResearchApplicationApi.ApplicationMember[],
): ResearchApplicationApi.ApplicationMember[] {
  return (members ?? []).map((member) => ({
    name: member.name,
    contactPhone: member.contactPhone,
    workUnit: member.workUnit,
    jobTitle: member.jobTitle,
    memberRole: member.memberRole,
    sortNo: member.sortNo,
  }));
}
/** è¯¾é¢˜ç”³æŠ¥ä¹¦çŠ¶æ€ï¼ˆå¯¹åº”å­—å…¸ research_application_status) */
export const ApplicationStatus = {
@@ -180,10 +207,14 @@
      label: '联系电话',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系电话',
        maxlength: 20,
        placeholder: '请输入 11 ä½æ‰‹æœºå·',
        maxlength: 11,
        disabled: readonly,
      },
      rules: z
        .string({ message: '联系电话不能为空' })
        .min(1, '联系电话不能为空')
        .regex(/^1[3-9]\d{9}$/, '联系电话格式不正确,请输入正确的 11 ä½æ‰‹æœºå·'),
    },
    {
      fieldName: 'contactEmail',
@@ -209,15 +240,10 @@
      },
    },
    {
      fieldName: 'teamMembers',
      // å­è¡¨ç¼–辑由 form.vue çš„ #members æ’槽渲染
      fieldName: 'members',
      label: '课题组成员',
      component: 'Textarea',
      componentProps: {
        placeholder: '每行一条,格式:姓名|单位|职称\n例如:李四|XX大学|副教授',
        autoSize: { minRows: 3, maxRows: 6 },
        disabled: readonly,
      },
      help: '每行一条,用竖线分隔:姓名|单位|职称',
      component: 'Input',
      formItemClass: 'col-span-2',
    },
    {
src/views/research/application/detail/approve/index.vue
@@ -10,7 +10,7 @@
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
import { Spin, Table, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getApplication, getTopicReviewList } from '#/api/research/application';
@@ -37,6 +37,16 @@
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** è¯¾é¢˜ç»„成员只读列 */
const memberColumns = [
  { title: '姓名', dataIndex: 'name', key: 'name', width: 110 },
  { title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', width: 130 },
  { title: '所属单位', dataIndex: 'workUnit', key: 'workUnit' },
  { title: '职称/职务', dataIndex: 'jobTitle', key: 'jobTitle', width: 120 },
  { title: '成员角色', dataIndex: 'memberRole', key: 'memberRole', width: 110 },
  { title: '排序', dataIndex: 'sortNo', key: 'sortNo', width: 70 },
];
/** é‡‘额字段空值占位 */
function amountRender(val: string | number | undefined) {
@@ -189,15 +199,34 @@
          <Descriptions :data="formData" />
        </div>
        <!-- è¯¾é¢˜ç»„成员 -->
        <!-- è¯¾é¢˜ç»„成员(子表) -->
        <div
          v-if="formData?.teamMembers"
          v-if="formData?.members?.length"
          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>
          <Table
            :columns="memberColumns"
            :data-source="formData.members"
            :pagination="false"
            bordered
            size="small"
            row-key="id"
          />
        </div>
        <!-- åŽ†å²æ•°æ®å…¼å®¹ï¼šæ—§ç‰ˆæ–‡æœ¬æ ¼å¼çš„è¯¾é¢˜ç»„æˆå‘˜ -->
        <div
          v-else-if="formData?.teamMembers"
          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>
          <pre class="whitespace-pre-wrap break-all font-sans">{{
            formData.teamMembers
          }}</pre>
src/views/research/application/index.vue
@@ -27,6 +27,7 @@
} from './data';
import FundPanel from './modules/fund-panel.vue';
import Form from './modules/form.vue';
import MemberStatisticsModal from './modules/member-statistics-modal.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
import ReviewPanel from './modules/review-panel.vue';
@@ -54,6 +55,8 @@
  connectedComponent: FundPanel,
  destroyOnClose: true,
});
const memberStatisticsRef = ref<InstanceType<typeof MemberStatisticsModal>>();
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -119,6 +122,11 @@
/** ç»è´¹æ‹¨ä»˜é¢æ¿ */
function handleFund(row: ResearchApplicationApi.Application) {
  fundModalApi.setData({ id: row.id }).open();
}
/** æˆå‘˜ç»´åº¦ç»Ÿè®¡ */
function handleMemberStatistics() {
  memberStatisticsRef.value?.open();
}
/** å‘布立项通知 */
@@ -207,6 +215,7 @@
    <ProcessModal @success="handleRefresh" />
    <ReviewModal @success="handleRefresh" />
    <FundModal @success="handleRefresh" />
    <MemberStatisticsModal ref="memberStatisticsRef" />
    <Grid table-title="课题申报书列表">
      <template #toolbar-tools>
        <TableAction
@@ -219,6 +228,13 @@
              onClick: handleCreate,
            },
            {
              label: '成员统计',
              type: 'primary',
              icon: ACTION_ICON.VIEW,
              auth: ['research:application:query'],
              onClick: handleMemberStatistics,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
src/views/research/application/modules/form.vue
@@ -21,7 +21,8 @@
  getPublishedGuidelineList,
} from '#/api/research/guideline';
import { useFormSchema } from '../data';
import { toSubmitMembers, useFormSchema } from '../data';
import MemberTable from './member-table.vue';
defineOptions({ name: 'ResearchApplicationForm' });
@@ -30,6 +31,9 @@
const formType = ref<FormType>('create');
/** ä¾›ã€Œæ‰€å±žè¯¾é¢˜æŒ‡å—」下拉与类别带出使用 */
const guidelines = ref<ResearchGuidelineApi.Guideline[]>([]);
/** æˆå‘˜è¡¨æ ¼å›žæ˜¾æ•°æ®æºï¼šåªåœ¨æ‰“开弹窗时赋值,增删改只通过 emit å•向流到表单值 */
const initialMembers = ref<ResearchApplicationApi.ApplicationMember[]>([]);
const memberTableRef = ref<InstanceType<typeof MemberTable>>();
const getTitle = computed(() => {
  if (formType.value === 'create') {
@@ -67,7 +71,7 @@
    jobTitle: values.jobTitle,
    contactPhone: values.contactPhone,
    contactEmail: values.contactEmail,
    teamMembers: values.teamMembers,
    members: toSubmitMembers(values.members),
    budgetAmount: values.budgetAmount,
    planContent: values.planContent,
    remark: values.remark,
@@ -81,6 +85,12 @@
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    try {
      memberTableRef.value?.validate();
    } catch (error) {
      message.error((error as Error).message || '课题组成员校验失败');
      return;
    }
    modalApi.lock();
@@ -112,6 +122,8 @@
          schema: useFormSchema(formType.value, formApi, guidelines.value),
        });
        formApi.setDisabled(formType.value === 'detail');
        // æˆå‘˜å­è¡¨å›žæ˜¾ï¼šä¿ç•™åŽŸ id ä¾›è¡¨æ ¼è¡Œ key ä½¿ç”¨ï¼Œæäº¤æ—¶å†å‰¥ç¦»
        initialMembers.value = application.members ?? [];
        // å·²æœ‰é™„件转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
        const blobIds = (application.attachmentList ?? []).map((item) => ({
          uid: String(item.id),
@@ -120,13 +132,21 @@
          status: 'done',
          id: item.id,
        }));
        await formApi.setValues({ ...application, blobIds }, false);
        await formApi.setValues(
          {
            ...application,
            members: toSubmitMembers(application.members),
            blobIds,
          },
          false,
        );
      } else {
        guidelines.value = await getPublishedGuidelineList();
        formApi.setState({
          schema: useFormSchema(formType.value, formApi, guidelines.value),
        });
        formApi.setDisabled(false);
        initialMembers.value = [];
        await formApi.resetForm();
      }
    } finally {
@@ -159,6 +179,16 @@
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
    <Form class="mx-3">
      <template #members>
        <MemberTable
          ref="memberTableRef"
          :members="initialMembers"
          :disabled="formType === 'detail'"
          class="w-full"
          @update:members="(members) => formApi.setValues({ members }, false)"
        />
      </template>
    </Form>
  </Modal>
</template>
src/views/research/application/modules/member-statistics-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { TableColumnsType } from 'ant-design-vue';
import type { DefaultOptionType } from 'ant-design-vue/es/select';
import type { ResearchApplicationApi } from '#/api/research/application';
import { computed, ref } from 'vue';
import { downloadFileFromBlobPart } from '@vben/utils';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import {
  Button,
  Input,
  Modal,
  Select,
  Table,
  message,
} from 'ant-design-vue';
import {
  exportMemberStatistics,
  getMemberStatistics,
} from '#/api/research/application';
defineOptions({ name: 'ResearchMemberStatisticsModal' });
const open = ref(false);
const loading = ref(false);
const keyword = ref('');
const status = ref<number | undefined>();
const rows = ref<ResearchApplicationApi.MemberStatistics[]>([]);
const statusOptions = getDictOptions(
  DICT_TYPE.RESEARCH_APPLICATION_STATUS,
  'number',
) as DefaultOptionType[];
const columns: TableColumnsType<ResearchApplicationApi.MemberStatistics> = [
  { title: '成员姓名', dataIndex: 'name', key: 'name', width: 110, fixed: 'left' },
  { title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', width: 130 },
  { title: '所属单位', dataIndex: 'workUnit', key: 'workUnit', width: 180 },
  { title: '职称/职务', dataIndex: 'jobTitle', key: 'jobTitle', width: 120 },
  { title: '成员角色', dataIndex: 'memberRoles', key: 'memberRoles', width: 140 },
  { title: '参与课题数', dataIndex: 'projectCount', key: 'projectCount', width: 110 },
  { title: '参与课题', dataIndex: 'projectTitles', key: 'projectTitles', ellipsis: true },
];
const totalMembers = computed(() => rows.value.length);
const queryParams = computed(() => ({
  keyword: keyword.value.trim() || undefined,
  status: status.value,
}));
async function loadData() {
  loading.value = true;
  try {
    rows.value = (await getMemberStatistics(queryParams.value)) ?? [];
  } finally {
    loading.value = false;
  }
}
function handleOpen() {
  open.value = true;
  keyword.value = '';
  status.value = undefined;
  loadData();
}
async function handleExport() {
  const data = await exportMemberStatistics(queryParams.value);
  downloadFileFromBlobPart({ fileName: '课题成员统计.xls', source: data });
}
/** ç”Ÿæˆå¯ç›´æŽ¥å‘到微信群/企业微信的名单文本 */
function buildPublishText() {
  const lines = ['【课题成员名单】'];
  rows.value.forEach((row, index) => {
    const meta = [row.workUnit, row.jobTitle].filter(Boolean).join('/');
    const metaText = meta ? `(${meta})` : '';
    const roles = row.memberRoles ? `ï¼»${row.memberRoles}ï¼½` : '';
    const projects = row.projectTitles ? `:${row.projectTitles}` : '';
    lines.push(
      `${index + 1}. ${row.name}${metaText}${roles} å‚与课题 ${row.projectCount ?? 0} é¡¹${projects}`,
    );
  });
  return lines.join('\n');
}
async function handleCopy() {
  if (rows.value.length === 0) {
    message.warning('暂无可发布的成员数据');
    return;
  }
  const text = buildPublishText();
  try {
    await navigator.clipboard.writeText(text);
    message.success('成员名单已复制,可直接粘贴到群内发布');
  } catch {
    // éž HTTPS çŽ¯å¢ƒä¸‹ clipboard ä¸å¯ç”¨æ—¶ï¼Œé€€åŒ–为手动选择复制
    Modal.info({
      title: '请手动复制以下名单',
      content: text,
      width: 640,
    });
  }
}
defineExpose({ open: handleOpen });
</script>
<template>
  <Modal
    v-model:open="open"
    title="课题成员统计(按成员维度)"
    width="1080px"
    :footer="null"
  >
    <div class="mb-3 flex flex-wrap items-center gap-2">
      <Input
        v-model:value="keyword"
        placeholder="姓名 / ç”µè¯ / å•位"
        allow-clear
        style="width: 220px"
        @press-enter="loadData"
      />
      <Select
        v-model:value="status"
        :options="statusOptions"
        placeholder="申报状态(全部)"
        allow-clear
        style="width: 180px"
      />
      <Button type="primary" @click="loadData">查询</Button>
      <div class="flex-1" />
      <span class="text-sm text-gray-500">共 {{ totalMembers }} åæˆå‘˜</span>
      <Button @click="handleCopy">复制群发名单</Button>
      <Button @click="handleExport">导出 Excel</Button>
    </div>
    <Table
      :columns="columns"
      :data-source="rows"
      :loading="loading"
      :pagination="{ pageSize: 10, showSizeChanger: true }"
      :scroll="{ x: 1100, y: 420 }"
      bordered
      size="small"
    />
    <div class="mt-2 text-xs text-gray-400">
      ç»Ÿè®¡å£å¾„:同一「姓名 + ç”µè¯ + å•位」视为同一成员;参与课题数按不重复课题计数。
    </div>
  </Modal>
</template>
src/views/research/application/modules/member-table.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,239 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchApplicationApi } from '#/api/research/application';
import { nextTick, watch } from 'vue';
import { Button, Input, InputNumber, Select } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { $t } from '#/locales';
import { MEMBER_ROLE_OPTIONS, toSubmitMembers } from '../data';
const props = withDefaults(
  defineProps<{
    members?: ResearchApplicationApi.ApplicationMember[];
    disabled?: boolean;
  }>(),
  {
    members: () => [],
    disabled: false,
  },
);
const emit = defineEmits<{
  'update:members': [members: ResearchApplicationApi.ApplicationMember[]];
}>();
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { type: 'seq', title: '序号', width: 55, fixed: 'left' },
      {
        field: 'name',
        title: '姓名',
        minWidth: 110,
        slots: { default: 'name' },
      },
      {
        field: 'contactPhone',
        title: '联系电话',
        minWidth: 140,
        slots: { default: 'contactPhone' },
      },
      {
        field: 'workUnit',
        title: '所属单位',
        minWidth: 180,
        slots: { default: 'workUnit' },
      },
      {
        field: 'jobTitle',
        title: '职称/职务',
        minWidth: 120,
        slots: { default: 'jobTitle' },
      },
      {
        field: 'memberRole',
        title: '成员角色',
        minWidth: 130,
        slots: { default: 'memberRole' },
      },
      {
        field: 'sortNo',
        title: '排序',
        width: 90,
        slots: { default: 'sortNo' },
      },
      {
        title: '操作',
        width: 80,
        fixed: 'right',
        slots: { default: 'actions' },
      },
    ],
    data: [],
    minHeight: 200,
    autoResize: true,
    border: true,
    rowConfig: { keyField: 'id', isHover: true },
    pagerConfig: { enabled: false },
    toolbarConfig: { enabled: false },
  } as VxeTableGridOptions<ResearchApplicationApi.ApplicationMember>,
});
/** è¯»å–当前全部成员行(提交形态:不含 id / applicationId) */
function collectMembers(): ResearchApplicationApi.ApplicationMember[] {
  const rows =
    (gridApi.grid.getData() as ResearchApplicationApi.ApplicationMember[]) ??
    [];
  const removed =
    (gridApi.grid.getRemoveRecords() as ResearchApplicationApi.ApplicationMember[]) ??
    [];
  const inserted =
    (gridApi.grid.getInsertRecords() as ResearchApplicationApi.ApplicationMember[]) ??
    [];
  const isRemoved = (row: ResearchApplicationApi.ApplicationMember) =>
    removed.some(
      (item) => item === row || (row.id != null && item.id === row.id),
    );
  return toSubmitMembers([
    ...rows.filter((row) => !isRemoved(row)),
    ...inserted,
  ]);
}
/** åŒæ­¥æˆå‘˜æ•°æ®ç»™çˆ¶ç»„ä»¶ */
function recalc() {
  emit('update:members', collectMembers());
}
/** æ·»åŠ æˆå‘˜è¡Œ */
async function handleAdd() {
  await gridApi.grid.insertAt(
    {
      memberRole: '参与人',
      sortNo: collectMembers().length + 1,
    } as unknown as ResearchApplicationApi.ApplicationMember,
    -1,
  );
  recalc();
}
/** åˆ é™¤æˆå‘˜è¡Œ */
async function handleDelete(row: ResearchApplicationApi.ApplicationMember) {
  await gridApi.grid.remove(row);
  recalc();
}
/** æ ¡éªŒæˆå‘˜ï¼šå§“名必填;填写电话时须为 11 ä½æ‰‹æœºå· */
function validate() {
  const rows = collectMembers();
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i];
    if (!row?.name || String(row.name).trim() === '') {
      throw new Error(`第 ${i + 1} è¡Œæˆå‘˜ï¼šè¯·å¡«å†™å§“名`);
    }
    const phone = row.contactPhone ? String(row.contactPhone).trim() : '';
    if (phone !== '' && !/^1[3-9]\d{9}$/.test(phone)) {
      throw new Error(
        `第 ${i + 1} è¡Œæˆå‘˜ ${row.name}:联系电话格式不正确,请输入 11 ä½æ‰‹æœºå·`,
      );
    }
  }
}
defineExpose({ validate });
/**
 * å›žæ˜¾æˆå‘˜ï¼ˆå•向:父 â†’ è¡¨æ ¼ï¼‰ã€‚
 * watcher å†…禁止回传 emit,避免 prop â†” emit æ­»å¾ªçŽ¯ã€‚
 */
watch(
  () => props.members,
  async (members) => {
    if (!members) {
      return;
    }
    await nextTick();
    await gridApi.grid.loadData([...members]);
  },
  { immediate: true },
);
</script>
<template>
  <Grid class="w-full">
    <template #name="{ row }">
      <Input
        v-model:value="row.name"
        :disabled="disabled"
        placeholder="请输入姓名"
        @change="() => recalc()"
      />
    </template>
    <template #contactPhone="{ row }">
      <Input
        v-model:value="row.contactPhone"
        :disabled="disabled"
        :maxlength="11"
        placeholder="11 ä½æ‰‹æœºå·ï¼ˆé€‰å¡«ï¼‰"
        @change="() => recalc()"
      />
    </template>
    <template #workUnit="{ row }">
      <Input
        v-model:value="row.workUnit"
        :disabled="disabled"
        placeholder="请输入所属单位"
        @change="() => recalc()"
      />
    </template>
    <template #jobTitle="{ row }">
      <Input
        v-model:value="row.jobTitle"
        :disabled="disabled"
        placeholder="请输入职称/职务"
        @change="() => recalc()"
      />
    </template>
    <template #memberRole="{ row }">
      <Select
        v-model:value="row.memberRole"
        :disabled="disabled"
        :options="MEMBER_ROLE_OPTIONS"
        placeholder="请选择角色"
        allow-clear
        @change="() => recalc()"
      />
    </template>
    <template #sortNo="{ row }">
      <InputNumber
        v-model:value="row.sortNo"
        :disabled="disabled"
        :min="1"
        :precision="0"
        class="!w-full"
        @change="() => recalc()"
      />
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.delete'),
            danger: true,
            type: 'link',
            icon: ACTION_ICON.DELETE,
            disabled,
            onClick: handleDelete.bind(null, row),
          },
        ]"
      />
    </template>
  </Grid>
  <div v-if="!disabled" class="-mt-4 flex justify-center">
    <Button type="primary" ghost @click="handleAdd"> æ·»åŠ æˆå‘˜ </Button>
  </div>
</template>
src/views/ums/member/change/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,182 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { UmsMemberProfileApi } from '#/api/ums/member/profile';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  approveProfileChange,
  getProfileChangePage,
} from '#/api/ums/member/profile';
import { $t } from '#/locales';
import RejectModal from './modules/reject-modal.vue';
defineOptions({ name: 'UmsMemberProfileChange' });
/** å®¡æ ¸çŠ¶æ€ */
const ChangeStatus = {
  PENDING: 0,
  APPROVED: 10,
  REJECTED: 20,
} as const;
const statusOptions = [
  { label: '待审核', value: ChangeStatus.PENDING },
  { label: '已通过', value: ChangeStatus.APPROVED },
  { label: '已驳回', value: ChangeStatus.REJECTED },
];
const rejectModalRef = ref<InstanceType<typeof RejectModal>>();
function useSearchSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'memberNo',
      label: '会员编号',
      component: 'Input',
      componentProps: { placeholder: '请输入会员编号', allowClear: true },
    },
    {
      fieldName: 'memberName',
      label: '会员名称',
      component: 'Input',
      componentProps: { placeholder: '请输入会员名称', allowClear: true },
    },
    {
      fieldName: 'status',
      label: '审核状态',
      component: 'Select',
      componentProps: {
        options: statusOptions,
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
  ];
}
/** å®¡æ ¸é€šè¿‡ */
async function handleApprove(row: UmsMemberProfileApi.ChangeRecord) {
  await approveProfileChange(row.id!);
  message.success('审核通过,资料已更新到会员信息');
  gridApi.query();
}
/** é©³å›ž */
function handleReject(row: UmsMemberProfileApi.ChangeRecord) {
  rejectModalRef.value?.show(row.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useSearchSchema(),
  },
  gridOptions: {
    columns: [
      { field: 'memberNo', title: '会员编号', width: 180, fixed: 'left' },
      { field: 'memberName', title: '会员名称', minWidth: 130 },
      {
        field: 'status',
        title: '审核状态',
        width: 100,
        slots: { default: 'status' },
      },
      {
        field: 'changeSummary',
        title: '变更内容',
        minWidth: 360,
        showOverflow: 'tooltip',
      },
      { field: 'contactPhone', title: '联系电话', minWidth: 130 },
      {
        field: 'createTime',
        title: '提交时间',
        width: 165,
        formatter: 'formatDateTime',
      },
      {
        field: 'auditTime',
        title: '审核时间',
        width: 165,
        formatter: 'formatDateTime',
      },
      {
        field: 'auditRemark',
        title: '审核意见',
        minWidth: 180,
        showOverflow: 'tooltip',
      },
      {
        title: '操作',
        width: 150,
        fixed: 'right',
        slots: { default: 'actions' },
      },
    ],
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getProfileChangePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<UmsMemberProfileApi.ChangeRecord>,
});
</script>
<template>
  <Page auto-content-height>
    <RejectModal ref="rejectModalRef" @success="gridApi.query()" />
    <Grid table-title="会员资料变更审核">
      <template #status="{ row }">
        <Tag v-if="row.status === ChangeStatus.PENDING" color="processing">
          å¾…审核
        </Tag>
        <Tag v-else-if="row.status === ChangeStatus.APPROVED" color="success">
          å·²é€šè¿‡
        </Tag>
        <Tag v-else color="error">已驳回</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '通过',
              type: 'link',
              auth: ['ums:member:change:audit'],
              ifShow: () => row.status === ChangeStatus.PENDING,
              popConfirm: {
                title: `确认通过会员 ${row.memberName} çš„资料变更吗?通过后将直接更新会员资料`,
                confirm: handleApprove.bind(null, row),
              },
            },
            {
              label: '驳回',
              type: 'link',
              danger: true,
              auth: ['ums:member:change:audit'],
              ifShow: () => row.status === ChangeStatus.PENDING,
              onClick: handleReject.bind(null, row),
            },
          ]"
        />
      </template>
      <template #empty>暂无资料变更申请</template>
    </Grid>
  </Page>
</template>
src/views/ums/member/change/modules/reject-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { message, Modal, Textarea } from 'ant-design-vue';
import { rejectProfileChange } from '#/api/ums/member/profile';
const emit = defineEmits(['success']);
const open = ref(false);
const submitting = ref(false);
const changeId = ref<number>();
const remark = ref('');
function show(id: number) {
  changeId.value = id;
  remark.value = '';
  open.value = true;
}
async function handleOk() {
  if (!remark.value.trim()) {
    message.warning('请填写驳回原因');
    return;
  }
  if (!changeId.value) {
    return;
  }
  submitting.value = true;
  try {
    await rejectProfileChange(changeId.value, remark.value.trim());
    message.success('已驳回该资料变更申请');
    open.value = false;
    emit('success');
  } finally {
    submitting.value = false;
  }
}
defineExpose({ show });
</script>
<template>
  <Modal
    v-model:open="open"
    title="驳回资料变更申请"
    :confirm-loading="submitting"
    ok-text="确认驳回"
    cancel-text="取消"
    @ok="handleOk"
  >
    <div class="mb-2 text-sm text-gray-500">
      é©³å›žåŽŸå› ä¼šå±•ç¤ºç»™ä¼šå‘˜ï¼Œè¯·è¯´æ˜Žå…·ä½“éœ€è¦ä¿®æ”¹çš„å†…å®¹ã€‚
    </div>
    <Textarea
      v-model:value="remark"
      :rows="4"
      :maxlength="200"
      show-count
      placeholder="请输入驳回原因,例如:联系电话格式不正确,请填写 11 ä½æ‰‹æœºå·"
    />
  </Modal>
</template>
src/views/ums/member/data.ts
@@ -252,6 +252,40 @@
      },
    },
    {
      fieldName: 'feePaid',
      label: '是否缴纳会费',
      component: 'Switch',
      componentProps: (_values, form) => ({
        checkedChildren: '已缴',
        unCheckedChildren: '未缴',
        disabled: formType === 'detail',
        onChange: (checked: boolean) => {
          // å–消缴纳时清空年度;勾选时默认当前年度,仍可修改
          form.setFieldValue(
            'feeYear',
            checked ? new Date().getFullYear() : undefined,
          );
        },
      }),
    },
    {
      fieldName: 'feeYear',
      label: '缴费年度',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入缴费年度,如 2026',
        min: 2000,
        max: 2100,
        precision: 0,
        class: '!w-full',
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['feePaid'],
        show: (values) => !!values.feePaid,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
@@ -408,6 +442,12 @@
      formatter: 'formatDate',
    },
    {
      field: 'feePaid',
      title: '会费缴纳',
      minWidth: 120,
      slots: { default: 'feePaid' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 85,
src/views/ums/member/index.vue
@@ -163,6 +163,12 @@
      <template #memberGrade="{ row }">
        <span>{{ getGradeLabel(row) }}</span>
      </template>
      <template #feePaid="{ row }">
        <Tag v-if="row.feePaid" color="success">
          å·²ç¼´{{ row.feeYear ? `(${row.feeYear} å¹´åº¦ï¼‰` : '' }}
        </Tag>
        <Tag v-else color="default">未缴</Tag>
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
src/views/ums/member/profile/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
import type { VbenFormSchema } from '#/adapter/form';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { markRaw } from 'vue';
import { AreaCascader } from '#/components/area';
/** ä¼šå‘˜ç±»åž‹ */
const MEMBER_TYPE_UNIT = 2;
/** æˆ‘的资料表单(仅允许维护联系方式等资料字段) */
export function useProfileSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '会员编号',
      component: 'Input',
      componentProps: {
        disabled: true,
        placeholder: '系统自动生成',
      },
    },
    {
      fieldName: 'name',
      label: '姓名/单位名称',
      component: 'Input',
      componentProps: {
        disabled: true,
      },
    },
    {
      // ä»…用于控制单位/个人字段显示,不参与提交
      fieldName: 'memberType',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'contactUser',
      label: '联系人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系人(单位会员填写)',
        maxlength: 64,
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MEMBER_TYPE_UNIT,
      },
    },
    {
      fieldName: 'contactPhone',
      label: '联系电话',
      component: 'Input',
      componentProps: {
        placeholder: '请输入 11 ä½æ‰‹æœºå·',
        maxlength: 20,
      },
    },
    {
      fieldName: 'contactEmail',
      label: '邮箱',
      component: 'Input',
      componentProps: {
        placeholder: '请输入邮箱',
        maxlength: 128,
      },
    },
    {
      fieldName: 'workUnit',
      label: '工作单位',
      component: 'Input',
      componentProps: {
        placeholder: '请输入工作单位',
        maxlength: 128,
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType !== MEMBER_TYPE_UNIT,
      },
    },
    {
      fieldName: 'title',
      label: '职称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入职称',
        maxlength: 64,
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType !== MEMBER_TYPE_UNIT,
      },
    },
    {
      fieldName: 'legalPerson',
      label: '法定代表人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入法定代表人',
        maxlength: 64,
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MEMBER_TYPE_UNIT,
      },
    },
    {
      fieldName: 'organizationCode',
      label: '统一社会信用代码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入统一社会信用代码',
        maxlength: 64,
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MEMBER_TYPE_UNIT,
      },
    },
    {
      fieldName: 'researchDirection',
      label: '研究方向',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入研究方向',
        autoSize: { minRows: 2, maxRows: 3 },
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'unitIntro',
      label: '单位简介',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入单位简介',
        autoSize: { minRows: 2, maxRows: 3 },
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MEMBER_TYPE_UNIT,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'regionCode',
      label: '地区',
      component: markRaw(AreaCascader),
      componentProps: {
        placeholder: '请选择地区',
        allowClear: true,
        class: '!w-full',
      },
    },
    {
      fieldName: 'industry',
      label: '行业',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_INDUSTRY, 'string'),
        placeholder: '请选择行业',
        allowClear: true,
      },
    },
  ];
}
src/views/ums/member/profile/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
<script lang="ts" setup>
import type { UmsMemberProfileApi } from '#/api/ums/member/profile';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  applyProfileChange,
  getMyLatestChange,
  getMyProfile,
} from '#/api/ums/member/profile';
import { useProfileSchema } from './data';
defineOptions({ name: 'UmsMemberProfile' });
const loading = ref(false);
const submitting = ref(false);
const latestChange = ref<UmsMemberProfileApi.ChangeRecord>();
/** æœ€è¿‘一次申请待审核时禁止重复提交 */
const pending = computed(() => latestChange.value?.status === 0);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 130,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useProfileSchema(),
  showDefaultActions: false,
});
/** åªæäº¤å…è®¸ä¼šå‘˜ç»´æŠ¤çš„字段 */
async function buildSubmitData(): Promise<UmsMemberProfileApi.ApplyReq> {
  const values = (await formApi.getValues()) as UmsMemberProfileApi.ApplyReq;
  return {
    contactUser: values.contactUser,
    contactPhone: values.contactPhone,
    contactEmail: values.contactEmail,
    workUnit: values.workUnit,
    title: values.title,
    researchDirection: values.researchDirection,
    unitIntro: values.unitIntro,
    legalPerson: values.legalPerson,
    organizationCode: values.organizationCode,
    regionCode: values.regionCode,
    industry: values.industry,
  };
}
async function loadData() {
  loading.value = true;
  try {
    const [profile, latest] = await Promise.all([
      getMyProfile(),
      getMyLatestChange(),
    ]);
    latestChange.value = latest ?? undefined;
    await formApi.setValues(
      {
        no: profile.no,
        name: profile.name,
        memberType: profile.memberType,
        contactUser: profile.contactUser,
        contactPhone: profile.contactPhone,
        contactEmail: profile.contactEmail,
        workUnit: profile.workUnit,
        title: profile.title,
        researchDirection: profile.researchDirection,
        unitIntro: profile.unitIntro,
        legalPerson: profile.legalPerson,
        organizationCode: profile.organizationCode,
        regionCode: profile.regionCode,
        industry: profile.industry,
      },
      false,
    );
  } finally {
    loading.value = false;
  }
}
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid) {
    return;
  }
  submitting.value = true;
  try {
    await applyProfileChange(await buildSubmitData());
    message.success('资料变更申请已提交,等待管理员审核');
    await loadData();
  } finally {
    submitting.value = false;
  }
}
const latestAlert = computed(() => {
  const latest = latestChange.value;
  if (!latest) {
    return null;
  }
  if (latest.status === 0) {
    return {
      type: 'info' as const,
      message: '资料变更申请审核中',
      description: `提交时间:${latest.createTime || '-'},请等待管理员审核。`,
    };
  }
  if (latest.status === 10) {
    return {
      type: 'success' as const,
      message: '最近一次资料变更已通过',
      description: `审核时间:${latest.auditTime || '-'}`,
    };
  }
  return {
    type: 'error' as const,
    message: '最近一次资料变更被驳回',
    description: `驳回原因:${latest.auditRemark || '-'};审核时间:${latest.auditTime || '-'}`,
  };
});
onMounted(loadData);
</script>
<template>
  <Page auto-content-height>
    <div class="mx-auto max-w-5xl p-4">
      <div class="mb-4 rounded-lg border border-border bg-background p-4">
        <div class="mb-1 text-base font-semibold">我的资料</div>
        <div class="text-sm text-gray-500">
          ä¿®æ”¹è”系方式等资料后提交,管理员审核通过后生效;审核期间不可重复提交。
        </div>
      </div>
      <Alert
        v-if="latestAlert"
        class="mb-4"
        :type="latestAlert.type"
        show-icon
        :message="latestAlert.message"
        :description="latestAlert.description"
      />
      <div class="rounded-lg border border-border bg-background p-4">
        <Form class="mx-3" />
        <div class="mt-4 flex justify-end">
          <Button
            type="primary"
            :loading="submitting || loading"
            :disabled="pending"
            @click="handleSubmit"
          >
            {{ pending ? '审核中,暂不可提交' : '提交审核' }}
          </Button>
        </div>
      </div>
    </div>
  </Page>
</template>
src/views/vehicle/application/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,192 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { VehicleApplicationApi } from '#/api/vehicle/application';
/** ç”¨è½¦ç”³è¯·çŠ¶æ€é€‰é¡¹ */
export const StatusOptions = [
  { label: '草稿', value: VehicleApplicationApi.Status.DRAFT },
  { label: '审批中', value: VehicleApplicationApi.Status.APPROVING },
  { label: '已通过', value: VehicleApplicationApi.Status.APPROVED },
  { label: '已驳回', value: VehicleApplicationApi.Status.REJECTED },
];
export function getStatusName(status?: number) {
  return StatusOptions.find((item) => item.value === status)?.label ?? '-';
}
/** æ–°å¢ž/修改表单 */
export function useFormSchema(formType: 'create' | 'edit' | 'detail') {
  const disabled = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'useStartTime',
      label: '用车开始时间',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择用车开始时间',
        style: { width: '100%' },
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'useEndTime',
      label: '用车结束时间',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择用车结束时间',
        style: { width: '100%' },
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'departure',
      label: '出发地',
      component: 'Input',
      componentProps: {
        placeholder: '请输入出发地',
        maxlength: 128,
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'destination',
      label: '目的地',
      component: 'Input',
      componentProps: {
        placeholder: '请输入目的地',
        maxlength: 128,
        disabled,
      },
      rules: 'required',
    },
    {
      fieldName: 'passengers',
      label: '随行人数',
      component: 'InputNumber',
      componentProps: {
        min: 1,
        precision: 0,
        placeholder: '请输入随行人数',
        class: '!w-full',
        disabled,
      },
    },
    {
      fieldName: 'vehicleNo',
      label: '车牌号',
      component: 'Input',
      componentProps: {
        placeholder: '选填,如 è‹F12345',
        maxlength: 32,
        disabled,
      },
    },
    {
      fieldName: 'reason',
      label: '用车事由',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入用车事由',
        autoSize: { minRows: 2, maxRows: 4 },
        disabled,
      },
      formItemClass: 'col-span-2',
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled,
      },
      formItemClass: 'col-span-2',
    },
  ] as VbenFormSchema[];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '申请单号',
      component: 'Input',
      componentProps: { placeholder: '请输入申请单号', allowClear: true },
    },
    {
      fieldName: 'applicantName',
      label: '申请人',
      component: 'Input',
      componentProps: { placeholder: '请输入申请人', allowClear: true },
    },
    {
      fieldName: 'destination',
      label: '目的地',
      component: 'Input',
      componentProps: { placeholder: '请输入目的地', allowClear: true },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: StatusOptions,
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表列 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    { field: 'no', title: '申请单号', width: 175, fixed: 'left' },
    { field: 'applicantName', title: '申请人', width: 110 },
    {
      field: 'useStartTime',
      title: '用车开始',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      field: 'useEndTime',
      title: '用车结束',
      width: 165,
      formatter: 'formatDateTime',
    },
    { field: 'departure', title: '出发地', minWidth: 140 },
    { field: 'destination', title: '目的地', minWidth: 140 },
    { field: 'passengers', title: '随行人数', width: 95 },
    { field: 'reason', title: '用车事由', minWidth: 180, showOverflow: 'tooltip' },
    { field: 'vehicleNo', title: '车牌号', width: 110 },
    {
      field: 'status',
      title: '状态',
      width: 100,
      slots: { default: 'status' },
    },
    {
      title: '操作',
      width: 220,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/vehicle/application/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,225 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { VehicleApplicationApi } from '#/api/vehicle/application';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteVehicleApplication,
  getVehicleApplicationPage,
  getVehicleApproveProcessList,
} from '#/api/vehicle/application';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
defineOptions({ name: 'VehicleApplication' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: VehicleApplicationApi.Application) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
function handleDetail(row: VehicleApplicationApi.Application) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deleteVehicleApplication(id)));
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
async function handleSubmit(row: VehicleApplicationApi.Application) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getVehicleApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning(
        '暂无可用用车审批流程,请先到 BPM æµç¨‹è®¾è®¡å™¨å‘布「用车审批」流程',
      );
      return;
    }
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
  }
}
function handleProcessDetail(row: VehicleApplicationApi.Application) {
  if (!row.processInstanceId) {
    message.warning('该用车申请尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: VehicleApplicationApi.Application[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getVehicleApplicationPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<VehicleApplicationApi.Application>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="用车申请">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增用车申请',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['vehicle:application:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['vehicle:application:delete'],
              popConfirm: {
                title: '是否删除所选用车申请?(仅草稿状态可删除)',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #status="{ row }">
        <Tag v-if="row.status === 0" color="default">草稿</Tag>
        <Tag v-else-if="row.status === 15" color="processing">审批中</Tag>
        <Tag v-else-if="row.status === 20" color="success">已通过</Tag>
        <Tag v-else color="error">已驳回</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['vehicle:application:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['vehicle:application:update'],
              ifShow: () => row.status === 0 || row.status === 35,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['vehicle:application:submit'],
              ifShow: () => row.status === 0 || row.status === 35,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['vehicle:application:query'],
              ifShow: () => !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['vehicle:application:delete'],
              ifShow: () => row.status === 0,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/vehicle/application/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { VehicleApplicationApi } from '#/api/vehicle/application';
import { 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 {
  createVehicleApplication,
  getVehicleApplication,
  updateVehicleApplication,
} from '#/api/vehicle/application';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
type FormType = 'create' | 'detail' | 'edit';
const formType = ref<FormType>('create');
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    labelWidth: 130,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema('create'),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data =
      (await formApi.getValues()) as VehicleApplicationApi.Application;
    try {
      await (formType.value === 'create'
        ? createVehicleApplication(data)
        : updateVehicleApplication(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.setDisabled(data.formType === 'detail');
    formApi.updateSchema(useFormSchema(data.formType));
    if (!data.id) {
      return;
    }
    modalApi.lock();
    try {
      const application = await getVehicleApplication(data.id);
      await formApi.setValues(application, false);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="
      formType === 'create'
        ? $t('ui.actionTitle.create', ['用车申请'])
        : formType === 'edit'
          ? $t('ui.actionTitle.edit', ['用车申请'])
          : '用车申请详情'
    "
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/vehicle/application/modules/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
<script lang="ts" setup>
import type { VehicleApplicationApi } from '#/api/vehicle/application';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { submitVehicleApplication } from '#/api/vehicle/application';
defineOptions({ name: 'VehicleApplicationProcessSelect' });
const emit = defineEmits(['success']);
const applicationId = ref<number>();
const processList = ref<VehicleApplicationApi.ProcessDefinition[]>([]);
const selectedProcessKey = ref('');
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 submitVehicleApplication(
        applicationId.value!,
        selectedProcessKey.value,
      );
      await modalApi.close();
      emit('success');
      message.success('提交成功,等待逐级审批');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      applicationId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data = modalApi.getData<{
      id: number;
      processList: VehicleApplicationApi.ProcessDefinition[];
    }>();
    applicationId.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">
      æäº¤åŽæŒ‰ BPM ä¸­é…ç½®çš„用车审核/逐级审批节点流转,审批结果会回写到本申请并反馈给申请人。
    </p>
  </Modal>
</template>
src/views/wxcp/activity/data.ts
@@ -2,6 +2,8 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { ACTIVITY_TYPE } from '#/api/wxcp/activity';
import {
  ACTIVITY_STATUS_OPTIONS,
  PUSH_SCOPE_OPTIONS,
@@ -12,11 +14,52 @@
/** è¡¨å•类型 */
export type ActivityFormType = 'create' | 'update' | 'detail';
/** æ´»åŠ¨æ¨¡æ¿ç±»åž‹é€‰é¡¹ */
export const ACTIVITY_TYPE_OPTIONS = [
  {
    label: '纯通知(只推一条文本)',
    value: ACTIVITY_TYPE.TEXT,
  },
  {
    label: '单条消息(可点击卡片)',
    value: ACTIVITY_TYPE.MESSAGE,
  },
  {
    label: '图片通知(上传图片推送)',
    value: ACTIVITY_TYPE.IMAGE,
  },
  {
    label: '附件通知(推送文件供下载)',
    value: ACTIVITY_TYPE.FILE,
  },
  {
    label: '数据采集/评价(填写表单)',
    value: ACTIVITY_TYPE.FORM,
  },
];
/** é‡‡é›†å­—段类型选项 */
export const FIELD_TYPE_OPTIONS = [
  { label: '单行文本', value: 'text' },
  { label: '多行文本', value: 'textarea' },
  { label: '数字', value: 'number' },
  { label: '日期', value: 'date' },
  { label: '下拉选择', value: 'select' },
  { label: '单选', value: 'radio' },
  { label: '多选', value: 'checkbox' },
  { label: '评分', value: 'rating' },
];
const DATETIME_PROPS = {
  showTime: true,
  valueFormat: 'YYYY-MM-DD HH:mm:ss',
  format: 'YYYY-MM-DD HH:mm:ss',
} as const;
/** ä»…数据采集模板需要报名时间与名额 */
function isFormType(values: Record<string, unknown>) {
  return values.activityType === ACTIVITY_TYPE.FORM;
}
/** æ´»åŠ¨è¡¨å• */
export function useActivityFormSchema(
@@ -30,6 +73,19 @@
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'activityType',
      label: '活动模板',
      component: 'Select',
      componentProps: {
        options: ACTIVITY_TYPE_OPTIONS,
        placeholder: '请选择活动模板',
        disabled: readonly,
      },
      rules: 'required',
      help: '不同模板决定推送形态:纯通知/单条消息/图片/附件,或带表单的数据采集',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'title',
      label: '活动标题',
      component: 'Input',
@@ -38,6 +94,22 @@
        disabled: readonly,
      },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'buttonText',
      label: '按钮文案',
      component: 'Input',
      componentProps: {
        placeholder: '如:查看详情 / ç«‹å³æŠ¥å',
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['activityType'],
        show: (values) =>
          values.activityType === ACTIVITY_TYPE.MESSAGE ||
          values.activityType === ACTIVITY_TYPE.FORM,
      },
      formItemClass: 'col-span-2',
    },
    {
@@ -64,12 +136,20 @@
      label: '报名开始',
      component: 'DatePicker',
      componentProps: { ...DATETIME_PROPS, disabled: readonly },
      dependencies: {
        triggerFields: ['activityType'],
        show: isFormType,
      },
    },
    {
      fieldName: 'signupEndTime',
      label: '报名截止',
      component: 'DatePicker',
      componentProps: { ...DATETIME_PROPS, disabled: readonly },
      dependencies: {
        triggerFields: ['activityType'],
        show: isFormType,
      },
    },
    {
      fieldName: 'signupLimit',
@@ -81,8 +161,13 @@
        class: 'w-full',
        disabled: readonly,
      },
      defaultValue: 0,
      rules: 'required',
      help: 'å¡« 0 è¡¨ç¤ºä¸é™åé¢',
      dependencies: {
        triggerFields: ['activityType'],
        show: isFormType,
      },
    },
    {
      // å°é¢èµ°é™„件中间表:这里提交 blobIds,回显读 attachmentList(见 form.vue çš„æ˜ å°„函数)
@@ -114,6 +199,100 @@
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      // è¯¦æƒ…图片走另一套附件关系(recordType = wxcp_activity_detail),与封面互不影响。
      // æŠ¥åé¡µæŠŠå®ƒæŽ’在「活动详情」文字下方、按原图比例完整展示(不裁剪),
      // æ‰€ä»¥äºŒç»´ç /海报这类被裁就失效的图必须放这里,别放封面。
      fieldName: 'detailBlobIds',
      label: '详情图片',
      component: 'ImageUpload',
      componentProps: {
        valueKey: 'id',
        resultField: 'id',
        maxNumber: 5,
        multiple: true,
        disabled: readonly,
      },
      help: '选填,最多 5 å¼ ï¼›æ´»åŠ¨é¡µæŒ‰åŽŸå›¾æ¯”ä¾‹å®Œæ•´æ˜¾ç¤ºä¸è£å‰ªï¼ŒäºŒç»´ç å»ºè®®æ”¾è¿™é‡Œ',
      formItemClass: 'col-span-2',
    },
    {
      // å›¾ç‰‡é€šçŸ¥æ¨¡æ¿ï¼šæŽ¨é€æ—¶é€å¼ å‘图片消息(企业微信图片消息)
      fieldName: 'imageBlobIds',
      label: '推送图片',
      component: 'ImageUpload',
      componentProps: {
        valueKey: 'id',
        resultField: 'id',
        maxNumber: 9,
        multiple: true,
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['activityType'],
        show: (values) => values.activityType === ACTIVITY_TYPE.IMAGE,
      },
      help: '推送时逐张发送图片消息,最多 9 å¼ ',
      formItemClass: 'col-span-2',
    },
    {
      // é™„件通知模板:推送时逐个发文件消息,接收人可直接下载
      fieldName: 'fileBlobIds',
      label: '推送附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 5,
        multiple: true,
        showDescription: !readonly,
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['activityType'],
        show: (values) => values.activityType === ACTIVITY_TYPE.FILE,
      },
      help: '推送时逐个发送文件消息,单个不超过 20MB,最多 5 ä¸ª',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'ratingEnabled',
      label: '开启评价',
      component: 'Switch',
      componentProps: { disabled: readonly },
      dependencies: {
        triggerFields: ['activityType'],
        show: isFormType,
      },
    },
    {
      fieldName: 'ratingTitle',
      label: '评价标题',
      component: 'Input',
      componentProps: {
        placeholder: '如:活动满意度评价',
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['activityType', 'ratingEnabled'],
        show: (values) => isFormType(values) && values.ratingEnabled === true,
      },
    },
    {
      fieldName: 'ratingMax',
      label: '最高星级',
      component: 'InputNumber',
      componentProps: {
        min: 1,
        max: 10,
        class: 'w-full',
        disabled: readonly,
      },
      defaultValue: 5,
      dependencies: {
        triggerFields: ['activityType', 'ratingEnabled'],
        show: (values) => isFormType(values) && values.ratingEnabled === true,
      },
    },
    {
      fieldName: 'remark',
@@ -160,6 +339,11 @@
/** æ´»åŠ¨åˆ—è¡¨å­—æ®µ */
export function useActivityGridColumns(): VxeTableGridOptions<WxCpActivityApi.Activity>['columns'] {
  return [
    {
      field: 'activityTypeName',
      title: '模板',
      width: 150,
    },
    {
      field: 'title',
      title: '活动标题',
@@ -234,6 +418,18 @@
      slots: { default: 'content' },
    },
    {
      field: 'fieldValues',
      title: '采集内容',
      minWidth: 200,
      slots: { default: 'fieldValues' },
    },
    {
      field: 'ratingScore',
      title: '评价',
      width: 80,
      slots: { default: 'ratingScore' },
    },
    {
      field: 'attachment',
      title: '附件',
      width: 80,
src/views/wxcp/activity/index.vue
@@ -8,6 +8,7 @@
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  ACTIVITY_TYPE,
  cancelActivity,
  deleteActivity,
  finishActivity,
@@ -87,7 +88,7 @@
async function handlePublish(row: WxCpActivityApi.Activity) {
  await publishActivity(row.id!);
  message.success('发布成功,现在可以推送和生成二维码了');
  message.success('发布成功,现在可以推送了');
  handleRefresh();
}
@@ -212,7 +213,7 @@
              auth: ['wxcp:activity:update'],
              ifShow: row.status === ACTIVITY_STATUS.DRAFT,
              popConfirm: {
                title: '发布后成员即可通过二维码报名,确认发布?',
                title: '发布后即可推送,确认发布?',
                confirm: handlePublish.bind(null, row),
              },
            },
@@ -226,19 +227,24 @@
            {
              label: '二维码',
              type: 'link',
              ifShow: row.status === ACTIVITY_STATUS.PUBLISHED,
              // åªæœ‰æ•°æ®é‡‡é›†æ¨¡æ¿æ‰æœ‰æŠ¥å/填写页,其它模板推送即完成
              ifShow:
                row.status === ACTIVITY_STATUS.PUBLISHED &&
                row.activityType === ACTIVITY_TYPE.FORM,
              onClick: () => qrCodeModalApi.setData({ activity: row }).open(),
            },
            {
              label: '统计',
              type: 'link',
              auth: ['wxcp:activity:query'],
              ifShow: row.activityType === ACTIVITY_TYPE.FORM,
              onClick: () => statsModalApi.setData({ activity: row }).open(),
            },
            {
              label: '报名记录',
              type: 'link',
              auth: ['wxcp:signup:query'],
              ifShow: row.activityType === ACTIVITY_TYPE.FORM,
              onClick: () => signupModalApi.setData({ activity: row }).open(),
            },
          ]"
src/views/wxcp/activity/modules/field-editor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
<script lang="ts" setup>
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { Button, Input, Select, Switch } from 'ant-design-vue';
import { FIELD_TYPE_OPTIONS } from '../data';
defineOptions({ name: 'WxCpActivityFieldEditor' });
interface Props {
  /** é‡‡é›†å­—段列表 */
  modelValue?: WxCpActivityApi.Field[];
  disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  modelValue: () => [],
  disabled: false,
});
const emit = defineEmits<{
  'update:modelValue': [value: WxCpActivityApi.Field[]];
}>();
/** éœ€è¦å¡«å†™é€‰é¡¹çš„字段类型 */
const OPTION_TYPES = new Set(['checkbox', 'radio', 'select']);
function update(index: number, patch: Partial<WxCpActivityApi.Field>) {
  const list = [...props.modelValue];
  list[index] = { ...list[index], ...patch };
  emit('update:modelValue', list);
}
function handleAdd() {
  emit('update:modelValue', [
    ...props.modelValue,
    {
      fieldLabel: '',
      fieldType: 'text',
      required: false,
      sortNo: props.modelValue.length + 1,
    },
  ]);
}
function handleRemove(index: number) {
  const list = props.modelValue.filter((_, i) => i !== index);
  emit('update:modelValue', list);
}
function needOptions(type?: string) {
  return OPTION_TYPES.has(type ?? '');
}
</script>
<template>
  <div class="mb-4">
    <div class="mb-2 flex items-center justify-between">
      <span class="text-sm font-medium">采集字段</span>
      <Button size="small" :disabled="disabled" @click="handleAdd">
        æ·»åŠ å­—æ®µ
      </Button>
    </div>
    <div v-if="!modelValue.length" class="text-xs text-gray-400">
      æœªæ·»åŠ å­—æ®µæ—¶ï¼Œæˆå‘˜åªéœ€å¡«å†™å§“åã€æ‰‹æœºå·ã€å‚ä¸Žè¯´æ˜Žå¹¶ä¸Šä¼ é™„ä»¶ã€‚
    </div>
    <div
      v-for="(item, index) in modelValue"
      :key="index"
      class="mb-2 rounded border border-gray-200 p-2 dark:border-gray-700"
    >
      <div class="flex flex-wrap items-center gap-2">
        <Input
          :value="item.fieldLabel"
          class="w-44"
          placeholder="字段名称"
          :disabled="disabled"
          @update:value="(value: string) => update(index, { fieldLabel: value })"
        />
        <Select
          :value="item.fieldType"
          class="w-32"
          :options="FIELD_TYPE_OPTIONS"
          :disabled="disabled"
          @update:value="(value: unknown) => update(index, { fieldType: String(value) })"
        />
        <Input
          v-if="needOptions(item.fieldType)"
          :value="item.fieldOptions"
          class="w-64"
          placeholder="选项,用英文逗号分隔"
          :disabled="disabled"
          @update:value="(value: string) => update(index, { fieldOptions: value })"
        />
        <Input
          v-else
          :value="item.placeholder"
          class="w-64"
          placeholder="输入提示(选填)"
          :disabled="disabled"
          @update:value="(value: string) => update(index, { placeholder: value })"
        />
        <span class="text-xs text-gray-500">必填</span>
        <Switch
          :checked="item.required"
          size="small"
          :disabled="disabled"
          @change="(checked: unknown) => update(index, { required: checked === true })"
        />
        <Button
          danger
          type="link"
          size="small"
          :disabled="disabled"
          @click="handleRemove(index)"
        >
          åˆ é™¤
        </Button>
      </div>
    </div>
  </div>
</template>
src/views/wxcp/activity/modules/form.vue
@@ -11,12 +11,14 @@
import { useVbenForm } from '#/adapter/form';
import { toAccessibleFileUrl } from '#/utils';
import {
  ACTIVITY_TYPE,
  createActivity,
  getActivity,
  updateActivity,
} from '#/api/wxcp/activity';
import { useActivityFormSchema } from '../data';
import { FIELD_TYPE_OPTIONS, useActivityFormSchema } from '../data';
import FieldEditor from './field-editor.vue';
defineOptions({ name: 'WxCpActivityForm' });
@@ -25,6 +27,23 @@
const formType = ref<ActivityFormType>('create');
const id = ref<number>();
const submitting = ref(false);
/** å½“前活动模板类型,用于控制采集字段编辑器的显隐 */
const activityType = ref<number>(ACTIVITY_TYPE.FORM);
/** æ•°æ®é‡‡é›†æ¨¡æ¿çš„自定义字段(表单项之外单独维护) */
const fields = ref<WxCpActivityApi.Field[]>([]);
const showFieldEditor = computed(
  () => activityType.value === ACTIVITY_TYPE.FORM && formType.value !== 'detail',
);
function fieldTypeLabel(type?: string) {
  return (
    FIELD_TYPE_OPTIONS.find((item) => item.value === type)?.label ??
    type ??
    '-'
  );
}
const isEdit = computed(() => formType.value !== 'detail');
const title = computed(() => {
@@ -43,19 +62,22 @@
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
  handleValuesChange: (values) => {
    activityType.value =
      (values.activityType as number) ?? ACTIVITY_TYPE.FORM;
  },
});
/**
 * æŠŠæ´»åŠ¨è¯¦æƒ…æ˜ å°„æˆè¡¨å•å€¼
 * æŠŠé™„件列表转成上传组件能识别的文件对象
 *
 * å°é¢åœ¨åŽç«¯ä¸è½ä¸šåŠ¡è¡¨å­—æ®µï¼Œè€Œæ˜¯å­˜åœ¨é™„ä»¶ä¸­é—´è¡¨é‡Œï¼Œ
 * å›žæ˜¾æ—¶è¦æŠŠ attachmentList è½¬æˆä¸Šä¼ ç»„件能识别的文件对象。
 * å›žæ˜¾å¯¹è±¡å¿…须带 response:组件设了 resultField åŽåªè®¤ item.response,
 * ä¸å¸¦çš„话「进编辑页不动图直接保存」会走 URL åˆ†æ”¯ â†’ NaN â†’ [] â†’ å›¾è¢«æ¸…空
 */
function activityToForm(
  activity: WxCpActivityApi.Activity,
): Record<string, unknown> {
  const form: Record<string, unknown> = { ...activity };
  form.blobIds = (activity.attachmentList ?? []).map((item) => {
function attachmentsToUploadFiles(
  list: WxCpActivityApi.SignupAttachment[] | undefined,
) {
  return (list ?? []).map((item) => {
    const name = item.name || item.originalFilename || '';
    const url = toAccessibleFileUrl(item.url || item.previewURL);
    return {
@@ -64,11 +86,26 @@
      url,
      status: 'done',
      id: item.id,
      // å›žæ˜¾å¯¹è±¡ä¹Ÿå¿…须带 response:组件设了 resultField åŽåªè®¤ item.response,
      // ä¸å¸¦çš„话「进编辑页不动图直接保存」会走 URL åˆ†æ”¯ â†’ NaN â†’ [] â†’ å°é¢è¢«æ¸…空
      response: { id: item.id, name, url },
    };
  });
}
/**
 * æŠŠæ´»åŠ¨è¯¦æƒ…æ˜ å°„æˆè¡¨å•å€¼
 *
 * å°é¢ä¸Žè¯¦æƒ…图片在后端都不落业务表字段,而是存在附件中间表里(两套 recordType),
 * å›žæ˜¾æ—¶åˆ†åˆ«æŠŠ attachmentList / detailAttachmentList è½¬æˆä¸Šä¼ ç»„件的文件对象。
 */
function activityToForm(
  activity: WxCpActivityApi.Activity,
): Record<string, unknown> {
  const form: Record<string, unknown> = { ...activity };
  form.blobIds = attachmentsToUploadFiles(activity.attachmentList);
  form.detailBlobIds = attachmentsToUploadFiles(activity.detailAttachmentList);
  // å›¾ç‰‡é€šçŸ¥ / é™„件通知的素材走各自的附件关系,回显时同样转成上传组件对象
  form.imageBlobIds = attachmentsToUploadFiles(activity.imageAttachmentList);
  form.fileBlobIds = attachmentsToUploadFiles(activity.fileAttachmentList);
  return form;
}
@@ -108,12 +145,26 @@
      modalApi.lock();
      try {
        const detail = await getActivity(data.id);
        activityType.value = detail.activityType ?? ACTIVITY_TYPE.FORM;
        fields.value = (detail.fieldList ?? []).map((item) => ({ ...item }));
        await formApi.setValues(activityToForm(detail));
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
      activityType.value = ACTIVITY_TYPE.FORM;
      fields.value = [];
      // é»˜è®¤ç»™ã€Œæ•°æ®é‡‡é›†/评价」,与改造前的报名活动一致
      await formApi.setValues(
        {
          activityType: ACTIVITY_TYPE.FORM,
          ratingEnabled: false,
          ratingMax: 5,
          signupLimit: 0,
        },
        false,
      );
    }
  },
});
@@ -126,9 +177,15 @@
  submitting.value = true;
  try {
    const data = (await formApi.getValues()) as Record<string, unknown>;
    const type = data.activityType ?? ACTIVITY_TYPE.FORM;
    const payload = {
      ...data,
      blobIds: normalizeBlobIds(data.blobIds),
      detailBlobIds: normalizeBlobIds(data.detailBlobIds),
      imageBlobIds: normalizeBlobIds(data.imageBlobIds),
      fileBlobIds: normalizeBlobIds(data.fileBlobIds),
      // åªæœ‰æ•°æ®é‡‡é›†æ¨¡æ¿æäº¤å­—段定义;其它模板不传,避免误清空
      fieldList: type === ACTIVITY_TYPE.FORM ? fields.value : undefined,
    } as WxCpActivityApi.Activity;
    if (id.value) {
      await updateActivity({ ...payload, id: id.value });
@@ -151,6 +208,28 @@
  <Modal :title="title" class="w-3/5">
    <Form class="mx-4" />
    <div v-if="showFieldEditor" class="mx-4 mt-2">
      <FieldEditor v-model="fields" />
    </div>
    <div
      v-else-if="
        formType === 'detail' &&
        activityType === ACTIVITY_TYPE.FORM &&
        fields.length
      "
      class="mx-4 mt-2"
    >
      <div class="mb-2 text-sm font-medium">采集字段</div>
      <div
        v-for="(item, index) in fields"
        :key="index"
        class="text-sm text-gray-600 dark:text-gray-400"
      >
        Â· {{ item.fieldLabel }}({{ fieldTypeLabel(item.fieldType) }})
        <span v-if="item.required" class="text-red-500">*</span>
      </div>
    </div>
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
src/views/wxcp/activity/modules/push-modal.vue
@@ -1,12 +1,12 @@
<script lang="ts" setup>
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, message } from 'ant-design-vue';
import { pushActivity } from '#/api/wxcp/activity';
import { ACTIVITY_TYPE, pushActivity } from '#/api/wxcp/activity';
import PushScopePicker from '../../components/push-scope-picker.vue';
import { PUSH_SCOPE } from '../../data';
@@ -21,6 +21,22 @@
const pushing = ref(false);
const resultMessage = ref('');
const resultSuccess = ref(true);
/** æŒ‰æ¨¡æ¿è¯´æ˜Žæœ¬æ¬¡æŽ¨é€ä¼šå‘什么 */
const pushHint = computed(() => {
  switch (activity.value.activityType) {
    case ACTIVITY_TYPE.TEXT:
      return '成员会收到一条纯文本消息,内容为活动标题 + æ´»åŠ¨è¯¦æƒ…ã€‚';
    case ACTIVITY_TYPE.MESSAGE:
      return '成员会收到一张可点击的卡片消息,点击打开活动详情页。';
    case ACTIVITY_TYPE.IMAGE:
      return '成员会先收到文字说明,再逐张收到上传的图片消息。';
    case ACTIVITY_TYPE.FILE:
      return '成员会先收到文字说明,再逐个收到附件,可直接下载查看。';
    default:
      return '成员会收到卡片消息,点击按钮打开采集页填写并提交评价。';
  }
});
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
@@ -75,10 +91,13 @@
  <Modal title="推送活动" class="w-2/5">
    <div class="px-4">
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm dark:bg-gray-800">
        <div class="mb-1 font-medium">{{ activity.title }}</div>
        <div class="text-gray-500">
          æŽ¨é€åŽæˆå‘˜çš„企业微信会收到卡片消息,点击「立即报名」直接打开报名页。
        <div class="mb-1 font-medium">
          {{ activity.title }}
          <span v-if="activity.activityTypeName" class="ml-2 text-xs text-gray-400">
            {{ activity.activityTypeName }}
          </span>
        </div>
        <div class="text-gray-500">{{ pushHint }}</div>
      </div>
      <div class="mb-2 text-sm font-medium">推送范围</div>
src/views/wxcp/activity/modules/signup-list.vue
@@ -148,6 +148,23 @@
          <span v-else class="text-gray-400">-</span>
        </template>
        <template #fieldValues="{ row }">
          <div v-if="row.fieldValues?.length" class="space-y-1 text-xs">
            <div v-for="item in row.fieldValues" :key="item.fieldId">
              <span class="text-gray-500">{{ item.fieldLabel }}:</span>
              <span>{{ item.fieldValue }}</span>
            </div>
          </div>
          <span v-else class="text-gray-400">-</span>
        </template>
        <template #ratingScore="{ row }">
          <span v-if="row.ratingScore">
            {{ row.ratingScore }} æ˜Ÿ
          </span>
          <span v-else class="text-gray-400">-</span>
        </template>
        <template #attachment="{ row }">
          <Button type="link" class="p-0" @click="handleViewAttachments(row)">
            æŸ¥çœ‹
src/views/wxcp/notice/data.ts
@@ -7,6 +7,21 @@
/** è¡¨å•类型 */
export type NoticeFormType = 'create' | 'update' | 'detail';
/** é€šçŸ¥æ¨¡æ¿ç±»åž‹ */
export const NOTICE_TYPE = {
  TEXT: 1,
  CARD: 2,
  FILE: 3,
  NEWS: 4,
} as const;
export const NOTICE_TYPE_OPTIONS = [
  { label: '纯文本通知', value: NOTICE_TYPE.TEXT },
  { label: '图文卡片(填报/链接)', value: NOTICE_TYPE.CARD },
  { label: '文件通知(上传文件推送)', value: NOTICE_TYPE.FILE },
  { label: '图文消息(标题+摘要+封面)', value: NOTICE_TYPE.NEWS },
];
/** é€šçŸ¥è¡¨å• */
export function useNoticeFormSchema(formType: NoticeFormType): VbenFormSchema[] {
  const readonly = formType === 'detail';
@@ -17,24 +32,111 @@
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'noticeType',
      label: '通知模板',
      component: 'Select',
      componentProps: {
        options: NOTICE_TYPE_OPTIONS,
        placeholder: '请选择通知模板',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'title',
      label: '通知标题',
      component: 'Input',
      componentProps: { placeholder: '请输入通知标题', disabled: readonly },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'content',
      label: '通知正文',
      component: 'Textarea',
      componentProps: {
        rows: 8,
        placeholder: '请输入通知正文',
        rows: 6,
        placeholder: '纯文本通知必填;其它模板可作为补充说明',
        disabled: readonly,
      },
      rules: 'required',
      help: '推送时超过 600 å­—会自动截断,建议把关键信息放前面',
      help: '纯文本通知推送时超过 600 å­—会自动截断,建议把关键信息放前面',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'summary',
      label: '摘要',
      component: 'Textarea',
      componentProps: {
        rows: 3,
        placeholder: '图文卡片/图文消息展示的摘要',
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['noticeType'],
        show: (values) =>
          values.noticeType === NOTICE_TYPE.CARD ||
          values.noticeType === NOTICE_TYPE.NEWS,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'linkUrl',
      label: '跳转链接',
      component: 'Input',
      componentProps: {
        placeholder: '填报页面或详情页地址,如 https://...',
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['noticeType'],
        show: (values) =>
          values.noticeType === NOTICE_TYPE.CARD ||
          values.noticeType === NOTICE_TYPE.NEWS,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'linkBtnText',
      label: '按钮文案',
      component: 'Input',
      componentProps: {
        placeholder: '如:去填报 / æŸ¥çœ‹è¯¦æƒ…',
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['noticeType'],
        show: (values) => values.noticeType === NOTICE_TYPE.CARD,
      },
    },
    {
      fieldName: 'coverUrl',
      label: '封面图地址',
      component: 'Input',
      componentProps: {
        placeholder: '图文消息封面图地址(选填)',
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['noticeType'],
        show: (values) => values.noticeType === NOTICE_TYPE.NEWS,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'fileBlobId',
      label: '通知文件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 1,
        multiple: false,
        showDescription: !readonly,
        disabled: readonly,
      },
      dependencies: {
        triggerFields: ['noticeType'],
        show: (values) => values.noticeType === NOTICE_TYPE.FILE,
      },
      help: '企业微信文件消息上限 20MB',
      formItemClass: 'col-span-2',
    },
    {
@@ -83,6 +185,11 @@
export function useNoticeGridColumns(): VxeTableGridOptions<WxCpNoticeApi.Notice>['columns'] {
  return [
    {
      field: 'noticeTypeName',
      title: '模板',
      width: 150,
    },
    {
      field: 'title',
      title: '通知标题',
      minWidth: 220,
src/views/wxcp/notice/modules/form.vue
@@ -15,7 +15,7 @@
  updateNotice,
} from '#/api/wxcp/notice';
import { useNoticeFormSchema } from '../data';
import { NOTICE_TYPE, useNoticeFormSchema } from '../data';
defineOptions({ name: 'WxCpNoticeForm' });
@@ -59,12 +59,25 @@
      modalApi.lock();
      try {
        const detail = await getNotice(data.id);
        await formApi.setValues(detail as unknown as Record<string, unknown>);
        const values: Record<string, unknown> = { ...detail };
        // æ–‡ä»¶é€šçŸ¥ï¼šæŠŠ blobId è¿˜åŽŸæˆä¸Šä¼ ç»„ä»¶å¯è¯†åˆ«çš„æ–‡ä»¶å¯¹è±¡
        if (detail.noticeType === NOTICE_TYPE.FILE && detail.fileBlobId) {
          values.fileBlobId = [
            {
              uid: String(detail.fileBlobId),
              name: detail.fileName || '',
              status: 'done',
              id: detail.fileBlobId,
            },
          ];
        }
        await formApi.setValues(values);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
      await formApi.setValues({ noticeType: NOTICE_TYPE.TEXT }, false);
    }
  },
});
@@ -77,6 +90,26 @@
  submitting.value = true;
  try {
    const data = (await formApi.getValues()) as WxCpNoticeApi.Notice;
    // ä¿å­˜æ—¶åªåšæ–‡ä»¶å½’一化;具体模板必填项在推送时按所选模板校验
    const noticeType = data.noticeType ?? NOTICE_TYPE.TEXT;
    if (noticeType === NOTICE_TYPE.FILE) {
      const raw = data.fileBlobId as unknown;
      let blobId: number | undefined;
      if (Array.isArray(raw)) {
        const first = raw[0];
        blobId =
          typeof first === 'number'
            ? first
            : (first as { id?: number } | undefined)?.id;
      } else if (typeof raw === 'number') {
        blobId = raw;
      } else if (raw && typeof raw === 'object') {
        blobId = (raw as { id?: number }).id;
      }
      data.fileBlobId = blobId;
    } else {
      data.fileBlobId = undefined;
    }
    if (id.value) {
      await updateNotice({ ...data, id: id.value });
      message.success('修改成功');
src/views/wxcp/notice/modules/push-modal.vue
@@ -1,15 +1,17 @@
<script lang="ts" setup>
import type { WxCpNoticeApi } from '#/api/wxcp/notice';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, message } from 'ant-design-vue';
import { Alert, Button, Input, Select, Textarea, message } from 'ant-design-vue';
import { pushNotice } from '#/api/wxcp/notice';
import FileUpload from '#/components/upload/file-upload.vue';
import PushScopePicker from '../../components/push-scope-picker.vue';
import { PUSH_SCOPE } from '../../data';
import { NOTICE_TYPE, NOTICE_TYPE_OPTIONS } from '../data';
defineOptions({ name: 'WxCpNoticePushModal' });
@@ -21,6 +23,33 @@
const pushing = ref(false);
const resultMessage = ref('');
// æŽ¨é€æ—¶é€‰æ‹©çš„æ¶ˆæ¯æ¨¡æ¿ä¸Žå‚数(默认取通知上已保存的配置)
const noticeType = ref<number>(NOTICE_TYPE.TEXT);
const summary = ref('');
const linkUrl = ref('');
const linkBtnText = ref('');
const coverUrl = ref('');
const fileName = ref('');
const fileList = ref<number[]>([]);
const isCard = computed(() => noticeType.value === NOTICE_TYPE.CARD);
const isNews = computed(() => noticeType.value === NOTICE_TYPE.NEWS);
const isFile = computed(() => noticeType.value === NOTICE_TYPE.FILE);
const showLink = computed(() => isCard.value || isNews.value);
const pushHint = computed(() => {
  switch (noticeType.value) {
    case NOTICE_TYPE.CARD:
      return '成员会收到图文卡片,点击按钮可跳转到填报或详情页面。';
    case NOTICE_TYPE.FILE:
      return '成员会收到一条说明文本和一个文件,文件上限 20MB。';
    case NOTICE_TYPE.NEWS:
      return '成员会收到图文消息,展示标题、摘要、封面并可点击跳转。';
    default:
      return '成员会收到纯文本消息,内容超过 600 å­—会自动截断。';
  }
});
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
@@ -29,11 +58,32 @@
      return;
    }
    const data = modalApi.getData<{ notice: WxCpNoticeApi.Notice }>();
    notice.value = data.notice ?? {};
    scope.value = data.notice?.pushScope ?? PUSH_SCOPE.ALL;
    target.value = data.notice?.pushTarget ?? '';
    const item = data.notice ?? {};
    notice.value = item;
    scope.value = item.pushScope ?? PUSH_SCOPE.ALL;
    target.value = item.pushTarget ?? '';
    noticeType.value = item.noticeType ?? NOTICE_TYPE.TEXT;
    summary.value = item.summary ?? '';
    linkUrl.value = item.linkUrl ?? '';
    linkBtnText.value = item.linkBtnText ?? '';
    coverUrl.value = item.coverUrl ?? '';
    fileName.value = item.fileName ?? '';
    fileList.value = item.fileBlobId ? [item.fileBlobId] : [];
  },
});
/** ä¸Šä¼ ç»„件可能返回数组或对象,统一取出 blobId */
function resolveFileBlobId(): number | undefined {
  const raw = fileList.value as unknown;
  if (Array.isArray(raw)) {
    const first = raw[0];
    if (typeof first === 'number') {
      return first;
    }
    return (first as { id?: number } | undefined)?.id;
  }
  return typeof raw === 'number' ? raw : undefined;
}
async function handlePush() {
  if (!notice.value.id) {
@@ -43,6 +93,20 @@
    message.warning('选择「指定部门」或「指定成员」时,请先选择推送目标');
    return;
  }
  if (noticeType.value === NOTICE_TYPE.TEXT && !notice.value.content?.trim()) {
    message.warning('纯文本通知的正文不能为空');
    return;
  }
  if (showLink.value && !linkUrl.value.trim()) {
    message.warning('图文卡片/图文消息的跳转链接不能为空');
    return;
  }
  const fileBlobId = resolveFileBlobId();
  if (isFile.value && !fileBlobId) {
    message.warning('文件通知必须上传文件');
    return;
  }
  pushing.value = true;
  resultMessage.value = '';
  try {
@@ -50,6 +114,13 @@
      id: notice.value.id,
      pushScope: scope.value,
      pushTarget: scope.value === PUSH_SCOPE.ALL ? '' : target.value,
      noticeType: noticeType.value,
      summary: summary.value,
      linkUrl: linkUrl.value,
      linkBtnText: linkBtnText.value,
      coverUrl: coverUrl.value,
      fileBlobId,
      fileName: fileName.value || undefined,
    });
    if (res?.success) {
      message.success('推送成功');
@@ -71,12 +142,64 @@
    <div class="px-4">
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm dark:bg-gray-800">
        <div class="mb-1 font-medium">{{ notice.title }}</div>
        <div class="text-gray-500">
          æŽ¨é€åŽæˆå‘˜çš„企业微信会收到纯文本消息,内容超过 600 å­—会自动截断。
        </div>
        <div class="text-gray-500">{{ pushHint }}</div>
      </div>
      <div class="mb-2 text-sm font-medium">推送范围</div>
      <div class="mb-2 text-sm font-medium">消息模板</div>
      <Select
        v-model:value="noticeType"
        :options="NOTICE_TYPE_OPTIONS"
        class="mb-4 w-full"
        placeholder="请选择推送模板类型"
      />
      <template v-if="showLink">
        <div class="mb-2 text-sm font-medium">摘要</div>
        <Textarea
          v-model:value="summary"
          class="mb-3"
          :rows="2"
          placeholder="图文卡片/图文消息展示的摘要"
        />
        <div class="mb-2 text-sm font-medium">跳转链接</div>
        <Input
          v-model:value="linkUrl"
          class="mb-3"
          placeholder="填报页面或详情页地址,如 https://..."
        />
      </template>
      <template v-if="isCard">
        <div class="mb-2 text-sm font-medium">按钮文案</div>
        <Input
          v-model:value="linkBtnText"
          class="mb-3"
          placeholder="如:去填报 / æŸ¥çœ‹è¯¦æƒ…"
        />
      </template>
      <template v-if="isNews">
        <div class="mb-2 text-sm font-medium">封面图地址</div>
        <Input
          v-model:value="coverUrl"
          class="mb-3"
          placeholder="封面图地址(选填)"
        />
      </template>
      <template v-if="isFile">
        <div class="mb-2 text-sm font-medium">通知文件</div>
        <FileUpload
          v-model:value="fileList"
          class="mb-3"
          :max-number="1"
          :multiple="false"
          value-key="id"
          :show-description="true"
        />
      </template>
      <div class="mb-2 mt-4 text-sm font-medium">推送范围</div>
      <PushScopePicker v-model:scope="scope" v-model:target="target" />
      <Alert