From fe2d6627b8f1002af7a31b08f6f43bec034d04ee Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期三, 21 十月 2026 18:29:07 +0800
Subject: [PATCH] feat: 完善课题、会员、财务、企微页面并新增资产/用车/采购
---
src/views/ums/member/profile/data.ts | 169 +
src/api/research/application/index.ts | 48
src/views/wxcp/notice/modules/push-modal.vue | 141 +
src/api/purchase/order/index.ts | 61
src/views/finance/reimbursement/modules/ocr-upload.vue | 283 +++
src/views/research/application/detail/approve/index.vue | 35
src/api/asset/requisition/index.ts | 87
src/views/asset/item/data.ts | 212 ++
src/views/finance/reimbursement/modules/form.vue | 85
src/views/vehicle/application/index.vue | 225 ++
src/views/ums/member/index.vue | 6
src/views/wxcp/activity/modules/push-modal.vue | 29
src/api/asset/item/index.ts | 67
src/views/asset/requisition/index.vue | 161 +
src/views/research/application/modules/member-table.vue | 239 ++
src/api/wxcp/notice.ts | 15
src/views/finance/reimbursement/modules/item-form.vue | 58
src/views/purchase/order/modules/item-table.vue | 215 ++
src/api/vehicle/application/index.ts | 94 +
src/views/research/application/data.ts | 46
src/api/ums/member/profile.ts | 93 +
src/views/wxcp/activity/data.ts | 196 ++
src/views/purchase/order/modules/form.vue | 131 +
src/components/upload/image-upload.vue | 16
src/views/ums/member/data.ts | 40
src/views/research/application/modules/member-statistics-modal.vue | 158 +
src/views/research/application/index.vue | 16
src/views/purchase/order/data.ts | 110 +
src/views/wxcp/activity/modules/form.vue | 101 +
src/views/research/application/modules/form.vue | 38
src/views/finance/reimbursement/data.ts | 62
src/views/vehicle/application/modules/process-select-modal.vue | 78
src/views/asset/requisition/modules/return-form.vue | 116 +
src/views/asset/item/modules/form.vue | 97 +
src/views/wxcp/activity/modules/signup-list.vue | 17
src/api/finance/reimbursement/ai/index.ts | 48
src/views/wxcp/notice/modules/form.vue | 37
src/views/purchase/order/index.vue | 159 +
src/views/wxcp/activity/modules/field-editor.vue | 124 +
src/api/finance/reimbursement/index.ts | 1
src/views/asset/requisition/modules/form.vue | 99 +
src/views/asset/requisition/data.ts | 206 ++
src/views/ums/member/change/index.vue | 182 ++
src/views/wxcp/activity/index.vue | 12
src/views/wxcp/notice/data.ts | 117 +
src/views/ums/member/change/modules/reject-modal.vue | 63
src/api/wxcp/activity.ts | 58
src/views/asset/item/index.vue | 160 +
src/views/finance/reimbursement/detail/approve/index.vue | 6
src/views/vehicle/application/data.ts | 192 ++
src/api/ums/member/index.ts | 2
src/views/vehicle/application/modules/form.vue | 94 +
src/views/ums/member/profile/index.vue | 169 +
53 files changed, 5,208 insertions(+), 66 deletions(-)
diff --git a/src/api/asset/item/index.ts b/src/api/asset/item/index.ts
new file mode 100644
index 0000000..580c681
--- /dev/null
+++ b/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 } });
+}
diff --git a/src/api/asset/requisition/index.ts b/src/api/asset/requisition/index.ts
new file mode 100644
index 0000000..729f32c
--- /dev/null
+++ b/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 },
+ });
+}
diff --git a/src/api/finance/reimbursement/ai/index.ts b/src/api/finance/reimbursement/ai/index.ts
new file mode 100644
index 0000000..e2d16e5
--- /dev/null
+++ b/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;
+ /** 鍙戠エ浠g爜锛堟暟鐢电エ鍙兘涓虹┖锛� */
+ 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 },
+ );
+}
diff --git a/src/api/finance/reimbursement/index.ts b/src/api/finance/reimbursement/index.ts
index 65754fd..0125498 100644
--- a/src/api/finance/reimbursement/index.ts
+++ b/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
diff --git a/src/api/purchase/order/index.ts b/src/api/purchase/order/index.ts
new file mode 100644
index 0000000..5c74ebf
--- /dev/null
+++ b/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 } });
+}
diff --git a/src/api/research/application/index.ts b/src/api/research/application/index.ts
index 3e91c52..5a2f91d 100644
--- a/src/api/research/application/index.ts
+++ b/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[]>(
diff --git a/src/api/ums/member/index.ts b/src/api/ums/member/index.ts
index eaa2d86..c3eb225 100644
--- a/src/api/ums/member/index.ts
+++ b/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锛堥潪绌哄嵆宸插綊妗o級
archived?: boolean; // 鏄惁宸插綊妗�
diff --git a/src/api/ums/member/profile.ts b/src/api/ums/member/profile.ts
new file mode 100644
index 0000000..684477f
--- /dev/null
+++ b/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 },
+ });
+}
diff --git a/src/api/vehicle/application/index.ts b/src/api/vehicle/application/index.ts
new file mode 100644
index 0000000..e982c89
--- /dev/null
+++ b/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',
+ );
+}
diff --git a/src/api/wxcp/activity.ts b/src/api/wxcp/activity.ts
index ed27ef6..4333733 100644
--- a/src/api/wxcp/activity.ts
+++ b/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;
diff --git a/src/api/wxcp/notice.ts b/src/api/wxcp/notice.ts
index 4f3f0a9..fe3ebb9 100644
--- a/src/api/wxcp/notice.ts
+++ b/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;
diff --git a/src/components/upload/image-upload.vue b/src/components/upload/image-upload.vue
index c894825..500311d 100644
--- a/src/components/upload/image-upload.vue
+++ b/src/components/upload/image-upload.vue
@@ -104,6 +104,10 @@
return null;
})
.filter(Boolean) as UploadProps['fileList'];
+ } else {
+ // v 涓虹┖锛坣ull/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 鏃跺嵆浣垮凡鏈変竴寮犱篃瑕佷繚鐣欎笂浼犲叆鍙o紙鐢ㄤ簬銆屾崲涓�寮犮�嶏級锛�
+ 鎻掓Ы涓�鏃︿负绌猴紝antd 浼氱敤 appendActionVisible=false 鎶婃暣涓笂浼犳寜閽� v-show 鎺夛紝
+ 缂栬緫鍦烘櫙涓嬪氨褰诲簳娌℃湁鍙偣鐨勪笂浼犲叆鍙d簡 -->
<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" />
diff --git a/src/views/asset/item/data.ts b/src/views/asset/item/data.ts
new file mode 100644
index 0000000..ff426f7
--- /dev/null
+++ b/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' },
+ },
+ ];
+}
diff --git a/src/views/asset/item/index.vue b/src/views/asset/item/index.vue
new file mode 100644
index 0000000..1d0d95a
--- /dev/null
+++ b/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>
diff --git a/src/views/asset/item/modules/form.vue b/src/views/asset/item/modules/form.vue
new file mode 100644
index 0000000..0d64c39
--- /dev/null
+++ b/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>
diff --git a/src/views/asset/requisition/data.ts b/src/views/asset/requisition/data.ts
new file mode 100644
index 0000000..1dd578e
--- /dev/null
+++ b/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' },
+ },
+ ];
+}
diff --git a/src/views/asset/requisition/index.vue b/src/views/asset/requisition/index.vue
new file mode 100644
index 0000000..7cf532f
--- /dev/null
+++ b/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>
diff --git a/src/views/asset/requisition/modules/form.vue b/src/views/asset/requisition/modules/form.vue
new file mode 100644
index 0000000..c15e133
--- /dev/null
+++ b/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>
diff --git a/src/views/asset/requisition/modules/return-form.vue b/src/views/asset/requisition/modules/return-form.vue
new file mode 100644
index 0000000..72388e2
--- /dev/null
+++ b/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>
diff --git a/src/views/finance/reimbursement/data.ts b/src/views/finance/reimbursement/data.ts
index 1b887c6..564b99d 100644
--- a/src/views/finance/reimbursement/data.ts
+++ b/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,
+ },
+ },
+ {
+ // 浠d粬浜哄彂璧凤細閫夋嫨瀹為檯鐢宠浜猴紝浠e綍浜虹敱鍚庣鍒涘缓浜哄瓧娈佃褰�
+ 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: '浠d粬浜哄彂璧�',
+ component: 'Switch',
+ componentProps: {
+ checkedChildren: '浠e彂璧�',
+ 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,
diff --git a/src/views/finance/reimbursement/detail/approve/index.vue b/src/views/finance/reimbursement/detail/approve/index.vue
index 1e425ad..0fc0b27 100644
--- a/src/views/finance/reimbursement/detail/approve/index.vue
+++ b/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,
diff --git a/src/views/finance/reimbursement/modules/form.vue b/src/views/finance/reimbursement/modules/form.vue
index 06fb782..708853a 100644
--- a/src/views/finance/reimbursement/modules/form.vue
+++ b/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;
+ };
+ // 浠d粬浜哄彂璧峰繀椤婚�夋嫨瀹為檯鐢宠浜猴紱鏈汉鍙戣捣鏃朵互褰撳墠鐧诲綍鐢ㄦ埛涓哄噯
+ if (data.proxyFlag) {
+ if (!data.applicantId) {
+ message.error('浠d粬浜哄彂璧锋椂锛岃閫夋嫨瀹為檯鐢宠浜�');
+ 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"
/>
diff --git a/src/views/finance/reimbursement/modules/item-form.vue b/src/views/finance/reimbursement/modules/item-form.vue
index 3c852a4..c87f1f5 100644
--- a/src/views/finance/reimbursement/modules/item-form.vue
+++ b/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>
diff --git a/src/views/finance/reimbursement/modules/ocr-upload.vue b/src/views/finance/reimbursement/modules/ocr-upload.vue
new file mode 100644
index 0000000..1f69bfd
--- /dev/null
+++ b/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;
+}
+
+/** 鑷畾涔変笂浼狅細鍙礋璐f妸鏂囦欢浼犲埌瀛樺偍锛岃瘑鍒敱銆屽紑濮嬭瘑鍒�嶇粺涓�瑙﹀彂 */
+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銆丳NG銆丅MP銆丟IF銆丳DF锛屾渶澶� 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">
+ 姝e湪鎵归噺璇嗗埆 {{ 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>
diff --git a/src/views/purchase/order/data.ts b/src/views/purchase/order/data.ts
new file mode 100644
index 0000000..bcb8b63
--- /dev/null
+++ b/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,
+ }));
+}
diff --git a/src/views/purchase/order/index.vue b/src/views/purchase/order/index.vue
new file mode 100644
index 0000000..6b30c89
--- /dev/null
+++ b/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>
diff --git a/src/views/purchase/order/modules/form.vue b/src/views/purchase/order/modules/form.vue
new file mode 100644
index 0000000..2362842
--- /dev/null
+++ b/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>
diff --git a/src/views/purchase/order/modules/item-table.vue b/src/views/purchase/order/modules/item-table.vue
new file mode 100644
index 0000000..9e9860a
--- /dev/null
+++ b/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>
diff --git a/src/views/research/application/data.ts b/src/views/research/application/data.ts
index 258cb62..fd50bee 100644
--- a/src/views/research/application/data.ts
+++ b/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',
},
{
diff --git a/src/views/research/application/detail/approve/index.vue b/src/views/research/application/detail/approve/index.vue
index 59f0539..6dfee12 100644
--- a/src/views/research/application/detail/approve/index.vue
+++ b/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>
diff --git a/src/views/research/application/index.vue b/src/views/research/application/index.vue
index b7828c1..a5c7e5b 100644
--- a/src/views/research/application/index.vue
+++ b/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,
diff --git a/src/views/research/application/modules/form.vue b/src/views/research/application/modules/form.vue
index a7e2a95..5b02f26 100644
--- a/src/views/research/application/modules/form.vue
+++ b/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>
diff --git a/src/views/research/application/modules/member-statistics-modal.vue b/src/views/research/application/modules/member-statistics-modal.vue
new file mode 100644
index 0000000..8e7452b
--- /dev/null
+++ b/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">
+ 缁熻鍙e緞锛氬悓涓�銆屽鍚� + 鐢佃瘽 + 鍗曚綅銆嶈涓哄悓涓�鎴愬憳锛涘弬涓庤棰樻暟鎸変笉閲嶅璇鹃璁℃暟銆�
+ </div>
+ </Modal>
+</template>
diff --git a/src/views/research/application/modules/member-table.vue b/src/views/research/application/modules/member-table.vue
new file mode 100644
index 0000000..6b44423
--- /dev/null
+++ b/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}锛氳仈绯荤數璇濇牸寮忎笉姝g‘锛岃杈撳叆 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>
diff --git a/src/views/ums/member/change/index.vue b/src/views/ums/member/change/index.vue
new file mode 100644
index 0000000..f259eec
--- /dev/null
+++ b/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>
diff --git a/src/views/ums/member/change/modules/reject-modal.vue b/src/views/ums/member/change/modules/reject-modal.vue
new file mode 100644
index 0000000..95ad062
--- /dev/null
+++ b/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="璇疯緭鍏ラ┏鍥炲師鍥狅紝渚嬪锛氳仈绯荤數璇濇牸寮忎笉姝g‘锛岃濉啓 11 浣嶆墜鏈哄彿"
+ />
+ </Modal>
+</template>
diff --git a/src/views/ums/member/data.ts b/src/views/ums/member/data.ts
index 5fec58c..9660b83 100644
--- a/src/views/ums/member/data.ts
+++ b/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,
diff --git a/src/views/ums/member/index.vue b/src/views/ums/member/index.vue
index bee2433..36ec2ff 100644
--- a/src/views/ums/member/index.vue
+++ b/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 ? '鏈�' : '鏃�' }}
diff --git a/src/views/ums/member/profile/data.ts b/src/views/ums/member/profile/data.ts
new file mode 100644
index 0000000..7608560
--- /dev/null
+++ b/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: '娉曞畾浠h〃浜�',
+ component: 'Input',
+ componentProps: {
+ placeholder: '璇疯緭鍏ユ硶瀹氫唬琛ㄤ汉',
+ maxlength: 64,
+ },
+ dependencies: {
+ triggerFields: ['memberType'],
+ show: (values) => values.memberType === MEMBER_TYPE_UNIT,
+ },
+ },
+ {
+ fieldName: 'organizationCode',
+ label: '缁熶竴绀句細淇$敤浠g爜',
+ component: 'Input',
+ componentProps: {
+ placeholder: '璇疯緭鍏ョ粺涓�绀句細淇$敤浠g爜',
+ 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,
+ },
+ },
+ ];
+}
diff --git a/src/views/ums/member/profile/index.vue b/src/views/ums/member/profile/index.vue
new file mode 100644
index 0000000..7950db4
--- /dev/null
+++ b/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>
diff --git a/src/views/vehicle/application/data.ts b/src/views/vehicle/application/data.ts
new file mode 100644
index 0000000..43c9ea3
--- /dev/null
+++ b/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: '閫夊~锛屽 鑻廎12345',
+ 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' },
+ },
+ ];
+}
diff --git a/src/views/vehicle/application/index.vue b/src/views/vehicle/application/index.vue
new file mode 100644
index 0000000..b5f6a33
--- /dev/null
+++ b/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>
diff --git a/src/views/vehicle/application/modules/form.vue b/src/views/vehicle/application/modules/form.vue
new file mode 100644
index 0000000..50a686e
--- /dev/null
+++ b/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>
diff --git a/src/views/vehicle/application/modules/process-select-modal.vue b/src/views/vehicle/application/modules/process-select-modal.vue
new file mode 100644
index 0000000..64cc679
--- /dev/null
+++ b/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>
diff --git a/src/views/wxcp/activity/data.ts b/src/views/wxcp/activity/data.ts
index 1692620..745584f 100644
--- a/src/views/wxcp/activity/data.ts
+++ b/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,
diff --git a/src/views/wxcp/activity/index.vue b/src/views/wxcp/activity/index.vue
index 1530c6e..b45e9a6 100644
--- a/src/views/wxcp/activity/index.vue
+++ b/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(),
},
]"
diff --git a/src/views/wxcp/activity/modules/field-editor.vue b/src/views/wxcp/activity/modules/field-editor.vue
new file mode 100644
index 0000000..4ff395b
--- /dev/null
+++ b/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>
diff --git a/src/views/wxcp/activity/modules/form.vue b/src/views/wxcp/activity/modules/form.vue
index 0407446..299f5df 100644
--- a/src/views/wxcp/activity/modules/form.vue
+++ b/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>
diff --git a/src/views/wxcp/activity/modules/push-modal.vue b/src/views/wxcp/activity/modules/push-modal.vue
index 56ef89d..e866c38 100644
--- a/src/views/wxcp/activity/modules/push-modal.vue
+++ b/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>
diff --git a/src/views/wxcp/activity/modules/signup-list.vue b/src/views/wxcp/activity/modules/signup-list.vue
index fabaec0..280a107 100644
--- a/src/views/wxcp/activity/modules/signup-list.vue
+++ b/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)">
鏌ョ湅
diff --git a/src/views/wxcp/notice/data.ts b/src/views/wxcp/notice/data.ts
index 470b00d..1b28e06 100644
--- a/src/views/wxcp/notice/data.ts
+++ b/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: '閫氱煡姝f枃',
component: 'Textarea',
componentProps: {
- rows: 8,
- placeholder: '璇疯緭鍏ラ�氱煡姝f枃',
+ 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,
diff --git a/src/views/wxcp/notice/modules/form.vue b/src/views/wxcp/notice/modules/form.vue
index cd835ac..cab9ba2 100644
--- a/src/views/wxcp/notice/modules/form.vue
+++ b/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('淇敼鎴愬姛');
diff --git a/src/views/wxcp/notice/modules/push-modal.vue b/src/views/wxcp/notice/modules/push-modal.vue
index 918ad4a..0a82a04 100644
--- a/src/views/wxcp/notice/modules/push-modal.vue
+++ b/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
--
Gitblit v1.9.3