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>
| | |
| | | "@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:", |
| | |
| | | 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 |
| | |
| | | '@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==} |
| | |
| | | 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==} |
| | |
| | | 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: |
| | |
| | | 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: |
| | |
| | | '@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 |
| | | |
| | |
| | | |
| | | '@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 |
| | | |
| | |
| | | '@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: |
| | |
| | | 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)': |
| | |
| | | |
| | | '@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': |
| | |
| | | '@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': {} |
| | | |
| | |
| | | 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 |
| | |
| | | '@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 |
| | |
| | | |
| | | strnum@1.0.5: {} |
| | | |
| | | style-mod@4.1.3: {} |
| | | style-mod@4.1.4: {} |
| | | |
| | | style-search@0.1.0: {} |
| | | |
| | |
| | | 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 |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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); |
| | | } |
| | |
| | | |
| | | 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'; |
| | | |
| | |
| | | getCaptcha, |
| | | getTenantByWebsite, |
| | | getTenantSimpleList, |
| | | socialAuthRedirect, |
| | | } from '#/api/core/auth'; |
| | | import { useAuthStore } from '#/store'; |
| | | |
| | |
| | | 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 [ |
| | |
| | | :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" |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' } }, |
| | | ]; |
| | | } |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /** |
| | | * ä¼ä¸å¾®ä¿¡æ¨¡åå
±äº«å¸¸é |
| | | * |
| | | * å¼ä¸å端æä¸¾ä¸ä¸å¯¹åºï¼æ¹å¨æ¶ä¸¤è¾¹å¿
é¡»åæ¥ï¼ |
| | | * - 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])); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' } }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |