From 7a6351a1fa5fc6e67b55c475bcd253d6d3e5d68f Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期二, 29 九月 2026 13:48:31 +0800
Subject: [PATCH] feat(wxcp): 新增企业微信模块页面与扫码登录入口

---
 src/views/wxcp/activity/modules/form.vue         |  116 ++
 src/views/wxcp/data.ts                           |   80 +
 src/views/wxcp/notice/modules/push-modal.vue     |  100 ++
 src/views/wxcp/activity/data.ts                  |  240 +++++
 src/views/wxcp/components/push-scope-picker.vue  |  172 ++++
 pnpm-lock.yaml                                   |   44 
 src/api/wxcp/member.ts                           |   58 +
 src/views/wxcp/activity/modules/signup-list.vue  |  242 +++++
 src/views/wxcp/notice/modules/form.vue           |  116 ++
 src/views/wxcp/activity/index.vue                |  266 ++++++
 src/views/wxcp/notice/index.vue                  |  204 ++++
 src/views/wxcp/notice/data.ts                    |  118 ++
 src/api/wxcp/activity.ts                         |  254 +++++
 src/views/_core/authentication/login.vue         |   58 +
 src/views/wxcp/activity/modules/push-modal.vue   |  105 ++
 package.json                                     |    1 
 src/views/wxcp/activity/modules/qrcode-modal.vue |  148 +++
 src/api/wxcp/notice.ts                           |   94 ++
 src/views/wxcp/activity/modules/stats-modal.vue  |  163 +++
 19 files changed, 2,556 insertions(+), 23 deletions(-)

diff --git a/package.json b/package.json
index a6a2e45..704e713 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
     "@vueuse/core": "catalog:",
     "@vueuse/integrations": "catalog:",
     "ant-design-vue": "catalog:",
+    "axios": "catalog:",
     "bpmn-js": "catalog:",
     "bpmn-js-properties-panel": "catalog:",
     "bpmn-js-token-simulation": "catalog:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fa4be39..ed42e33 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -620,6 +620,9 @@
       ant-design-vue:
         specifier: 'catalog:'
         version: 4.2.6(vue@3.5.38(typescript@6.0.3))
+      axios:
+        specifier: 'catalog:'
+        version: 1.18.1
       bpmn-js:
         specifier: 'catalog:'
         version: 18.18.0
@@ -3183,8 +3186,8 @@
   '@lezer/common@1.5.2':
     resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
 
-  '@lezer/highlight@1.2.3':
-    resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
+  '@lezer/highlight@1.2.4':
+    resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
 
   '@lezer/json@1.0.3':
     resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
@@ -8844,8 +8847,8 @@
   strnum@1.0.5:
     resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
 
-  style-mod@4.1.3:
-    resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+  style-mod@4.1.4:
+    resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
 
   style-search@0.1.0:
     resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==}
@@ -9207,8 +9210,8 @@
     resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
     engines: {node: '>= 0.4'}
 
-  typed-array-byte-offset@1.0.4:
-    resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+  typed-array-byte-offset@1.0.5:
+    resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==}
     engines: {node: '>= 0.4'}
 
   typed-array-length@1.0.8:
@@ -10710,7 +10713,7 @@
     dependencies:
       '@codemirror/language': 6.12.3
       '@codemirror/view': 6.43.1
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
 
   '@bpmn-io/diagram-js-ui@0.2.4':
     dependencies:
@@ -10733,7 +10736,7 @@
       '@codemirror/lint': 6.9.7
       '@codemirror/state': 6.6.0
       '@codemirror/view': 6.43.1
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
       min-dom: 5.3.0
       mitt: 3.0.1
 
@@ -10758,13 +10761,13 @@
 
   '@bpmn-io/lezer-feel@2.5.0':
     dependencies:
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
       '@lezer/lr': 1.4.10
       min-dash: 5.0.0
 
   '@bpmn-io/lezer-feel@3.0.1':
     dependencies:
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
       '@lezer/lr': 1.4.10
       min-dash: 5.0.0
 
@@ -10849,9 +10852,9 @@
       '@codemirror/state': 6.6.0
       '@codemirror/view': 6.43.1
       '@lezer/common': 1.5.2
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
       '@lezer/lr': 1.4.10
-      style-mod: 4.1.3
+      style-mod: 4.1.4
 
   '@codemirror/lint@6.9.7':
     dependencies:
@@ -10867,7 +10870,7 @@
     dependencies:
       '@codemirror/state': 6.6.0
       crelt: 1.0.7
-      style-mod: 4.1.3
+      style-mod: 4.1.4
       w3c-keyname: 2.2.8
 
   '@commitlint/cli@19.8.0(@types/node@25.9.4)(typescript@6.0.3)':
@@ -11560,14 +11563,14 @@
 
   '@lezer/common@1.5.2': {}
 
-  '@lezer/highlight@1.2.3':
+  '@lezer/highlight@1.2.4':
     dependencies:
       '@lezer/common': 1.5.2
 
   '@lezer/json@1.0.3':
     dependencies:
       '@lezer/common': 1.5.2
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
       '@lezer/lr': 1.4.10
 
   '@lezer/lr@1.4.10':
@@ -11577,7 +11580,7 @@
   '@lezer/markdown@1.6.4':
     dependencies:
       '@lezer/common': 1.5.2
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
 
   '@livekit/mutex@1.1.1': {}
 
@@ -14351,7 +14354,7 @@
       string.prototype.trimstart: 1.0.8
       typed-array-buffer: 1.0.3
       typed-array-byte-length: 1.0.3
-      typed-array-byte-offset: 1.0.4
+      typed-array-byte-offset: 1.0.5
       typed-array-length: 1.0.8
       unbox-primitive: 1.1.0
       which-typed-array: 1.1.22
@@ -14771,7 +14774,7 @@
       '@codemirror/state': 6.6.0
       '@codemirror/view': 6.43.1
       '@lezer/common': 1.5.2
-      '@lezer/highlight': 1.2.3
+      '@lezer/highlight': 1.2.4
       '@lezer/lr': 1.4.10
       '@lezer/markdown': 1.6.4
       min-dom: 5.3.0
@@ -17384,7 +17387,7 @@
 
   strnum@1.0.5: {}
 
-  style-mod@4.1.3: {}
+  style-mod@4.1.4: {}
 
   style-search@0.1.0: {}
 
@@ -17782,13 +17785,12 @@
       has-proto: 1.2.0
       is-typed-array: 1.1.15
 
-  typed-array-byte-offset@1.0.4:
+  typed-array-byte-offset@1.0.5:
     dependencies:
       available-typed-arrays: 1.0.7
       call-bind: 1.0.9
       for-each: 0.3.5
       gopd: 1.2.0
-      has-proto: 1.2.0
       is-typed-array: 1.1.15
       reflect.getprototypeof: 1.0.10
 
diff --git a/src/api/wxcp/activity.ts b/src/api/wxcp/activity.ts
new file mode 100644
index 0000000..5d5b1c8
--- /dev/null
+++ b/src/api/wxcp/activity.ts
@@ -0,0 +1,254 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace WxCpActivityApi {
+  /** 娲诲姩鐘舵�侊細0鑽夌 1宸插彂甯� 2宸茬粨鏉� 3宸插彇娑� */
+  export const ACTIVITY_STATUS = {
+    DRAFT: 0,
+    PUBLISHED: 1,
+    FINISHED: 2,
+    CANCELED: 3,
+  } as const;
+
+  /** 鎺ㄩ�佽寖鍥达細0鍏ㄥ憳 1鎸囧畾閮ㄩ棬 2鎸囧畾鎴愬憳 */
+  export const PUSH_SCOPE = {
+    ALL: 0,
+    PARTY: 1,
+    USER: 2,
+  } as const;
+
+  /** 娲诲姩 */
+  export interface Activity {
+    id?: number;
+    title?: string;
+    content?: string;
+    coverUrl?: string;
+    location?: string;
+    startTime?: string;
+    endTime?: string;
+    signupStartTime?: string;
+    signupEndTime?: string;
+    /** 鎶ュ悕涓婇檺锛�0 琛ㄧず涓嶉檺 */
+    signupLimit?: number;
+    signupCount?: number;
+    status?: number;
+    statusName?: string;
+    pushScope?: number;
+    pushScopeName?: string;
+    pushTarget?: string;
+    pushStatus?: number;
+    pushTime?: string;
+    remark?: string;
+    createTime?: string;
+  }
+
+  /** 娲诲姩鍒嗛〉鏌ヨ鍙傛暟 */
+  export interface ActivityPageParams extends PageParam {
+    title?: string;
+    status?: number;
+    pushStatus?: number;
+    startTime?: [string, string];
+  }
+
+  /** 鎶ュ悕璁板綍 */
+  export interface Signup {
+    id?: number;
+    activityId?: number;
+    activityTitle?: string;
+    wxUserId?: string;
+    name?: string;
+    mobile?: string;
+    deptName?: string;
+    content?: string;
+    /** 鐘舵�侊細0宸叉姤鍚� 1宸插彇娑� 2宸茬鍒� */
+    status?: number;
+    statusName?: string;
+    signupTime?: string;
+    checkInTime?: string;
+    remark?: string;
+    createTime?: string;
+  }
+
+  /** 鎶ュ悕璁板綍鍒嗛〉鏌ヨ鍙傛暟 */
+  export interface SignupPageParams extends PageParam {
+    activityId?: number;
+    name?: string;
+    mobile?: string;
+    status?: number;
+  }
+
+  /** 鍚嶇О-鏁伴噺缁熻椤� */
+  export interface NameCount {
+    name: string;
+    count: number;
+  }
+
+  /** 鎶ュ悕缁熻 */
+  export interface ActivityStats {
+    activityId: number;
+    activityTitle?: string;
+    signupLimit?: number;
+    totalSignup: number;
+    signedUpCount: number;
+    checkedInCount: number;
+    canceledCount: number;
+    /** 涓嶉檺鍚嶉鏃朵负 null */
+    remaining?: null | number;
+    signupPercent?: null | number;
+    checkInPercent: number;
+    deptStats: NameCount[];
+    dateStats: NameCount[];
+    sourceStats: NameCount[];
+  }
+
+  /** 鎺ㄩ�佽姹� */
+  export interface PushParams {
+    id: number;
+    pushScope?: number;
+    pushTarget?: string;
+  }
+
+  /** 鎺ㄩ�佺粨鏋� */
+  export interface PushResult {
+    success: boolean;
+    errCode?: null | number;
+    message?: string;
+    toUser?: string;
+    toParty?: string;
+  }
+
+  /** 鎶ュ悕闄勪欢 */
+  export interface SignupAttachment {
+    id: number;
+    name?: string;
+    originalFilename?: string;
+    contentType?: string;
+    byteSize?: number;
+    url?: string;
+    previewURL?: string;
+    downloadURL?: string;
+  }
+}
+
+/** 鑾峰緱娲诲姩鍒嗛〉 */
+export function getActivityPage(params: WxCpActivityApi.ActivityPageParams) {
+  return requestClient.get<PageResult<WxCpActivityApi.Activity>>(
+    '/wxcp/activity/page',
+    { params },
+  );
+}
+
+/** 鑾峰緱娲诲姩璇︽儏 */
+export function getActivity(id: number) {
+  return requestClient.get<WxCpActivityApi.Activity>(
+    '/wxcp/activity/get',
+    { params: { id } },
+  );
+}
+
+/** 鍒涘缓娲诲姩锛堝瓨涓鸿崏绋匡級 */
+export function createActivity(data: WxCpActivityApi.Activity) {
+  return requestClient.post<number>('/wxcp/activity/create', data);
+}
+
+/** 淇敼娲诲姩 */
+export function updateActivity(data: WxCpActivityApi.Activity) {
+  return requestClient.put<boolean>('/wxcp/activity/update', data);
+}
+
+/** 鍒犻櫎娲诲姩 */
+export function deleteActivity(id: number) {
+  return requestClient.delete<boolean>('/wxcp/activity/delete', {
+    params: { id },
+  });
+}
+
+/** 鍙戝竷娲诲姩 */
+export function publishActivity(id: number) {
+  return requestClient.put<boolean>('/wxcp/activity/publish', null, {
+    params: { id },
+  });
+}
+
+/** 缁撴潫娲诲姩 */
+export function finishActivity(id: number) {
+  return requestClient.put<boolean>('/wxcp/activity/finish', null, {
+    params: { id },
+  });
+}
+
+/** 鍙栨秷娲诲姩 */
+export function cancelActivity(id: number) {
+  return requestClient.put<boolean>('/wxcp/activity/cancel', null, {
+    params: { id },
+  });
+}
+
+/** 鎺ㄩ�佹椿鍔ㄧ粰浼佷笟寰俊鎴愬憳 */
+export function pushActivity(data: WxCpActivityApi.PushParams) {
+  return requestClient.post<WxCpActivityApi.PushResult>(
+    '/wxcp/activity/push',
+    data,
+  );
+}
+
+/** 鑾峰緱娲诲姩鎶ュ悕椤靛湴鍧� */
+export function getActivitySignupUrl(id: number) {
+  return requestClient.get<string>('/wxcp/activity/signup-url', {
+    params: { id },
+  });
+}
+
+/**
+ * 鑾峰緱娲诲姩鎶ュ悕浜岀淮鐮侊紙PNG Blob锛�
+ *
+ * 璇ユ帴鍙h繑鍥炲浘鐗囦簩杩涘埗锛岃�� <img src> 鏃犳硶鎼哄甫璇锋眰澶达紝
+ * 鎵�浠ヨ繖閲岀敤 raw 妯″紡鍙栧洖 Blob锛屽啀鐢辫皟鐢ㄦ柟杞垚 objectURL 鏄剧ず/涓嬭浇銆�
+ */
+export function getActivityQrCodeBlob(id: number, size?: number) {
+  return requestClient.request('/wxcp/activity/qrcode', {
+    method: 'GET',
+    params: { id, size },
+    responseType: 'blob',
+    responseReturn: 'raw',
+  });
+}
+
+/** 鑾峰緱娲诲姩鎶ュ悕缁熻 */
+export function getActivityStats(id: number) {
+  return requestClient.get<WxCpActivityApi.ActivityStats>(
+    '/wxcp/activity/stats',
+    { params: { id } },
+  );
+}
+
+/** 鑾峰緱鎶ュ悕璁板綍鍒嗛〉 */
+export function getSignupPage(params: WxCpActivityApi.SignupPageParams) {
+  return requestClient.get<PageResult<WxCpActivityApi.Signup>>(
+    '/wxcp/activity/signup/page',
+    { params },
+  );
+}
+
+/** 鍙栨秷鎶ュ悕 */
+export function cancelSignup(id: number) {
+  return requestClient.put<boolean>('/wxcp/activity/signup/cancel', null, {
+    params: { id },
+  });
+}
+
+/** 鎶ュ悕绛惧埌 */
+export function checkInSignup(id: number) {
+  return requestClient.put<boolean>('/wxcp/activity/signup/check-in', null, {
+    params: { id },
+  });
+}
+
+/** 鑾峰緱鏌愭潯鎶ュ悕璁板綍涓婁紶鐨勫浘鐗�/闄勪欢 */
+export function getSignupAttachments(signupId: number) {
+  return requestClient.get<WxCpActivityApi.SignupAttachment[]>(
+    '/wxcp/activity/signup/attachments',
+    { params: { signupId } },
+  );
+}
diff --git a/src/api/wxcp/member.ts b/src/api/wxcp/member.ts
new file mode 100644
index 0000000..28ddf59
--- /dev/null
+++ b/src/api/wxcp/member.ts
@@ -0,0 +1,58 @@
+import { requestClient } from '#/api/request';
+
+export namespace WxCpMemberApi {
+  /** 浼佷笟寰俊閮ㄩ棬 */
+  export interface Department {
+    id: number;
+    name?: string;
+    parentId?: number;
+    order?: number;
+    children?: Department[];
+  }
+
+  /** 浼佷笟寰俊鎴愬憳 */
+  export interface Member {
+    /** 鎴愬憳璐﹀彿锛屾帹閫佹椂浣滀负 pushTarget 浣跨敤 */
+    userId: string;
+    name?: string;
+    mobile?: string;
+    email?: string;
+    avatar?: string;
+    position?: string;
+    /** 0鏈畾涔� 1鐢� 2濂� */
+    gender?: string;
+    /** 1宸插惎鐢� 2宸茬鐢� 4鏈縺娲� 5閫�鍑轰紒涓� */
+    status?: number;
+    departmentIds?: number[];
+    departmentNames?: string;
+  }
+}
+
+/**
+ * 鑾峰緱閮ㄩ棬鏍�
+ *
+ * 娉ㄦ剰锛氳鎺ュ彛瀹炴椂璋冪敤浼佷笟寰俊锛屾湁棰戠巼闄愬埗锛岃皟鐢ㄦ柟闇�鑷缂撳瓨锛�
+ * 涓嶈鍦ㄨ緭鍏ユ閲屾瘡鏁蹭竴涓瓧灏辫姹備竴娆°��
+ */
+export function getWxCpDepartmentTree() {
+  return requestClient.get<WxCpMemberApi.Department[]>(
+    '/wxcp/member/department/tree',
+  );
+}
+
+/** 鑾峰緱閮ㄩ棬骞抽摵鍒楄〃锛堝仛涓嬫媺閫夋嫨鏇存柟渚匡級 */
+export function getWxCpDepartmentList() {
+  return requestClient.get<WxCpMemberApi.Department[]>(
+    '/wxcp/member/department/list',
+  );
+}
+
+/** 鑾峰緱浼佷笟寰俊鎴愬憳鍒楄〃 */
+export function getWxCpMemberList(params?: {
+  departmentId?: number;
+  fetchChild?: boolean;
+}) {
+  return requestClient.get<WxCpMemberApi.Member[]>('/wxcp/member/list', {
+    params,
+  });
+}
diff --git a/src/api/wxcp/notice.ts b/src/api/wxcp/notice.ts
new file mode 100644
index 0000000..4f3f0a9
--- /dev/null
+++ b/src/api/wxcp/notice.ts
@@ -0,0 +1,94 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace WxCpNoticeApi {
+  /** 閫氱煡鐘舵�侊細0鑽夌 1宸插彂甯� 2宸叉挙鍥� */
+  export const NOTICE_STATUS = {
+    DRAFT: 0,
+    PUBLISHED: 1,
+    REVOKED: 2,
+  } as const;
+
+  /** 閫氱煡 */
+  export interface Notice {
+    id?: number;
+    title?: string;
+    content?: string;
+    status?: number;
+    statusName?: string;
+    pushScope?: number;
+    pushScopeName?: string;
+    pushTarget?: string;
+    pushStatus?: number;
+    pushTime?: string;
+    remark?: string;
+    createTime?: string;
+  }
+
+  /** 閫氱煡鍒嗛〉鏌ヨ鍙傛暟 */
+  export interface NoticePageParams extends PageParam {
+    title?: string;
+    status?: number;
+    pushStatus?: number;
+  }
+}
+
+/** 鑾峰緱閫氱煡鍒嗛〉 */
+export function getNoticePage(params: WxCpNoticeApi.NoticePageParams) {
+  return requestClient.get<PageResult<WxCpNoticeApi.Notice>>(
+    '/wxcp/notice/page',
+    { params },
+  );
+}
+
+/** 鑾峰緱閫氱煡璇︽儏 */
+export function getNotice(id: number) {
+  return requestClient.get<WxCpNoticeApi.Notice>('/wxcp/notice/get', {
+    params: { id },
+  });
+}
+
+/** 鍒涘缓閫氱煡锛堝瓨涓鸿崏绋匡級 */
+export function createNotice(data: WxCpNoticeApi.Notice) {
+  return requestClient.post<number>('/wxcp/notice/create', data);
+}
+
+/** 淇敼閫氱煡 */
+export function updateNotice(data: WxCpNoticeApi.Notice) {
+  return requestClient.put<boolean>('/wxcp/notice/update', data);
+}
+
+/** 鍒犻櫎閫氱煡 */
+export function deleteNotice(id: number) {
+  return requestClient.delete<boolean>('/wxcp/notice/delete', {
+    params: { id },
+  });
+}
+
+/** 鍙戝竷閫氱煡 */
+export function publishNotice(id: number) {
+  return requestClient.put<boolean>('/wxcp/notice/publish', null, {
+    params: { id },
+  });
+}
+
+/** 鎾ゅ洖閫氱煡 */
+export function revokeNotice(id: number) {
+  return requestClient.put<boolean>('/wxcp/notice/revoke', null, {
+    params: { id },
+  });
+}
+
+/** 鎺ㄩ�侀�氱煡缁欎紒涓氬井淇℃垚鍛� */
+export function pushNotice(data: {
+  id: number;
+  pushScope?: number;
+  pushTarget?: string;
+}) {
+  return requestClient.post<{
+    success: boolean;
+    errCode?: null | number;
+    message?: string;
+  }>('/wxcp/notice/push', data);
+}
diff --git a/src/views/_core/authentication/login.vue b/src/views/_core/authentication/login.vue
index 16e9f68..0d0f566 100644
--- a/src/views/_core/authentication/login.vue
+++ b/src/views/_core/authentication/login.vue
@@ -5,8 +5,9 @@
 
 import { computed, onMounted, ref } from 'vue';
 
-import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
+import { AuthenticationLogin, VbenButton, Verification, z } from '@vben/common-ui';
 import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
+import { SvgWeChatIcon } from '@vben/icons';
 import { $t } from '@vben/locales';
 import { useAccessStore } from '@vben/stores';
 
@@ -15,6 +16,7 @@
   getCaptcha,
   getTenantByWebsite,
   getTenantSimpleList,
+  socialAuthRedirect,
 } from '#/api/core/auth';
 import { useAuthStore } from '#/store';
 
@@ -71,6 +73,37 @@
 onMounted(() => {
   fetchTenantList();
 });
+
+// ========== 浼佷笟寰俊鐧诲綍 ==========
+
+/** 浼佷笟寰俊鐨勭ぞ浜ょ被鍨嬶紝瀵瑰簲鍚庣 SocialTypeEnum.WECHAT_ENTERPRISE */
+const WXCP_SOCIAL_TYPE = 30;
+
+const wxcpLoading = ref(false);
+
+/**
+ * 浼佷笟寰俊鎵爜鐧诲綍
+ *
+ * 娴佺▼锛氬彇鎺堟潈鍦板潃 -> 鏁撮〉璺宠浆鍒颁紒涓氬井淇℃壂鐮侀〉 -> 鎵爜鍚庝紒涓氬井淇″甫 code/state
+ * 鍥炶烦鍒� social-login 椤甸潰 -> 璇ラ〉闈㈣皟鐢� /system/auth/social-login 鎹㈠彇浠ょ墝
+ */
+async function handleWxcpLogin() {
+  try {
+    wxcpLoading.value = true;
+    // 娉ㄦ剰锛歵ype 闇�瑕佸厛 encode 涓�娆★紝閰嶅悎 social-login 椤甸潰鐨� getUrlValue() 浣跨敤锛�
+    // 鍚﹀垯浼佷笟寰俊鍥炶烦鍚� type 鍙傛暟浼氳鍚炴帀锛屽鑷存棤娉曡瘑鍒ぞ浜ょ被鍨�
+    const redirectUri = `${location.origin}/auth/social-login?${encodeURIComponent(
+      `type=${WXCP_SOCIAL_TYPE}`,
+    )}`;
+    window.location.href = await socialAuthRedirect(
+      WXCP_SOCIAL_TYPE,
+      redirectUri,
+    );
+  } catch (error) {
+    console.error('浼佷笟寰俊鐧诲綍澶辫触:', error);
+    wxcpLoading.value = false;
+  }
+}
 
 const formSchema = computed((): VbenFormSchema[] => {
   return [
@@ -134,7 +167,28 @@
       :show-forget-password="false"
       :show-doc-link="false"
       @submit="handleLogin"
-    />
+    >
+      <template #third-party-login>
+        <div class="mt-4">
+          <div class="mb-4 flex items-center justify-between">
+            <span class="w-[35%] border-b border-input dark:border-gray-600"></span>
+            <span class="text-center text-xs text-muted-foreground">
+              鍏朵粬鐧诲綍鏂瑰紡
+            </span>
+            <span class="w-[35%] border-b border-input dark:border-gray-600"></span>
+          </div>
+          <VbenButton
+            class="w-full"
+            variant="outline"
+            :loading="wxcpLoading"
+            @click="handleWxcpLogin"
+          >
+            <SvgWeChatIcon class="mr-2 size-5" />
+            浼佷笟寰俊鐧诲綍
+          </VbenButton>
+        </div>
+      </template>
+    </AuthenticationLogin>
     <Verification
       ref="verifyRef"
       v-if="captchaEnable"
diff --git a/src/views/wxcp/activity/data.ts b/src/views/wxcp/activity/data.ts
new file mode 100644
index 0000000..da4118e
--- /dev/null
+++ b/src/views/wxcp/activity/data.ts
@@ -0,0 +1,240 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { WxCpActivityApi } from '#/api/wxcp/activity';
+
+import {
+  ACTIVITY_STATUS_OPTIONS,
+  PUSH_SCOPE_OPTIONS,
+  PUSH_STATUS_OPTIONS,
+  SIGNUP_STATUS_OPTIONS,
+} from '../data';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type ActivityFormType = 'create' | 'update' | 'detail';
+
+const DATETIME_PROPS = {
+  showTime: true,
+  valueFormat: 'YYYY-MM-DD HH:mm:ss',
+  format: 'YYYY-MM-DD HH:mm:ss',
+} as const;
+
+/** 娲诲姩琛ㄥ崟 */
+export function useActivityFormSchema(
+  formType: ActivityFormType,
+): VbenFormSchema[] {
+  const readonly = formType === 'detail';
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: { triggerFields: [''], show: () => false },
+    },
+    {
+      fieldName: 'title',
+      label: '娲诲姩鏍囬',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ユ椿鍔ㄦ爣棰�',
+        disabled: readonly,
+      },
+      rules: 'required',
+      formItemClass: 'col-span-2',
+    },
+    {
+      fieldName: 'location',
+      label: '娲诲姩鍦扮偣',
+      component: 'Input',
+      componentProps: { placeholder: '濡傦細鍏徃澶т細璁', disabled: readonly },
+      formItemClass: 'col-span-2',
+    },
+    {
+      fieldName: 'startTime',
+      label: '娲诲姩寮�濮�',
+      component: 'DatePicker',
+      componentProps: { ...DATETIME_PROPS, disabled: readonly },
+    },
+    {
+      fieldName: 'endTime',
+      label: '娲诲姩缁撴潫',
+      component: 'DatePicker',
+      componentProps: { ...DATETIME_PROPS, disabled: readonly },
+    },
+    {
+      fieldName: 'signupStartTime',
+      label: '鎶ュ悕寮�濮�',
+      component: 'DatePicker',
+      componentProps: { ...DATETIME_PROPS, disabled: readonly },
+    },
+    {
+      fieldName: 'signupEndTime',
+      label: '鎶ュ悕鎴',
+      component: 'DatePicker',
+      componentProps: { ...DATETIME_PROPS, disabled: readonly },
+    },
+    {
+      fieldName: 'signupLimit',
+      label: '鎶ュ悕涓婇檺',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '涓嶉檺璇峰~ 0',
+        min: 0,
+        class: 'w-full',
+        disabled: readonly,
+      },
+      rules: 'required',
+      help: '濉� 0 琛ㄧず涓嶉檺鍚嶉',
+    },
+    {
+      fieldName: 'coverUrl',
+      label: '灏侀潰鍥�',
+      component: 'Input',
+      componentProps: { placeholder: '灏侀潰鍥惧湴鍧�锛堥�夊~锛�', disabled: readonly },
+      formItemClass: 'col-span-2',
+    },
+    {
+      fieldName: 'content',
+      label: '娲诲姩璇︽儏',
+      component: 'Textarea',
+      componentProps: {
+        rows: 4,
+        placeholder: '娲诲姩浠嬬粛銆佹敞鎰忎簨椤圭瓑锛堟姤鍚嶉〉鎸夌函鏂囨湰灞曠ず锛�',
+        disabled: readonly,
+      },
+      formItemClass: 'col-span-2',
+    },
+    {
+      fieldName: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      componentProps: { rows: 2, placeholder: '鍐呴儴澶囨敞锛屼笉灞曠ず缁欐垚鍛�', disabled: readonly },
+      formItemClass: 'col-span-2',
+    },
+  ];
+}
+
+/** 娲诲姩鎼滅储琛ㄥ崟 */
+export function useActivitySearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'title',
+      label: '娲诲姩鏍囬',
+      component: 'Input',
+      componentProps: { allowClear: true, placeholder: '璇疯緭鍏ユ椿鍔ㄦ爣棰�' },
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: ACTIVITY_STATUS_OPTIONS,
+        placeholder: '璇烽�夋嫨鐘舵��',
+      },
+    },
+    {
+      fieldName: 'pushStatus',
+      label: '鎺ㄩ�佺姸鎬�',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: PUSH_STATUS_OPTIONS,
+        placeholder: '璇烽�夋嫨鎺ㄩ�佺姸鎬�',
+      },
+    },
+  ];
+}
+
+/** 娲诲姩鍒楄〃瀛楁 */
+export function useActivityGridColumns(): VxeTableGridOptions<WxCpActivityApi.Activity>['columns'] {
+  return [
+    {
+      field: 'title',
+      title: '娲诲姩鏍囬',
+      minWidth: 200,
+      slots: { default: 'title' },
+    },
+    { field: 'location', title: '娲诲姩鍦扮偣', minWidth: 140 },
+    { field: 'startTime', title: '娲诲姩寮�濮�', width: 170 },
+    { field: 'endTime', title: '娲诲姩缁撴潫', width: 170 },
+    {
+      field: 'signupCount',
+      title: '鎶ュ悕/涓婇檺',
+      width: 110,
+      slots: { default: 'signupCount' },
+    },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      width: 90,
+      slots: { default: 'status' },
+    },
+    {
+      field: 'pushStatus',
+      title: '鎺ㄩ��',
+      width: 90,
+      slots: { default: 'pushStatus' },
+    },
+    { field: 'createTime', title: '鍒涘缓鏃堕棿', width: 170 },
+    { title: '鎿嶄綔', width: 300, fixed: 'right', slots: { default: 'actions' } },
+  ];
+}
+
+/** 鎶ュ悕璁板綍鎼滅储琛ㄥ崟 */
+export function useSignupSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'name',
+      label: '濮撳悕',
+      component: 'Input',
+      componentProps: { allowClear: true, placeholder: '璇疯緭鍏ュ鍚�' },
+    },
+    {
+      fieldName: 'mobile',
+      label: '鎵嬫満鍙�',
+      component: 'Input',
+      componentProps: { allowClear: true, placeholder: '璇疯緭鍏ユ墜鏈哄彿' },
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: SIGNUP_STATUS_OPTIONS,
+        placeholder: '璇烽�夋嫨鐘舵��',
+      },
+    },
+  ];
+}
+
+/** 鎶ュ悕璁板綍鍒楄〃瀛楁 */
+export function useSignupGridColumns(): VxeTableGridOptions<WxCpActivityApi.Signup>['columns'] {
+  return [
+    { field: 'name', title: '濮撳悕', minWidth: 100 },
+    { field: 'mobile', title: '鎵嬫満鍙�', width: 130 },
+    { field: 'deptName', title: '閮ㄩ棬', minWidth: 120 },
+    { field: 'wxUserId', title: '浼佷笟寰俊璐﹀彿', minWidth: 110 },
+    {
+      field: 'content',
+      title: '鍙備笌璇存槑',
+      minWidth: 160,
+      slots: { default: 'content' },
+    },
+    {
+      field: 'attachment',
+      title: '闄勪欢',
+      width: 80,
+      slots: { default: 'attachment' },
+    },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      width: 90,
+      slots: { default: 'status' },
+    },
+    { field: 'signupTime', title: '鎶ュ悕鏃堕棿', width: 170 },
+    { field: 'checkInTime', title: '绛惧埌鏃堕棿', width: 170 },
+    { title: '鎿嶄綔', width: 150, fixed: 'right', slots: { default: 'actions' } },
+  ];
+}
+
diff --git a/src/views/wxcp/activity/index.vue b/src/views/wxcp/activity/index.vue
new file mode 100644
index 0000000..9370cbb
--- /dev/null
+++ b/src/views/wxcp/activity/index.vue
@@ -0,0 +1,266 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { WxCpActivityApi } from '#/api/wxcp/activity';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { Button, message, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  cancelActivity,
+  deleteActivity,
+  finishActivity,
+  getActivityPage,
+  publishActivity,
+} from '#/api/wxcp/activity';
+
+import {
+  ACTIVITY_STATUS,
+  ACTIVITY_STATUS_OPTIONS,
+  toLabelMap,
+} from '../data';
+import {
+  useActivityGridColumns,
+  useActivitySearchFormSchema,
+} from './data';
+import ActivityForm from './modules/form.vue';
+import PushModal from './modules/push-modal.vue';
+import QrCodeModal from './modules/qrcode-modal.vue';
+import SignupListModal from './modules/signup-list.vue';
+import StatsModal from './modules/stats-modal.vue';
+
+defineOptions({ name: 'WxCpActivityList' });
+
+const statusLabelMap = toLabelMap(ACTIVITY_STATUS_OPTIONS);
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: ActivityForm,
+  destroyOnClose: true,
+});
+const [PushModalComp, pushModalApi] = useVbenModal({
+  connectedComponent: PushModal,
+  destroyOnClose: true,
+});
+const [QrCodeModalComp, qrCodeModalApi] = useVbenModal({
+  connectedComponent: QrCodeModal,
+  destroyOnClose: true,
+});
+const [StatsModalComp, statsModalApi] = useVbenModal({
+  connectedComponent: StatsModal,
+  destroyOnClose: true,
+});
+const [SignupModalComp, signupModalApi] = useVbenModal({
+  connectedComponent: SignupListModal,
+  destroyOnClose: true,
+});
+
+function handleRefresh() {
+  gridApi.query();
+}
+
+function handleCreate() {
+  formModalApi.setData({ formType: 'create' }).open();
+}
+
+function handleDetail(row: WxCpActivityApi.Activity) {
+  formModalApi.setData({ formType: 'detail', id: row.id }).open();
+}
+
+function handleEdit(row: WxCpActivityApi.Activity) {
+  formModalApi.setData({ formType: 'update', id: row.id }).open();
+}
+
+async function handleDelete(row: WxCpActivityApi.Activity) {
+  await deleteActivity(row.id!);
+  message.success('鍒犻櫎鎴愬姛');
+  handleRefresh();
+}
+
+async function handlePublish(row: WxCpActivityApi.Activity) {
+  await publishActivity(row.id!);
+  message.success('鍙戝竷鎴愬姛锛岀幇鍦ㄥ彲浠ユ帹閫佸拰鐢熸垚浜岀淮鐮佷簡');
+  handleRefresh();
+}
+
+async function handleFinish(row: WxCpActivityApi.Activity) {
+  await finishActivity(row.id!);
+  message.success('宸茬粨鏉�');
+  handleRefresh();
+}
+
+async function handleCancel(row: WxCpActivityApi.Activity) {
+  await cancelActivity(row.id!);
+  message.success('宸插彇娑�');
+  handleRefresh();
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useActivitySearchFormSchema(),
+  },
+  gridOptions: {
+    columns: useActivityGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getActivityPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: { keyField: 'id', isHover: true },
+    toolbarConfig: { refresh: true, search: true },
+  } as VxeTableGridOptions<WxCpActivityApi.Activity>,
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormModal @success="handleRefresh" />
+    <PushModalComp @success="handleRefresh" />
+    <QrCodeModalComp />
+    <StatsModalComp />
+    <SignupModalComp @success="handleRefresh" />
+
+    <Grid table-title="浼佷笟寰俊娲诲姩鍒楄〃">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: '鏂板缓娲诲姩',
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['wxcp:activity:create'],
+              onClick: handleCreate,
+            },
+          ]"
+        />
+      </template>
+
+      <template #title="{ row }">
+        <Button type="link" class="p-0" @click="handleDetail(row)">
+          {{ row.title }}
+        </Button>
+      </template>
+
+      <template #signupCount="{ row }">
+        <span>
+          {{ row.signupCount ?? 0 }} /
+          {{ row.signupLimit && row.signupLimit > 0 ? row.signupLimit : '涓嶉檺' }}
+        </span>
+      </template>
+
+      <template #status="{ row }">
+        <Tag
+          :color="
+            row.status === ACTIVITY_STATUS.PUBLISHED
+              ? 'green'
+              : row.status === ACTIVITY_STATUS.DRAFT
+                ? 'default'
+                : row.status === ACTIVITY_STATUS.FINISHED
+                  ? 'blue'
+                  : 'red'
+          "
+        >
+          {{ statusLabelMap[row.status!] || '-' }}
+        </Tag>
+      </template>
+
+      <template #pushStatus="{ row }">
+        <Tag :color="row.pushStatus === 1 ? 'green' : 'default'">
+          {{ row.pushStatus === 1 ? '宸叉帹閫�' : '鏈帹閫�' }}
+        </Tag>
+      </template>
+
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '缂栬緫',
+              type: 'link',
+              icon: ACTION_ICON.EDIT,
+              auth: ['wxcp:activity:update'],
+              ifShow: row.status !== ACTIVITY_STATUS.FINISHED && row.status !== ACTIVITY_STATUS.CANCELED,
+              onClick: handleEdit.bind(null, row),
+            },
+            {
+              label: '鍙戝竷',
+              type: 'link',
+              auth: ['wxcp:activity:update'],
+              ifShow: row.status === ACTIVITY_STATUS.DRAFT,
+              popConfirm: {
+                title: '鍙戝竷鍚庢垚鍛樺嵆鍙�氳繃浜岀淮鐮佹姤鍚嶏紝纭鍙戝竷锛�',
+                confirm: handlePublish.bind(null, row),
+              },
+            },
+            {
+              label: '鎺ㄩ��',
+              type: 'link',
+              auth: ['wxcp:activity:push'],
+              ifShow: row.status === ACTIVITY_STATUS.PUBLISHED,
+              onClick: () => pushModalApi.setData({ activity: row }).open(),
+            },
+            {
+              label: '浜岀淮鐮�',
+              type: 'link',
+              ifShow: row.status === ACTIVITY_STATUS.PUBLISHED,
+              onClick: () => qrCodeModalApi.setData({ activity: row }).open(),
+            },
+            {
+              label: '缁熻',
+              type: 'link',
+              auth: ['wxcp:activity:query'],
+              onClick: () => statsModalApi.setData({ activity: row }).open(),
+            },
+            {
+              label: '鎶ュ悕璁板綍',
+              type: 'link',
+              auth: ['wxcp:signup:query'],
+              onClick: () => signupModalApi.setData({ activity: row }).open(),
+            },
+          ]"
+          :drop-down-actions="[
+            {
+              label: '缁撴潫',
+              type: 'link',
+              auth: ['wxcp:activity:update'],
+              ifShow: row.status === ACTIVITY_STATUS.PUBLISHED,
+              popConfirm: {
+                title: '缁撴潫鍚庢垚鍛樺皢鏃犳硶鍐嶆姤鍚嶏紝纭缁撴潫锛�',
+                confirm: handleFinish.bind(null, row),
+              },
+            },
+            {
+              label: '鍙栨秷',
+              type: 'link',
+              auth: ['wxcp:activity:update'],
+              ifShow: row.status === ACTIVITY_STATUS.DRAFT || row.status === ACTIVITY_STATUS.PUBLISHED,
+              popConfirm: {
+                title: `纭鍙栨秷娲诲姩銆�${row.title}銆戯紵`,
+                confirm: handleCancel.bind(null, row),
+              },
+            },
+            {
+              label: '鍒犻櫎',
+              type: 'link',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              auth: ['wxcp:activity:delete'],
+              ifShow: row.status === ACTIVITY_STATUS.DRAFT,
+              popConfirm: {
+                title: `纭鍒犻櫎娲诲姩銆�${row.title}銆戯紵`,
+                confirm: handleDelete.bind(null, row),
+              },
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/wxcp/activity/modules/form.vue b/src/views/wxcp/activity/modules/form.vue
new file mode 100644
index 0000000..05d379b
--- /dev/null
+++ b/src/views/wxcp/activity/modules/form.vue
@@ -0,0 +1,116 @@
+<script lang="ts" setup>
+import type { WxCpActivityApi } from '#/api/wxcp/activity';
+
+import type { ActivityFormType } from '../data';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createActivity,
+  getActivity,
+  updateActivity,
+} from '#/api/wxcp/activity';
+
+import { useActivityFormSchema } from '../data';
+
+defineOptions({ name: 'WxCpActivityForm' });
+
+const emit = defineEmits<{ success: [] }>();
+
+const formType = ref<ActivityFormType>('create');
+const id = ref<number>();
+const submitting = ref(false);
+
+const isEdit = computed(() => formType.value !== 'detail');
+const title = computed(() => {
+  if (formType.value === 'detail') return '鏌ョ湅娲诲姩';
+  if (id.value) return '缂栬緫娲诲姩';
+  return '鏂板缓娲诲姩';
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: { class: 'w-full' },
+    formItemClass: 'col-span-1',
+    labelWidth: 90,
+  },
+  layout: 'horizontal',
+  schema: [],
+  showDefaultActions: false,
+  wrapperClass: 'grid-cols-2',
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      id.value = undefined;
+      return;
+    }
+    const data = modalApi.getData<{ formType: ActivityFormType; id?: number }>();
+    formType.value = data.formType;
+    id.value = data.id;
+    formApi.setState({ schema: useActivityFormSchema(formType.value) });
+    formApi.setDisabled(formType.value === 'detail');
+    if (data.id) {
+      modalApi.lock();
+      try {
+        const detail = await getActivity(data.id);
+        await formApi.setValues(detail as unknown as Record<string, unknown>);
+      } finally {
+        modalApi.unlock();
+      }
+    } else {
+      await formApi.resetForm();
+    }
+  },
+});
+
+async function handleSubmit() {
+  const { valid } = await formApi.validate();
+  if (!valid) {
+    return;
+  }
+  submitting.value = true;
+  try {
+    const data = (await formApi.getValues()) as WxCpActivityApi.Activity;
+    if (id.value) {
+      await updateActivity({ ...data, id: id.value });
+      message.success('淇敼鎴愬姛');
+    } else {
+      await createActivity(data);
+      message.success('鍒涘缓鎴愬姛锛岃鍦ㄥ垪琛ㄤ腑鍙戝竷鍚庡啀鎺ㄩ��');
+    }
+    await modalApi.close();
+    emit('success');
+  } catch (error: unknown) {
+    message.error((error as Error)?.message || '鎻愪氦澶辫触');
+  } finally {
+    submitting.value = false;
+  }
+}
+</script>
+
+<template>
+  <Modal :title="title" class="w-3/5">
+    <Form class="mx-4" />
+
+    <template #footer>
+      <div class="flex justify-end gap-2">
+        <Button @click="modalApi.close()">鍙栨秷</Button>
+        <Button
+          v-if="isEdit"
+          type="primary"
+          :loading="submitting"
+          @click="handleSubmit"
+        >
+          纭
+        </Button>
+        <Button v-else type="primary" @click="modalApi.close()">鍏抽棴</Button>
+      </div>
+    </template>
+  </Modal>
+</template>
diff --git a/src/views/wxcp/activity/modules/push-modal.vue b/src/views/wxcp/activity/modules/push-modal.vue
new file mode 100644
index 0000000..56ef89d
--- /dev/null
+++ b/src/views/wxcp/activity/modules/push-modal.vue
@@ -0,0 +1,105 @@
+<script lang="ts" setup>
+import type { WxCpActivityApi } from '#/api/wxcp/activity';
+
+import { ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { Alert, Button, message } from 'ant-design-vue';
+
+import { pushActivity } from '#/api/wxcp/activity';
+
+import PushScopePicker from '../../components/push-scope-picker.vue';
+import { PUSH_SCOPE } from '../../data';
+
+defineOptions({ name: 'WxCpActivityPushModal' });
+
+const emit = defineEmits<{ success: [] }>();
+
+const activity = ref<WxCpActivityApi.Activity>({});
+const scope = ref<number>(PUSH_SCOPE.ALL);
+const target = ref<string>('');
+const pushing = ref(false);
+const resultMessage = ref('');
+const resultSuccess = ref(true);
+
+const [Modal, modalApi] = useVbenModal({
+  onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      activity.value = {};
+      resultMessage.value = '';
+      return;
+    }
+    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
+    activity.value = data.activity ?? {};
+    // 娌跨敤娲诲姩涓婂凡閰嶇疆鐨勬帹閫佽寖鍥翠笌鐩爣锛岄伩鍏嶆瘡娆¢兘瑕侀噸閫�
+    scope.value = data.activity?.pushScope ?? PUSH_SCOPE.ALL;
+    target.value = data.activity?.pushTarget ?? '';
+  },
+});
+
+async function handlePush() {
+  if (!activity.value.id) {
+    return;
+  }
+  if (scope.value !== PUSH_SCOPE.ALL && !target.value.trim()) {
+    message.warning('閫夋嫨銆屾寚瀹氶儴闂ㄣ�嶆垨銆屾寚瀹氭垚鍛樸�嶆椂锛岃鍏堥�夋嫨鎺ㄩ�佺洰鏍�');
+    return;
+  }
+  pushing.value = true;
+  resultMessage.value = '';
+  try {
+    const res = await pushActivity({
+      id: activity.value.id,
+      pushScope: scope.value,
+      pushTarget: scope.value === PUSH_SCOPE.ALL ? '' : target.value,
+    });
+    resultSuccess.value = !!res?.success;
+    if (res?.success) {
+      message.success('鎺ㄩ�佹垚鍔�');
+      emit('success');
+      await modalApi.close();
+    } else {
+      // 鎺ㄩ�佸け璐ヤ笉鎶涘紓甯革紝鎶婁紒涓氬井淇$粰鐨勫師鍥犵洿鎺ュ睍绀虹粰鐢ㄦ埛
+      resultMessage.value = res?.message || '鎺ㄩ�佸け璐�';
+    }
+  } catch (error: unknown) {
+    resultSuccess.value = false;
+    resultMessage.value = (error as Error)?.message || '鎺ㄩ�佸け璐�';
+  } finally {
+    pushing.value = false;
+  }
+}
+</script>
+
+<template>
+  <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>
+      </div>
+
+      <div class="mb-2 text-sm font-medium">鎺ㄩ�佽寖鍥�</div>
+      <PushScopePicker v-model:scope="scope" v-model:target="target" />
+
+      <Alert
+        v-if="resultMessage"
+        class="mt-4"
+        :type="resultSuccess ? 'success' : 'error'"
+        :message="resultMessage"
+        show-icon
+      />
+    </div>
+
+    <template #footer>
+      <div class="flex justify-end gap-2">
+        <Button @click="modalApi.close()">鍙栨秷</Button>
+        <Button type="primary" :loading="pushing" @click="handlePush">
+          纭鎺ㄩ��
+        </Button>
+      </div>
+    </template>
+  </Modal>
+</template>
diff --git a/src/views/wxcp/activity/modules/qrcode-modal.vue b/src/views/wxcp/activity/modules/qrcode-modal.vue
new file mode 100644
index 0000000..841029c
--- /dev/null
+++ b/src/views/wxcp/activity/modules/qrcode-modal.vue
@@ -0,0 +1,148 @@
+<script lang="ts" setup>
+import type { WxCpActivityApi } from '#/api/wxcp/activity';
+
+import { onUnmounted, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { Button, message, Spin } from 'ant-design-vue';
+
+import {
+  getActivityQrCodeBlob,
+  getActivitySignupUrl,
+} from '#/api/wxcp/activity';
+
+defineOptions({ name: 'WxCpActivityQrCodeModal' });
+
+const activity = ref<WxCpActivityApi.Activity>({});
+const qrCodeUrl = ref('');
+const signupUrl = ref('');
+const loading = ref(false);
+const loadError = ref('');
+
+/** 閲婃斁涓婁竴娆$殑 objectURL锛岄伩鍏嶅唴瀛樻硠婕� */
+function releaseObjectUrl() {
+  if (qrCodeUrl.value) {
+    URL.revokeObjectURL(qrCodeUrl.value);
+    qrCodeUrl.value = '';
+  }
+}
+
+async function loadQrCode(id: number) {
+  loading.value = true;
+  loadError.value = '';
+  releaseObjectUrl();
+  try {
+    // 浜岀淮鐮佹帴鍙h繑鍥� PNG 浜岃繘鍒讹紝涓� <img> 甯︿笉浜嗚姹傚ご锛屽洜姝よ蛋 blob + objectURL
+    const res = (await getActivityQrCodeBlob(id, 400)) as unknown as {
+      data?: Blob;
+    };
+    const blob = res?.data instanceof Blob ? res.data : (res as unknown as Blob);
+    if (!(blob instanceof Blob)) {
+      throw new Error('浜岀淮鐮佽繑鍥炴牸寮忓紓甯�');
+    }
+    qrCodeUrl.value = URL.createObjectURL(blob);
+  } catch (error: unknown) {
+    loadError.value = (error as Error)?.message || '浜岀淮鐮佸姞杞藉け璐�';
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function loadSignupUrl(id: number) {
+  try {
+    signupUrl.value = await getActivitySignupUrl(id);
+  } catch {
+    // 鎶ュ悕閾炬帴鍙栦笉鍒颁笉褰卞搷浜岀淮鐮佸睍绀猴紝闈欓粯澶勭悊
+    signupUrl.value = '';
+  }
+}
+
+function handleDownload() {
+  if (!qrCodeUrl.value || !activity.value.id) {
+    return;
+  }
+  const link = document.createElement('a');
+  link.href = qrCodeUrl.value;
+  link.download = `娲诲姩鎶ュ悕鐮�-${activity.value.title || activity.value.id}.png`;
+  document.body.append(link);
+  link.click();
+  link.remove();
+}
+
+async function handleCopyLink() {
+  if (!signupUrl.value) {
+    message.warning('鏆傛棤鎶ュ悕閾炬帴');
+    return;
+  }
+  try {
+    await navigator.clipboard.writeText(signupUrl.value);
+    message.success('鎶ュ悕閾炬帴宸插鍒�');
+  } catch {
+    message.warning('澶嶅埗澶辫触锛岃鎵嬪姩閫変腑閾炬帴澶嶅埗');
+  }
+}
+
+const [Modal, modalApi] = useVbenModal({
+  onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      activity.value = {};
+      signupUrl.value = '';
+      loadError.value = '';
+      releaseObjectUrl();
+      return;
+    }
+    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
+    activity.value = data.activity ?? {};
+    if (activity.value.id) {
+      loadQrCode(activity.value.id);
+      loadSignupUrl(activity.value.id);
+    }
+  },
+});
+
+onUnmounted(releaseObjectUrl);
+</script>
+
+<template>
+  <Modal title="娲诲姩鎶ュ悕浜岀淮鐮�" class="w-2/5">
+    <div class="flex flex-col items-center px-4">
+      <div class="mb-4 text-center text-sm text-gray-500">
+        {{ activity.title }}
+      </div>
+
+      <Spin :spinning="loading">
+        <div
+          class="flex size-70 items-center justify-center rounded border border-dashed border-gray-300"
+        >
+          <img
+            v-if="qrCodeUrl"
+            :src="qrCodeUrl"
+            alt="娲诲姩鎶ュ悕浜岀淮鐮�"
+            class="size-65 object-contain"
+          />
+          <div v-else class="px-6 text-center text-sm text-gray-400">
+            {{ loadError || '浜岀淮鐮佸姞杞戒腑鈥�' }}
+          </div>
+        </div>
+      </Spin>
+
+      <div class="mt-4 w-full text-xs text-gray-400 break-all">
+        <div v-if="signupUrl">鎶ュ悕閾炬帴锛歿{ signupUrl }}</div>
+        <div class="mt-2">
+          鎴愬憳鐢ㄤ紒涓氬井淇℃壂涓�鎵嵆鍙墦寮�鎶ュ悕椤靛苟鎻愪氦鎶ュ悕銆�
+        </div>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="flex justify-end gap-2">
+        <Button :disabled="!signupUrl" @click="handleCopyLink">
+          澶嶅埗鎶ュ悕閾炬帴
+        </Button>
+        <Button type="primary" :disabled="!qrCodeUrl" @click="handleDownload">
+          涓嬭浇浜岀淮鐮�
+        </Button>
+      </div>
+    </template>
+  </Modal>
+</template>
diff --git a/src/views/wxcp/activity/modules/signup-list.vue b/src/views/wxcp/activity/modules/signup-list.vue
new file mode 100644
index 0000000..2c1b953
--- /dev/null
+++ b/src/views/wxcp/activity/modules/signup-list.vue
@@ -0,0 +1,242 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { WxCpActivityApi } from '#/api/wxcp/activity';
+
+import { ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import {
+  Button,
+  Empty,
+  message,
+  Modal as AntModal,
+  Spin,
+  Tag,
+  Tooltip,
+} from 'ant-design-vue';
+
+import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  cancelSignup,
+  checkInSignup,
+  getSignupAttachments,
+  getSignupPage,
+} from '#/api/wxcp/activity';
+
+import { SIGNUP_STATUS, SIGNUP_STATUS_OPTIONS, toLabelMap } from '../../data';
+import { useSignupGridColumns, useSignupSearchFormSchema } from '../data';
+
+defineOptions({ name: 'WxCpActivitySignupList' });
+
+const emit = defineEmits<{ success: [] }>();
+
+const statusLabelMap = toLabelMap(SIGNUP_STATUS_OPTIONS);
+
+const activity = ref<WxCpActivityApi.Activity>({});
+
+/** 闄勪欢鏌ョ湅 */
+const attachmentVisible = ref(false);
+const attachmentLoading = ref(false);
+const attachments = ref<WxCpActivityApi.SignupAttachment[]>([]);
+const attachmentTitle = ref('');
+
+async function handleViewAttachments(row: WxCpActivityApi.Signup) {
+  if (!row.id) {
+    return;
+  }
+  attachmentTitle.value = `${row.name || ''} 鎻愪氦鐨勯檮浠禶;
+  attachmentVisible.value = true;
+  attachmentLoading.value = true;
+  attachments.value = [];
+  try {
+    attachments.value = (await getSignupAttachments(row.id)) ?? [];
+  } catch (error: unknown) {
+    message.error((error as Error)?.message || '闄勪欢鍔犺浇澶辫触');
+  } finally {
+    attachmentLoading.value = false;
+  }
+}
+
+function isImage(item: WxCpActivityApi.SignupAttachment) {
+  return (item.contentType || '').startsWith('image/');
+}
+
+function formatSize(bytes?: number) {
+  if (!bytes || bytes <= 0) {
+    return '';
+  }
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+async function handleCancel(row: WxCpActivityApi.Signup) {
+  await cancelSignup(row.id!);
+  message.success('宸插彇娑堣鎶ュ悕');
+  gridApi.query();
+  emit('success');
+}
+
+async function handleCheckIn(row: WxCpActivityApi.Signup) {
+  await checkInSignup(row.id!);
+  message.success('绛惧埌鎴愬姛');
+  gridApi.query();
+  emit('success');
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useSignupSearchFormSchema(),
+  },
+  gridOptions: {
+    columns: useSignupGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          if (!activity.value.id) {
+            return { list: [], total: 0 };
+          }
+          return await getSignupPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            activityId: activity.value.id,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: { keyField: 'id', isHover: true },
+    toolbarConfig: { refresh: true, search: true },
+  } as VxeTableGridOptions<WxCpActivityApi.Signup>,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      activity.value = {};
+      return;
+    }
+    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
+    activity.value = data.activity ?? {};
+    // 娲诲姩ID灏辩华鍚庡啀鏌ヨ
+    gridApi.query();
+  },
+});
+</script>
+
+<template>
+  <Modal :title="`鎶ュ悕璁板綍 - ${activity.title || ''}`" class="w-11/12">
+    <div class="px-4">
+      <Grid table-title="鎶ュ悕璁板綍">
+        <template #content="{ row }">
+          <Tooltip v-if="row.content" :title="row.content">
+            <span class="line-clamp-2">{{ row.content }}</span>
+          </Tooltip>
+          <span v-else class="text-gray-400">-</span>
+        </template>
+
+        <template #attachment="{ row }">
+          <Button type="link" class="p-0" @click="handleViewAttachments(row)">
+            鏌ョ湅
+          </Button>
+        </template>
+
+        <template #status="{ row }">
+          <Tag
+            :color="
+              row.status === SIGNUP_STATUS.CHECKED_IN
+                ? 'green'
+                : row.status === SIGNUP_STATUS.CANCELED
+                  ? 'default'
+                  : 'blue'
+            "
+          >
+            {{ statusLabelMap[row.status!] || '-' }}
+          </Tag>
+        </template>
+
+        <template #actions="{ row }">
+          <TableAction
+            :actions="[
+              {
+                label: '绛惧埌',
+                type: 'link',
+                auth: ['wxcp:signup:update'],
+                ifShow: row.status === SIGNUP_STATUS.SIGNED_UP,
+                popConfirm: {
+                  title: `纭涓恒��${row.name}銆戠鍒帮紵`,
+                  confirm: handleCheckIn.bind(null, row),
+                },
+              },
+              {
+                label: '鍙栨秷鎶ュ悕',
+                type: 'link',
+                danger: true,
+                auth: ['wxcp:signup:update'],
+                ifShow: row.status !== SIGNUP_STATUS.CANCELED,
+                popConfirm: {
+                  title: `纭鍙栨秷銆�${row.name}銆戠殑鎶ュ悕锛熷彇娑堝悗鎶ュ悕浜烘暟浼氱浉搴斿噺灏戙�俙,
+                  confirm: handleCancel.bind(null, row),
+                },
+              },
+            ]"
+          />
+        </template>
+      </Grid>
+    </div>
+
+    <template #footer>
+      <div class="flex justify-end">
+        <Button @click="modalApi.close()">鍏抽棴</Button>
+      </div>
+    </template>
+
+    <!-- 闄勪欢鏌ョ湅 -->
+    <AntModal
+      v-model:open="attachmentVisible"
+      :title="attachmentTitle"
+      :footer="null"
+      width="640px"
+    >
+      <Spin :spinning="attachmentLoading">
+        <div v-if="attachments.length" class="space-y-3">
+          <div
+            v-for="item in attachments"
+            :key="item.id"
+            class="rounded border border-gray-200 p-3 dark:border-gray-700"
+          >
+            <div class="mb-2 text-sm break-all">
+              {{ item.originalFilename || item.name || `闄勪欢${item.id}` }}
+              <span class="ml-2 text-xs text-gray-400">
+                {{ formatSize(item.byteSize) }}
+              </span>
+            </div>
+            <a
+              v-if="isImage(item)"
+              :href="item.previewURL || item.url"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              <img
+                :src="item.previewURL || item.url"
+                alt="闄勪欢棰勮"
+                class="max-h-40 rounded object-contain"
+              />
+            </a>
+            <a
+              :href="item.downloadURL || item.url"
+              target="_blank"
+              rel="noopener noreferrer"
+              class="text-xs text-blue-500 hover:underline"
+            >
+              涓嬭浇
+            </a>
+          </div>
+        </div>
+        <Empty v-else-if="!attachmentLoading" description="璇ユ姤鍚嶆湭涓婁紶闄勪欢" />
+      </Spin>
+    </AntModal>
+  </Modal>
+</template>
diff --git a/src/views/wxcp/activity/modules/stats-modal.vue b/src/views/wxcp/activity/modules/stats-modal.vue
new file mode 100644
index 0000000..e72ad17
--- /dev/null
+++ b/src/views/wxcp/activity/modules/stats-modal.vue
@@ -0,0 +1,163 @@
+<script lang="ts" setup>
+import type { WxCpActivityApi } from '#/api/wxcp/activity';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { Progress, Spin } from 'ant-design-vue';
+
+import { getActivityStats } from '#/api/wxcp/activity';
+
+defineOptions({ name: 'WxCpActivityStatsModal' });
+
+const activity = ref<WxCpActivityApi.Activity>({});
+const stats = ref<null | WxCpActivityApi.ActivityStats>(null);
+const loading = ref(false);
+const loadError = ref('');
+
+/** 涓嶉檺鍚嶉鏃朵笉灞曠ず杩涘害鏉� */
+const hasLimit = computed(
+  () => !!stats.value?.signupLimit && stats.value.signupLimit > 0,
+);
+
+async function loadStats(id: number) {
+  loading.value = true;
+  loadError.value = '';
+  try {
+    stats.value = await getActivityStats(id);
+  } catch (error: unknown) {
+    loadError.value = (error as Error)?.message || '缁熻鍔犺浇澶辫触';
+  } finally {
+    loading.value = false;
+  }
+}
+
+const [Modal, modalApi] = useVbenModal({
+  onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      stats.value = null;
+      activity.value = {};
+      loadError.value = '';
+      return;
+    }
+    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
+    activity.value = data.activity ?? {};
+    if (activity.value.id) {
+      loadStats(activity.value.id);
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal title="鎶ュ悕缁熻" class="w-3/5">
+    <Spin :spinning="loading">
+      <div class="px-4">
+        <div class="mb-3 text-sm text-gray-500">
+          {{ stats?.activityTitle || activity.title }}
+        </div>
+
+        <div v-if="loadError" class="py-6 text-center text-sm text-red-500">
+          {{ loadError }}
+        </div>
+
+        <template v-else-if="stats">
+          <!-- 鍏抽敭鎸囨爣 -->
+          <div class="mb-4 grid grid-cols-4 gap-3">
+            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
+              <div class="text-xl font-semibold">
+                {{ stats.totalSignup }}
+              </div>
+              <div class="mt-1 text-xs text-gray-500">鏈夋晥鎶ュ悕</div>
+            </div>
+            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
+              <div class="text-xl font-semibold">
+                {{ stats.signedUpCount }}
+              </div>
+              <div class="mt-1 text-xs text-gray-500">鏈鍒�</div>
+            </div>
+            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
+              <div class="text-xl font-semibold">
+                {{ stats.checkedInCount }}
+              </div>
+              <div class="mt-1 text-xs text-gray-500">宸茬鍒�</div>
+            </div>
+            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
+              <div class="text-xl font-semibold">
+                {{ stats.canceledCount }}
+              </div>
+              <div class="mt-1 text-xs text-gray-500">宸插彇娑�</div>
+            </div>
+          </div>
+
+          <!-- 杩涘害 -->
+          <div v-if="hasLimit" class="mb-5">
+            <div class="mb-1 flex justify-between text-xs text-gray-500">
+              <span>鎶ュ悕杩涘害</span>
+              <span>
+                {{ stats.totalSignup }} / {{ stats.signupLimit }}
+                锛堝墿浣� {{ stats.remaining ?? 0 }}锛�
+              </span>
+            </div>
+            <Progress :percent="stats.signupPercent ?? 0" />
+          </div>
+          <div v-else class="mb-5 text-xs text-gray-500">
+            鏈娲诲姩涓嶉檺鍚嶉锛屽綋鍓嶅凡鎶ュ悕 {{ stats.totalSignup }} 浜�
+          </div>
+
+          <!-- 鍒嗗竷 -->
+          <div class="grid grid-cols-3 gap-4 text-sm">
+            <div>
+              <div class="mb-2 font-medium">鎸夐儴闂�</div>
+              <div v-if="stats.deptStats?.length" class="space-y-1">
+                <div
+                  v-for="item in stats.deptStats"
+                  :key="item.name"
+                  class="flex justify-between text-gray-600"
+                >
+                  <span class="truncate">{{ item.name }}</span>
+                  <span>{{ item.count }}</span>
+                </div>
+              </div>
+              <div v-else class="text-xs text-gray-400">鏆傛棤鏁版嵁</div>
+            </div>
+
+            <div>
+              <div class="mb-2 font-medium">鎸夋姤鍚嶆棩鏈�</div>
+              <div v-if="stats.dateStats?.length" class="space-y-1">
+                <div
+                  v-for="item in stats.dateStats"
+                  :key="item.name"
+                  class="flex justify-between text-gray-600"
+                >
+                  <span>{{ item.name }}</span>
+                  <span>{{ item.count }}</span>
+                </div>
+              </div>
+              <div v-else class="text-xs text-gray-400">鏆傛棤鏁版嵁</div>
+            </div>
+
+            <div>
+              <div class="mb-2 font-medium">鎸夋潵婧�</div>
+              <div v-if="stats.sourceStats?.length" class="space-y-1">
+                <div
+                  v-for="item in stats.sourceStats"
+                  :key="item.name"
+                  class="flex justify-between text-gray-600"
+                >
+                  <span>{{ item.name }}</span>
+                  <span>{{ item.count }}</span>
+                </div>
+              </div>
+              <div v-else class="text-xs text-gray-400">鏆傛棤鏁版嵁</div>
+            </div>
+          </div>
+
+          <div class="mt-4 text-xs text-gray-400">
+            绛惧埌鐜� {{ stats.checkInPercent }}%锛堟寜鏈夋晥鎶ュ悕鏁拌绠楋紝涓嶅惈宸插彇娑堬級
+          </div>
+        </template>
+      </div>
+    </Spin>
+  </Modal>
+</template>
diff --git a/src/views/wxcp/components/push-scope-picker.vue b/src/views/wxcp/components/push-scope-picker.vue
new file mode 100644
index 0000000..51d5e99
--- /dev/null
+++ b/src/views/wxcp/components/push-scope-picker.vue
@@ -0,0 +1,172 @@
+<script lang="ts" setup>
+import type { WxCpMemberApi } from '#/api/wxcp/member';
+
+import { computed, onMounted, ref, watch } from 'vue';
+
+import { Alert, Radio, Select, Spin } from 'ant-design-vue';
+
+import {
+  getWxCpDepartmentList,
+  getWxCpMemberList,
+} from '#/api/wxcp/member';
+
+import { PUSH_SCOPE_OPTIONS, PUSH_SCOPE } from '../data';
+
+defineOptions({ name: 'WxCpPushScopePicker' });
+
+interface Props {
+  /** 鎺ㄩ�佽寖鍥达細0鍏ㄥ憳 1鎸囧畾閮ㄩ棬 2鎸囧畾鎴愬憳 */
+  scope?: number;
+  /** 鎺ㄩ�佺洰鏍囷紝閫楀彿鍒嗛殧锛涜寖鍥存槸鍏ㄥ憳鏃舵棤鎰忎箟 */
+  target?: string;
+  disabled?: boolean;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  scope: PUSH_SCOPE.ALL,
+  target: '',
+  disabled: false,
+});
+
+const emit = defineEmits<{
+  'update:scope': [value: number];
+  'update:target': [value: string];
+}>();
+
+/** 閫氳褰曟暟鎹細鍙湪棣栨闇�瑕佹椂鎷夊彇锛岄伩鍏嶆瘡娆℃墦寮�寮圭獥閮芥墦浼佷笟寰俊鎺ュ彛 */
+const departments = ref<WxCpMemberApi.Department[]>([]);
+const members = ref<WxCpMemberApi.Member[]>([]);
+const loading = ref(false);
+const loaded = ref(false);
+const loadError = ref('');
+
+async function ensureContacts() {
+  if (loaded.value || loading.value) {
+    return;
+  }
+  loading.value = true;
+  loadError.value = '';
+  try {
+    const [deptList, memberList] = await Promise.all([
+      getWxCpDepartmentList(),
+      getWxCpMemberList({ fetchChild: true }),
+    ]);
+    departments.value = deptList ?? [];
+    members.value = memberList ?? [];
+    loaded.value = true;
+  } catch (error: unknown) {
+    loadError.value =
+      (error as Error)?.message || '鍔犺浇浼佷笟寰俊閫氳褰曞け璐ワ紝璇风◢鍚庨噸璇�';
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 閫夊埌闇�瑕侀�氳褰曠殑鑼冨洿鏃舵墠鍔犺浇
+watch(
+  () => props.scope,
+  (scope) => {
+    if (scope === PUSH_SCOPE.PARTY || scope === PUSH_SCOPE.USER) {
+      ensureContacts();
+    }
+  },
+);
+
+onMounted(() => {
+  if (props.scope === PUSH_SCOPE.PARTY || props.scope === PUSH_SCOPE.USER) {
+    ensureContacts();
+  }
+});
+
+const deptOptions = computed(() =>
+  departments.value.map((d) => ({ label: d.name || `閮ㄩ棬${d.id}`, value: d.id })),
+);
+
+const memberOptions = computed(() =>
+  members.value.map((m) => ({
+    label: m.name ? `${m.name}锛�${m.userId}锛塦 : m.userId,
+    value: m.userId,
+  })),
+);
+
+/** 鐩爣瀛楃涓� <-> 鏁扮粍 */
+const selected = computed<string[] | number[]>({
+  get() {
+    const raw = (props.target || '').trim();
+    if (!raw) {
+      return [];
+    }
+    const parts = raw
+      .split(/[,锛宂/)
+      .map((s) => s.trim())
+      .filter(Boolean);
+    // 閮ㄩ棬鐢ㄦ暟瀛楋紝鎴愬憳鐢ㄥ瓧绗︿覆锛堟垚鍛樿处鍙锋槸 LiuYi 杩欑锛�
+    return props.scope === PUSH_SCOPE.PARTY
+      ? parts.map((p) => Number(p)).filter((n) => Number.isFinite(n))
+      : parts;
+  },
+  set(value) {
+    emit('update:target', (value as (number | string)[]).join(','));
+  },
+});
+
+function handleScopeChange(e: unknown) {
+  const value = Number((e as { target?: { value?: number } })?.target?.value);
+  emit('update:scope', Number.isFinite(value) ? value : PUSH_SCOPE.ALL);
+  // 鍒囨崲鑼冨洿鏃舵竻鎺夋棫鐩爣锛岄伩鍏嶆妸鎴愬憳璐﹀彿褰撴垚閮ㄩ棬ID鎻愪氦
+  emit('update:target', '');
+}
+</script>
+
+<template>
+  <div>
+    <Radio.Group
+      :value="scope"
+      :disabled="disabled"
+      button-style="solid"
+      @change="handleScopeChange"
+    >
+      <Radio.Button
+        v-for="opt in PUSH_SCOPE_OPTIONS"
+        :key="opt.value"
+        :value="opt.value"
+      >
+        {{ opt.label }}
+      </Radio.Button>
+    </Radio.Group>
+
+    <div v-if="scope !== PUSH_SCOPE.ALL" class="mt-3">
+      <Spin :spinning="loading">
+        <Alert
+          v-if="loadError"
+          :message="loadError"
+          type="warning"
+          show-icon
+          class="mb-2"
+        />
+        <Select
+          v-model:value="selected"
+          :options="scope === PUSH_SCOPE.PARTY ? deptOptions : memberOptions"
+          :disabled="disabled || !!loadError"
+          :mode="scope === PUSH_SCOPE.PARTY ? 'multiple' : 'multiple'"
+          :placeholder="
+            scope === PUSH_SCOPE.PARTY
+              ? '璇烽�夋嫨閮ㄩ棬锛屽彲澶氶��'
+              : '璇烽�夋嫨鎴愬憳锛屽彲澶氶��'
+          "
+          option-filter-prop="label"
+          show-search
+          allow-clear
+          class="w-full"
+        />
+      </Spin>
+      <div class="mt-2 text-xs text-gray-400">
+        {{
+          scope === PUSH_SCOPE.PARTY
+            ? '鎺ㄩ�佺粰鎵�閫夐儴闂ㄤ笅鐨勫叏閮ㄦ垚鍛�'
+            : '鎺ㄩ�佺粰鎵�閫夋垚鍛橈紝璐﹀彿鏉ヨ嚜浼佷笟寰俊閫氳褰�'
+        }}
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/src/views/wxcp/data.ts b/src/views/wxcp/data.ts
new file mode 100644
index 0000000..4661547
--- /dev/null
+++ b/src/views/wxcp/data.ts
@@ -0,0 +1,80 @@
+/**
+ * 浼佷笟寰俊妯″潡鍏变韩甯搁噺
+ *
+ * 鍊间笌鍚庣鏋氫妇涓�涓�瀵瑰簲锛屾敼鍔ㄦ椂涓よ竟蹇呴』鍚屾锛�
+ * - WxCpPushScopeEnum锛�0鍏ㄥ憳 1鎸囧畾閮ㄩ棬 2鎸囧畾鎴愬憳
+ * - WxCpActivityStatusEnum锛�0鑽夌 1宸插彂甯� 2宸茬粨鏉� 3宸插彇娑�
+ * - WxCpNoticeStatusEnum锛�0鑽夌 1宸插彂甯� 2宸叉挙鍥�
+ * - WxCpSignupStatusEnum锛�0宸叉姤鍚� 1宸插彇娑� 2宸茬鍒�
+ */
+
+/** 鎺ㄩ�佽寖鍥� */
+export const PUSH_SCOPE = {
+  ALL: 0,
+  PARTY: 1,
+  USER: 2,
+} as const;
+
+/** 鎺ㄩ�佽寖鍥撮�夐」 */
+export const PUSH_SCOPE_OPTIONS = [
+  { label: '鍏ㄥ憳', value: PUSH_SCOPE.ALL },
+  { label: '鎸囧畾閮ㄩ棬', value: PUSH_SCOPE.PARTY },
+  { label: '鎸囧畾鎴愬憳', value: PUSH_SCOPE.USER },
+];
+
+/** 鎺ㄩ�佺姸鎬侊紙娲诲姩涓庨�氱煡鍏辩敤锛� */
+export const PUSH_STATUS_OPTIONS = [
+  { label: '鏈帹閫�', value: 0 },
+  { label: '宸叉帹閫�', value: 1 },
+];
+
+/** 娲诲姩鐘舵�� */
+export const ACTIVITY_STATUS = {
+  DRAFT: 0,
+  PUBLISHED: 1,
+  FINISHED: 2,
+  CANCELED: 3,
+} as const;
+
+/** 娲诲姩鐘舵�侀�夐」 */
+export const ACTIVITY_STATUS_OPTIONS = [
+  { label: '鑽夌', value: ACTIVITY_STATUS.DRAFT },
+  { label: '宸插彂甯�', value: ACTIVITY_STATUS.PUBLISHED },
+  { label: '宸茬粨鏉�', value: ACTIVITY_STATUS.FINISHED },
+  { label: '宸插彇娑�', value: ACTIVITY_STATUS.CANCELED },
+];
+
+/** 閫氱煡鐘舵�� */
+export const NOTICE_STATUS = {
+  DRAFT: 0,
+  PUBLISHED: 1,
+  REVOKED: 2,
+} as const;
+
+/** 閫氱煡鐘舵�侀�夐」 */
+export const NOTICE_STATUS_OPTIONS = [
+  { label: '鑽夌', value: NOTICE_STATUS.DRAFT },
+  { label: '宸插彂甯�', value: NOTICE_STATUS.PUBLISHED },
+  { label: '宸叉挙鍥�', value: NOTICE_STATUS.REVOKED },
+];
+
+/** 鎶ュ悕鐘舵�� */
+export const SIGNUP_STATUS = {
+  SIGNED_UP: 0,
+  CANCELED: 1,
+  CHECKED_IN: 2,
+} as const;
+
+/** 鎶ュ悕鐘舵�侀�夐」 */
+export const SIGNUP_STATUS_OPTIONS = [
+  { label: '宸叉姤鍚�', value: SIGNUP_STATUS.SIGNED_UP },
+  { label: '宸插彇娑�', value: SIGNUP_STATUS.CANCELED },
+  { label: '宸茬鍒�', value: SIGNUP_STATUS.CHECKED_IN },
+];
+
+/** 鎶婇�夐」鏁扮粍杞垚 value -> label 鐨勬槧灏勶紝渚夸簬琛ㄦ牸閲岀洿鎺ュ彇涓枃鍚� */
+export function toLabelMap(
+  options: Array<{ label: string; value: number }>,
+): Record<number, string> {
+  return Object.fromEntries(options.map((o) => [o.value, o.label]));
+}
diff --git a/src/views/wxcp/notice/data.ts b/src/views/wxcp/notice/data.ts
new file mode 100644
index 0000000..470b00d
--- /dev/null
+++ b/src/views/wxcp/notice/data.ts
@@ -0,0 +1,118 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { WxCpNoticeApi } from '#/api/wxcp/notice';
+
+import { NOTICE_STATUS_OPTIONS, PUSH_STATUS_OPTIONS } from '../data';
+
+/** 琛ㄥ崟绫诲瀷 */
+export type NoticeFormType = 'create' | 'update' | 'detail';
+
+/** 閫氱煡琛ㄥ崟 */
+export function useNoticeFormSchema(formType: NoticeFormType): VbenFormSchema[] {
+  const readonly = formType === 'detail';
+  return [
+    {
+      fieldName: 'id',
+      component: 'Input',
+      dependencies: { triggerFields: [''], show: () => false },
+    },
+    {
+      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枃',
+        disabled: readonly,
+      },
+      rules: 'required',
+      help: '鎺ㄩ�佹椂瓒呰繃 600 瀛椾細鑷姩鎴柇锛屽缓璁妸鍏抽敭淇℃伅鏀惧墠闈�',
+      formItemClass: 'col-span-2',
+    },
+    {
+      fieldName: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      componentProps: { rows: 2, placeholder: '鍐呴儴澶囨敞锛屼笉鎺ㄩ��', disabled: readonly },
+      formItemClass: 'col-span-2',
+    },
+  ];
+}
+
+/** 閫氱煡鎼滅储琛ㄥ崟 */
+export function useNoticeSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'title',
+      label: '閫氱煡鏍囬',
+      component: 'Input',
+      componentProps: { allowClear: true, placeholder: '璇疯緭鍏ラ�氱煡鏍囬' },
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: NOTICE_STATUS_OPTIONS,
+        placeholder: '璇烽�夋嫨鐘舵��',
+      },
+    },
+    {
+      fieldName: 'pushStatus',
+      label: '鎺ㄩ�佺姸鎬�',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: PUSH_STATUS_OPTIONS,
+        placeholder: '璇烽�夋嫨鎺ㄩ�佺姸鎬�',
+      },
+    },
+  ];
+}
+
+/** 閫氱煡鍒楄〃瀛楁 */
+export function useNoticeGridColumns(): VxeTableGridOptions<WxCpNoticeApi.Notice>['columns'] {
+  return [
+    {
+      field: 'title',
+      title: '閫氱煡鏍囬',
+      minWidth: 220,
+      slots: { default: 'title' },
+    },
+    {
+      field: 'content',
+      title: '姝f枃鎽樿',
+      minWidth: 260,
+      slots: { default: 'content' },
+    },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      width: 90,
+      slots: { default: 'status' },
+    },
+    {
+      field: 'pushScopeName',
+      title: '鎺ㄩ�佽寖鍥�',
+      width: 110,
+    },
+    {
+      field: 'pushStatus',
+      title: '鎺ㄩ��',
+      width: 90,
+      slots: { default: 'pushStatus' },
+    },
+    { field: 'pushTime', title: '鎺ㄩ�佹椂闂�', width: 170 },
+    { field: 'createTime', title: '鍒涘缓鏃堕棿', width: 170 },
+    { title: '鎿嶄綔', width: 220, fixed: 'right', slots: { default: 'actions' } },
+  ];
+}
diff --git a/src/views/wxcp/notice/index.vue b/src/views/wxcp/notice/index.vue
new file mode 100644
index 0000000..541b046
--- /dev/null
+++ b/src/views/wxcp/notice/index.vue
@@ -0,0 +1,204 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { WxCpNoticeApi } from '#/api/wxcp/notice';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { Button, message, Tag, Tooltip } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deleteNotice,
+  getNoticePage,
+  publishNotice,
+  revokeNotice,
+} from '#/api/wxcp/notice';
+
+import { NOTICE_STATUS, NOTICE_STATUS_OPTIONS, toLabelMap } from '../data';
+import {
+  useNoticeGridColumns,
+  useNoticeSearchFormSchema,
+} from './data';
+import NoticeForm from './modules/form.vue';
+import NoticePushModal from './modules/push-modal.vue';
+
+defineOptions({ name: 'WxCpNoticeList' });
+
+const statusLabelMap = toLabelMap(NOTICE_STATUS_OPTIONS);
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: NoticeForm,
+  destroyOnClose: true,
+});
+const [PushModalComp, pushModalApi] = useVbenModal({
+  connectedComponent: NoticePushModal,
+  destroyOnClose: true,
+});
+
+function handleRefresh() {
+  gridApi.query();
+}
+
+function handleCreate() {
+  formModalApi.setData({ formType: 'create' }).open();
+}
+
+function handleDetail(row: WxCpNoticeApi.Notice) {
+  formModalApi.setData({ formType: 'detail', id: row.id }).open();
+}
+
+function handleEdit(row: WxCpNoticeApi.Notice) {
+  formModalApi.setData({ formType: 'update', id: row.id }).open();
+}
+
+async function handleDelete(row: WxCpNoticeApi.Notice) {
+  await deleteNotice(row.id!);
+  message.success('鍒犻櫎鎴愬姛');
+  handleRefresh();
+}
+
+async function handlePublish(row: WxCpNoticeApi.Notice) {
+  await publishNotice(row.id!);
+  message.success('鍙戝竷鎴愬姛锛岀幇鍦ㄥ彲浠ユ帹閫佷簡');
+  handleRefresh();
+}
+
+async function handleRevoke(row: WxCpNoticeApi.Notice) {
+  await revokeNotice(row.id!);
+  message.success('宸叉挙鍥�');
+  handleRefresh();
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useNoticeSearchFormSchema(),
+  },
+  gridOptions: {
+    columns: useNoticeGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getNoticePage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: { keyField: 'id', isHover: true },
+    toolbarConfig: { refresh: true, search: true },
+  } as VxeTableGridOptions<WxCpNoticeApi.Notice>,
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormModal @success="handleRefresh" />
+    <PushModalComp @success="handleRefresh" />
+
+    <Grid table-title="浼佷笟寰俊閫氱煡鍒楄〃">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: '鏂板缓閫氱煡',
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['wxcp:notice:create'],
+              onClick: handleCreate,
+            },
+          ]"
+        />
+      </template>
+
+      <template #title="{ row }">
+        <Button type="link" class="p-0" @click="handleDetail(row)">
+          {{ row.title }}
+        </Button>
+      </template>
+
+      <template #content="{ row }">
+        <Tooltip v-if="row.content" :title="row.content">
+          <span class="line-clamp-2">{{ row.content }}</span>
+        </Tooltip>
+        <span v-else class="text-gray-400">-</span>
+      </template>
+
+      <template #status="{ row }">
+        <Tag
+          :color="
+            row.status === NOTICE_STATUS.PUBLISHED
+              ? 'green'
+              : row.status === NOTICE_STATUS.DRAFT
+                ? 'default'
+                : 'orange'
+          "
+        >
+          {{ statusLabelMap[row.status!] || '-' }}
+        </Tag>
+      </template>
+
+      <template #pushStatus="{ row }">
+        <Tag :color="row.pushStatus === 1 ? 'green' : 'default'">
+          {{ row.pushStatus === 1 ? '宸叉帹閫�' : '鏈帹閫�' }}
+        </Tag>
+      </template>
+
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '缂栬緫',
+              type: 'link',
+              icon: ACTION_ICON.EDIT,
+              auth: ['wxcp:notice:update'],
+              ifShow: row.status !== NOTICE_STATUS.REVOKED,
+              onClick: handleEdit.bind(null, row),
+            },
+            {
+              label: '鍙戝竷',
+              type: 'link',
+              auth: ['wxcp:notice:update'],
+              ifShow: row.status === NOTICE_STATUS.DRAFT,
+              popConfirm: {
+                title: '鍙戝竷鍚庡嵆鍙帹閫佺粰鎴愬憳锛岀‘璁ゅ彂甯冿紵',
+                confirm: handlePublish.bind(null, row),
+              },
+            },
+            {
+              label: '鎺ㄩ��',
+              type: 'link',
+              auth: ['wxcp:notice:push'],
+              ifShow: row.status === NOTICE_STATUS.PUBLISHED,
+              onClick: () => pushModalApi.setData({ notice: row }).open(),
+            },
+            {
+              label: '鎾ゅ洖',
+              type: 'link',
+              auth: ['wxcp:notice:update'],
+              ifShow: row.status === NOTICE_STATUS.PUBLISHED,
+              popConfirm: {
+                title: '鎾ゅ洖鍚庤閫氱煡涓嶈兘鍐嶆帹閫侊紝纭鎾ゅ洖锛�',
+                confirm: handleRevoke.bind(null, row),
+              },
+            },
+            {
+              label: '鍒犻櫎',
+              type: 'link',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              auth: ['wxcp:notice:delete'],
+              popConfirm: {
+                title: `纭鍒犻櫎閫氱煡銆�${row.title}銆戯紵`,
+                confirm: handleDelete.bind(null, row),
+              },
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/wxcp/notice/modules/form.vue b/src/views/wxcp/notice/modules/form.vue
new file mode 100644
index 0000000..cd835ac
--- /dev/null
+++ b/src/views/wxcp/notice/modules/form.vue
@@ -0,0 +1,116 @@
+<script lang="ts" setup>
+import type { WxCpNoticeApi } from '#/api/wxcp/notice';
+
+import type { NoticeFormType } from '../data';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createNotice,
+  getNotice,
+  updateNotice,
+} from '#/api/wxcp/notice';
+
+import { useNoticeFormSchema } from '../data';
+
+defineOptions({ name: 'WxCpNoticeForm' });
+
+const emit = defineEmits<{ success: [] }>();
+
+const formType = ref<NoticeFormType>('create');
+const id = ref<number>();
+const submitting = ref(false);
+
+const isEdit = computed(() => formType.value !== 'detail');
+const title = computed(() => {
+  if (formType.value === 'detail') return '鏌ョ湅閫氱煡';
+  if (id.value) return '缂栬緫閫氱煡';
+  return '鏂板缓閫氱煡';
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: { class: 'w-full' },
+    formItemClass: 'col-span-1',
+    labelWidth: 90,
+  },
+  layout: 'horizontal',
+  schema: [],
+  showDefaultActions: false,
+  wrapperClass: 'grid-cols-1',
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      id.value = undefined;
+      return;
+    }
+    const data = modalApi.getData<{ formType: NoticeFormType; id?: number }>();
+    formType.value = data.formType;
+    id.value = data.id;
+    formApi.setState({ schema: useNoticeFormSchema(formType.value) });
+    formApi.setDisabled(formType.value === 'detail');
+    if (data.id) {
+      modalApi.lock();
+      try {
+        const detail = await getNotice(data.id);
+        await formApi.setValues(detail as unknown as Record<string, unknown>);
+      } finally {
+        modalApi.unlock();
+      }
+    } else {
+      await formApi.resetForm();
+    }
+  },
+});
+
+async function handleSubmit() {
+  const { valid } = await formApi.validate();
+  if (!valid) {
+    return;
+  }
+  submitting.value = true;
+  try {
+    const data = (await formApi.getValues()) as WxCpNoticeApi.Notice;
+    if (id.value) {
+      await updateNotice({ ...data, id: id.value });
+      message.success('淇敼鎴愬姛');
+    } else {
+      await createNotice(data);
+      message.success('鍒涘缓鎴愬姛锛岃鍦ㄥ垪琛ㄤ腑鍙戝竷鍚庡啀鎺ㄩ��');
+    }
+    await modalApi.close();
+    emit('success');
+  } catch (error: unknown) {
+    message.error((error as Error)?.message || '鎻愪氦澶辫触');
+  } finally {
+    submitting.value = false;
+  }
+}
+</script>
+
+<template>
+  <Modal :title="title" class="w-3/5">
+    <Form class="mx-4" />
+
+    <template #footer>
+      <div class="flex justify-end gap-2">
+        <Button @click="modalApi.close()">鍙栨秷</Button>
+        <Button
+          v-if="isEdit"
+          type="primary"
+          :loading="submitting"
+          @click="handleSubmit"
+        >
+          纭
+        </Button>
+        <Button v-else type="primary" @click="modalApi.close()">鍏抽棴</Button>
+      </div>
+    </template>
+  </Modal>
+</template>
diff --git a/src/views/wxcp/notice/modules/push-modal.vue b/src/views/wxcp/notice/modules/push-modal.vue
new file mode 100644
index 0000000..918ad4a
--- /dev/null
+++ b/src/views/wxcp/notice/modules/push-modal.vue
@@ -0,0 +1,100 @@
+<script lang="ts" setup>
+import type { WxCpNoticeApi } from '#/api/wxcp/notice';
+
+import { ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { Alert, Button, message } from 'ant-design-vue';
+
+import { pushNotice } from '#/api/wxcp/notice';
+
+import PushScopePicker from '../../components/push-scope-picker.vue';
+import { PUSH_SCOPE } from '../../data';
+
+defineOptions({ name: 'WxCpNoticePushModal' });
+
+const emit = defineEmits<{ success: [] }>();
+
+const notice = ref<WxCpNoticeApi.Notice>({});
+const scope = ref<number>(PUSH_SCOPE.ALL);
+const target = ref<string>('');
+const pushing = ref(false);
+const resultMessage = ref('');
+
+const [Modal, modalApi] = useVbenModal({
+  onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      notice.value = {};
+      resultMessage.value = '';
+      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 ?? '';
+  },
+});
+
+async function handlePush() {
+  if (!notice.value.id) {
+    return;
+  }
+  if (scope.value !== PUSH_SCOPE.ALL && !target.value.trim()) {
+    message.warning('閫夋嫨銆屾寚瀹氶儴闂ㄣ�嶆垨銆屾寚瀹氭垚鍛樸�嶆椂锛岃鍏堥�夋嫨鎺ㄩ�佺洰鏍�');
+    return;
+  }
+  pushing.value = true;
+  resultMessage.value = '';
+  try {
+    const res = await pushNotice({
+      id: notice.value.id,
+      pushScope: scope.value,
+      pushTarget: scope.value === PUSH_SCOPE.ALL ? '' : target.value,
+    });
+    if (res?.success) {
+      message.success('鎺ㄩ�佹垚鍔�');
+      emit('success');
+      await modalApi.close();
+    } else {
+      resultMessage.value = res?.message || '鎺ㄩ�佸け璐�';
+    }
+  } catch (error: unknown) {
+    resultMessage.value = (error as Error)?.message || '鎺ㄩ�佸け璐�';
+  } finally {
+    pushing.value = false;
+  }
+}
+</script>
+
+<template>
+  <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">{{ notice.title }}</div>
+        <div class="text-gray-500">
+          鎺ㄩ�佸悗鎴愬憳鐨勪紒涓氬井淇′細鏀跺埌绾枃鏈秷鎭紝鍐呭瓒呰繃 600 瀛椾細鑷姩鎴柇銆�
+        </div>
+      </div>
+
+      <div class="mb-2 text-sm font-medium">鎺ㄩ�佽寖鍥�</div>
+      <PushScopePicker v-model:scope="scope" v-model:target="target" />
+
+      <Alert
+        v-if="resultMessage"
+        class="mt-4"
+        type="error"
+        :message="resultMessage"
+        show-icon
+      />
+    </div>
+
+    <template #footer>
+      <div class="flex justify-end gap-2">
+        <Button @click="modalApi.close()">鍙栨秷</Button>
+        <Button type="primary" :loading="pushing" @click="handlePush">
+          纭鎺ㄩ��
+        </Button>
+      </div>
+    </template>
+  </Modal>
+</template>

--
Gitblit v1.9.3