diff --git a/lib/__tests__/dingtalk-client.test.ts b/lib/__tests__/dingtalk-client.test.ts index 453d6125..a46383d6 100644 --- a/lib/__tests__/dingtalk-client.test.ts +++ b/lib/__tests__/dingtalk-client.test.ts @@ -110,11 +110,15 @@ describe('dingtalk client', () => { title: '测试通知', text: '这是一条测试通知', actionUrl: 'https://sc.jiansun.vip/dingtalk-entry?redirect=%2Fperformance-survey', + actionTitle: '立即填写', }) const [, sendOptions] = (global.fetch as jest.Mock).mock.calls[1] const body = JSON.parse(sendOptions.body) expect(body.msg.msgtype).toBe('action_card') + expect(body.msg.action_card.title).toBe('测试通知') + expect(body.msg.action_card.markdown).toBe('这是一条测试通知') + expect(body.msg.action_card.single_title).toBe('立即填写') expect(body.msg.action_card.single_url).toContain('dingtalk://dingtalkclient/action/openapp') expect(body.msg.action_card.single_url).toContain('corpid=ding-test-corp') expect(body.msg.action_card.single_url).toContain('app_id=0_4279191869') diff --git a/lib/__tests__/performance-survey-notifications.test.ts b/lib/__tests__/performance-survey-notifications.test.ts index 56be5f2d..942ba602 100644 --- a/lib/__tests__/performance-survey-notifications.test.ts +++ b/lib/__tests__/performance-survey-notifications.test.ts @@ -51,6 +51,7 @@ function notification(overrides: Record = {}) { title: '你有新的考核调研待填写', content: '请填写考核调研', actionUrl: 'https://sc.jiansun.vip/performance-survey/survey/survey-1', + eventType: 'survey.assignment.created', status: 'pending', attemptCount: 0, maxAttempts: 5, @@ -116,6 +117,7 @@ describe('performance survey notifications', () => { title: '你有新的考核调研待填写', text: '请填写考核调研', actionUrl: 'https://sc.jiansun.vip/performance-survey/survey/survey-1', + actionTitle: '立即填写', }) expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({ where: { id: 'notification-1' }, @@ -206,7 +208,7 @@ describe('performance survey notifications', () => { recipientName: '被代发起人', eventType: 'survey.target.confirmation.required', businessKey: 'target-confirmation:survey-1:target-other', - title: '请确认一项代发起调研', + title: '待确认|七月课程分享', actionUrl: expect.stringContaining('/dingtalk-entry?redirect='), status: 'pending', }), @@ -245,8 +247,8 @@ describe('performance survey notifications', () => { expect(prisma.performanceSurveyNotification.create).toHaveBeenCalledWith({ data: expect.objectContaining({ - title: '请确认并提交视觉评分作品', - content: expect.stringContaining('提交 3 个案例作品'), + title: '待提交作品|七月视觉评分', + content: expect.stringContaining('**作品要求:** 3 个案例作品'), actionUrl: expect.stringContaining( '%2Fperformance-survey%2Fsurvey%2Fsurvey-visual%3FtargetId%3Dtarget-other', ), diff --git a/lib/dingtalk-client.ts b/lib/dingtalk-client.ts index 5371e17a..9ffc63c5 100644 --- a/lib/dingtalk-client.ts +++ b/lib/dingtalk-client.ts @@ -3,6 +3,7 @@ type DingtalkWorkNoticeInput = { title: string text: string actionUrl?: string | null + actionTitle?: string | null } type DingtalkWorkNoticeResult = { @@ -224,6 +225,7 @@ export async function sendDingtalkWorkNotice( const token = await getDingtalkWorkNoticeAccessToken() const messageTitle = truncateText(input.title, 120) + const actionTitle = truncateText(input.actionTitle || '查看详情', 20) const actionUrl = input.actionUrl ? buildDingtalkWorkNoticeActionUrl(input.actionUrl, numericAgentId) : null @@ -233,7 +235,7 @@ export async function sendDingtalkWorkNotice( action_card: { title: messageTitle, markdown: truncateText(input.text), - single_title: '打开素材库查看', + single_title: actionTitle, single_url: actionUrl, }, } diff --git a/lib/performance-survey-notifications.ts b/lib/performance-survey-notifications.ts index e46a9ae2..84ac0d9a 100644 --- a/lib/performance-survey-notifications.ts +++ b/lib/performance-survey-notifications.ts @@ -58,6 +58,42 @@ function clean(value: unknown) { return normalized.length > 0 ? normalized : null } +function escapeDingtalkMarkdown(value: unknown) { + return String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/([`*_\[\]#>])/g, '\\$1') + .trim() +} + +function buildNotificationCard(input: { + heading: string + surveyTitle: string + fields: Array<{ label: string; value: string | null | undefined }> + note?: string | null +}) { + const fieldLines = input.fields + .filter((field) => clean(field.value)) + .map((field) => `**${escapeDingtalkMarkdown(field.label)}:** ${escapeDingtalkMarkdown(field.value)} `) + + return [ + `### ${escapeDingtalkMarkdown(input.heading)}`, + '', + `**${escapeDingtalkMarkdown(input.surveyTitle)}**`, + '', + ...fieldLines, + input.note ? `\n> ${escapeDingtalkMarkdown(input.note)}` : null, + ].filter((line): line is string => line !== null).join('\n').trim() +} + +function getNotificationActionTitle(eventType: string) { + if (eventType === 'survey.assignment.created') return '立即填写' + if (eventType === 'survey.assignment.due_reminder') return '继续填写' + if (eventType === 'survey.target.confirmation.required') return '确认并提交' + if (eventType === 'survey.result.available') return '查看结果' + if (eventType.startsWith('survey.creator.')) return '查看结果' + return '查看详情' +} + async function resolveNotificationDingtalkUserId(notification: { dingtalkUserId?: string | null recipientUserId?: string | null @@ -219,8 +255,16 @@ export async function queueSurveyAssignmentNotifications(surveyId: string) { recipientName: assignment.evaluatorName, eventType: 'survey.assignment.created', businessKey: `assignment:${assignment.id}`, - title: '你有新的考核调研待填写', - content: `你有一份新的考核调研需要填写。\n\n调研:${survey.title}\n对象:${targetName}${survey.dueAt ? `\n截止:${formatDateTime(survey.dueAt)}` : ''}`, + title: `待填写|${survey.title}`, + content: buildNotificationCard({ + heading: '新的考核调研待填写', + surveyTitle: survey.title, + fields: [ + { label: '评价对象', value: targetName }, + { label: '截止时间', value: formatDateTime(survey.dueAt) }, + ], + note: '请在截止时间前完成评分', + }), actionUrl: getSurveyUrl(`/performance-survey/survey/${survey.id}?targetId=${assignment.targetId}`), }), ) @@ -273,10 +317,20 @@ export async function queueSurveyTargetConfirmationNotifications(surveyId: strin recipientName: target.targetName, eventType: 'survey.target.confirmation.required', businessKey: `target-confirmation:${survey.id}:${target.id}`, - title: isVisualSurvey ? '请确认并提交视觉评分作品' : '请确认一项代发起调研', - content: isVisualSurvey - ? `${survey.creatorName} 为你发起了一项视觉评分调研,请确认被考核人信息并提交 ${requiredWorkItemCount} 个案例作品。\n\n调研:${survey.title}\n被考核人:${target.targetName}\n考核月份:${formatAssessmentMonth(survey.assessmentMonth, survey.createdAt)}\n\n如信息有误,请联系发起人。` - : `${survey.creatorName} 为你发起了一项调研,请确认被考核人信息。\n\n调研:${survey.title}\n被考核人:${target.targetName}\n考核月份:${formatAssessmentMonth(survey.assessmentMonth, survey.createdAt)}\n\n如信息有误,请联系发起人。`, + title: isVisualSurvey ? `待提交作品|${survey.title}` : `待确认|${survey.title}`, + content: buildNotificationCard({ + heading: isVisualSurvey ? '请确认并提交视觉评分作品' : '请确认代发起调研', + surveyTitle: survey.title, + fields: [ + { label: '发起人', value: survey.creatorName }, + { label: '被考核人', value: target.targetName }, + { label: '考核月份', value: formatAssessmentMonth(survey.assessmentMonth, survey.createdAt) }, + isVisualSurvey + ? { label: '作品要求', value: `${requiredWorkItemCount} 个案例作品` } + : { label: '确认事项', value: '被考核人信息' }, + ], + note: '如信息有误,请联系发起人', + }), actionUrl: getSurveyUrl( isVisualSurvey ? `/performance-survey/survey/${survey.id}?targetId=${target.id}` @@ -311,8 +365,13 @@ export async function queueSurveyResultNotifications(surveyId: string) { recipientName: target.targetName, eventType: 'survey.result.available', businessKey: `result:${survey.id}:${target.id}`, - title: '考核调研结果已可查看', - content: `你参与的考核调研结果已经生成。\n\n调研:${survey.title}\n对象:${target.targetName}`, + title: `结果已生成|${survey.title}`, + content: buildNotificationCard({ + heading: '考核调研结果已生成', + surveyTitle: survey.title, + fields: [{ label: '评价对象', value: target.targetName }], + note: '点击下方按钮查看完整结果', + }), actionUrl: getSurveyUrl(`/performance-survey/results/${survey.id}?targetId=${target.id}`), }), ) @@ -346,8 +405,13 @@ export async function queueSurveyCreatorNotification(surveyId: string, eventType recipientName: creator?.realName || creator?.username || survey.creatorName, eventType, businessKey: `${eventType}:${survey.id}`, - title: eventType === 'survey.creator.closed' ? '考核调研已关闭' : '考核调研已完成', - content: `你发起的考核调研有新的进度。\n\n调研:${survey.title}\n提交进度:${submitted}/${total}`, + title: `${eventType === 'survey.creator.closed' ? '已关闭' : '已完成'}|${survey.title}`, + content: buildNotificationCard({ + heading: eventType === 'survey.creator.closed' ? '考核调研已关闭' : '考核调研已完成', + surveyTitle: survey.title, + fields: [{ label: '提交进度', value: `${submitted}/${total}` }], + note: '点击下方按钮查看调研结果', + }), actionUrl: getSurveyUrl(`/performance-survey/results/${survey.id}`), }) } @@ -388,8 +452,16 @@ export async function queueDuePerformanceSurveyReminders() { recipientName: assignment.evaluatorName, eventType: 'survey.assignment.due_reminder', businessKey: `due:${dueKey}:${assignment.id}`, - title: '考核调研即将截止', - content: `你还有一份考核调研未填写。\n\n调研:${survey.title}\n对象:${assignment.target.targetName}\n截止:${formatDateTime(survey.dueAt)}`, + title: `即将截止|${survey.title}`, + content: buildNotificationCard({ + heading: '考核调研即将截止', + surveyTitle: survey.title, + fields: [ + { label: '评价对象', value: assignment.target.targetName }, + { label: '截止时间', value: formatDateTime(survey.dueAt) }, + ], + note: '请尽快完成评分,避免错过截止时间', + }), actionUrl: getSurveyUrl(`/performance-survey/survey/${survey.id}?targetId=${assignment.targetId}`), }), ) @@ -515,6 +587,7 @@ export async function processPerformanceSurveyNotification(notificationId: strin title: notification.title, text: notification.content, actionUrl: notification.actionUrl, + actionTitle: getNotificationActionTitle(notification.eventType), }) if (result.skipped) { @@ -644,7 +717,9 @@ function formatDateTime(value: Date | string | null | undefined) { if (!value) return '' const date = new Date(value) if (Number.isNaN(date.getTime())) return '' - return date.toLocaleString('zh-CN', { hour12: false }) + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + return `${date.getMonth() + 1}月${date.getDate()}日 ${hour}:${minute}` } function formatAssessmentMonth(value: string | null | undefined, fallback: Date | string) {