liu
2026-09-29 7a6351a1fa5fc6e67b55c475bcd253d6d3e5d68f
feat(wxcp): 新增企业微信模块页面与扫码登录入口

一、活动管理
- 列表:按标题/状态/推送状态检索,行内可编辑、发布、推送、看二维码与统计
- 新建/编辑:标题、地点、活动时间、报名窗口、名额上限(0 表示不限)
- 推送弹窗:选择推送范围后调用推送接口,失败时把企业微信返回的原因直接展示
- 二维码弹窗:以 Blob 方式取回图片(img 标签带不了认证头),支持下载与复制报名链接
- 统计弹窗:有效报名、未签到、已签到、已取消、报名进度、签到率,
以及按部门 / 按日期 / 按来源三组分布;不限名额时不显示进度条
- 报名记录:检索、签到、取消报名,并可查看成员提交的参与说明与上传的图片附件

二、通知管理
- 列表、新建/编辑、发布、推送、撤回
- 推送弹窗复用同一套推送范围选择器

三、推送范围选择器(components/push-scope-picker.vue)
- 支持全员 / 指定部门 / 指定成员三种范围
- 部门与成员数据实时取自企业微信通讯录,做了「按需加载 + 只加载一次」的缓存,
避免每次打开弹窗都打企业微信接口(通讯录接口有频率限制)
- 切换范围时清空已选目标,防止把成员账号当成部门 ID 提交

四、登录入口
- 登录页新增「企业微信登录」按钮,复用现有微信图标
- 点击后取授权地址并整页跳转到企业微信扫码页
- redirectUri 用 location.origin 拼接,并预先 encode 一次 type 参数
(配合 social-login 页的 getUrlValue 使用,否则回跳后 type 会丢失)

五、其他
- 根 package.json 补声明 axios(vite.config.ts 的 optimizeDeps.include 需要它,
但根从未声明,会导致 Vite 依赖预构建失败、dev server 崩溃)
- pnpm-lock.yaml 随之更新;其中 @lezer/highlight、style-mod、typed-array-byte-offset
三个传递依赖被 pnpm 顺带做了补丁级升级,与本次功能无关

Co-Authored-By: Claude <noreply@anthropic.com>
已修改3个文件
已添加16个文件
2579 ■■■■■ 文件已修改
package.json 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/activity.ts 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/member.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/notice.ts 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/data.ts 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/index.vue 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/form.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/push-modal.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/qrcode-modal.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/signup-list.vue 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/stats-modal.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/components/push-scope-picker.vue 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/data.ts 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/data.ts 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/index.vue 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/modules/form.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/modules/push-modal.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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:",
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
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)
 *
 * è¯¥æŽ¥å£è¿”回图片二进制,而 <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 } },
  );
}
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,
  });
}
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);
}
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;
    // æ³¨æ„ï¼štype éœ€è¦å…ˆ 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"
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' } },
  ];
}
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>
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>
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>
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 {
    // äºŒç»´ç æŽ¥å£è¿”回 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>
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>
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>
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>
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]));
}
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: '通知正文',
      component: 'Textarea',
      componentProps: {
        rows: 8,
        placeholder: '请输入通知正文',
        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: '正文摘要',
      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' } },
  ];
}
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>
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>
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>