feat: 优化考核调研钉钉通知卡片

This commit is contained in:
chenpei 2026-07-14 10:29:07 +08:00
parent 14a7a65ce3
commit 8f964baee5
4 changed files with 100 additions and 17 deletions

View File

@ -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')

View File

@ -51,6 +51,7 @@ function notification(overrides: Record<string, unknown> = {}) {
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',
),

View File

@ -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,
},
}

View File

@ -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) {