diff --git a/app/api/internal/notification-center/recipients/resolve/route.ts b/app/api/internal/notification-center/recipients/resolve/route.ts new file mode 100644 index 0000000..f05949c --- /dev/null +++ b/app/api/internal/notification-center/recipients/resolve/route.ts @@ -0,0 +1,75 @@ +import { createHash, timingSafeEqual } from 'crypto' +import { NextRequest, NextResponse } from 'next/server' + +import { + NotificationCenterAdminError, + resolveNotificationCenterRecipientAlias, +} from '@/lib/notification-center-admin' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function withNoStore(response: NextResponse) { + response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate') + response.headers.set('Pragma', 'no-cache') + response.headers.set('Expires', '0') + return response +} + +function sha256(value: string) { + return createHash('sha256').update(value, 'utf8').digest() +} + +function isAuthorizedBearer(providedSecret: string | null, configuredSecret: string | null) { + if (!providedSecret || !configuredSecret) return false + return timingSafeEqual(sha256(providedSecret), sha256(configuredSecret)) +} + +function readBearerSecret(request: NextRequest) { + const header = String(request.headers.get('authorization') || '') + if (!header.startsWith('Bearer ')) return null + const token = header.slice('Bearer '.length).trim() + return token || null +} + +function errorResponse(status: number, code: string, message: string) { + return withNoStore( + NextResponse.json( + { + error: { code, message }, + }, + { status }, + ), + ) +} + +export async function POST(request: NextRequest) { + const configuredSecret = String(process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET || '').trim() || null + if (!configuredSecret) { + return errorResponse(503, 'NOTIFICATION_CENTER_ADMIN_SECRET_NOT_CONFIGURED', '服务端未配置通知中心鉴权密钥') + } + + const providedSecret = readBearerSecret(request) + if (!isAuthorizedBearer(providedSecret, configuredSecret)) { + return errorResponse(401, 'UNAUTHORIZED', '未授权') + } + + const body = await request.json().catch(() => ({})) + const recipientAlias = String((body as { recipientAlias?: unknown })?.recipientAlias || '').trim() + if (!recipientAlias) { + return errorResponse(422, 'RECIPIENT_ALIAS_REQUIRED', '花名不能为空') + } + + try { + const resolved = await resolveNotificationCenterRecipientAlias(recipientAlias) + if (!resolved) { + return errorResponse(404, 'RECIPIENT_ALIAS_NOT_FOUND', '花名未找到对应钉钉 userId') + } + return withNoStore(NextResponse.json(resolved, { status: 200 })) + } catch (error) { + if (error instanceof NotificationCenterAdminError) { + return errorResponse(error.statusCode, error.code, error.message) + } + return errorResponse(500, 'NOTIFICATION_CENTER_RECIPIENT_RESOLVE_FAILED', '花名解析失败,请稍后重试') + } +} diff --git a/components/admin/NotificationCenterPanel.test.tsx b/components/admin/NotificationCenterPanel.test.tsx index eb7bb7d..99bfc19 100644 --- a/components/admin/NotificationCenterPanel.test.tsx +++ b/components/admin/NotificationCenterPanel.test.tsx @@ -21,8 +21,6 @@ const project = { isActive: true, allowedHosts: ['https://sc.jiansun.vip'], recipientMode: 'alias', - recipientCount: 1, - recipients: [{ alias: '测试员工', userId: 'ding-user-1' }], updatedAt: '2026-08-28T08:00:00.000Z', deliverySummary: { lastStatus: 'sent', lastSentAt: '2026-08-28T08:00:00.000Z', recentFailedCount: 0 }, } @@ -36,23 +34,30 @@ const jsonResponse = (body: unknown, status = 200) => describe('NotificationCenterPanel', () => { const showToast = jest.fn() + let clipboardText = '' beforeEach(() => { jest.clearAllMocks() + clipboardText = '' Object.defineProperty(navigator, 'clipboard', { configurable: true, - value: { writeText: jest.fn().mockResolvedValue(undefined) }, + value: { + writeText: jest.fn().mockImplementation(async (value: string) => { + clipboardText = value + }), + readText: jest.fn().mockImplementation(async () => clipboardText), + }, }) global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) - if (url.includes('/api/admin/notification-center/projects?includeRecipients=1') && (!init?.method || init.method === 'GET')) { + if (url.endsWith('/api/admin/notification-center/projects') && (!init?.method || init.method === 'GET')) { return jsonResponse({ success: true, data: { projects: [project] } }) } if (url.endsWith('/api/admin/notification-center/projects') && init?.method === 'POST') { return jsonResponse({ success: true, data: { - project, + projectCode: 'fixed_assets', secret: 'one-time-project-secret', env: { YYD_NOTIFICATION_CENTER_URL: 'https://sc.jiansun.vip/notification-center', @@ -62,9 +67,6 @@ describe('NotificationCenterPanel', () => { }, }, 201) } - if (url.includes('/recipients') && init?.method === 'PUT') { - return jsonResponse({ project }) - } if (url.includes('/rotate-secret') && init?.method === 'POST') { return jsonResponse({ success: true, @@ -98,49 +100,51 @@ describe('NotificationCenterPanel', () => { }) as jest.Mock }) - test('默认展示项目总览并明确区分素材外发', async () => { + test('默认展示极简接入总览并说明标题前缀与外部项目边界', async () => { render() expect(await screen.findByText('素材平台')).toBeInTheDocument() - expect(screen.getByText(/素材平台向外部系统发送 Webhook 的能力位于“素材外发”/)).toBeInTheDocument() - expect(screen.getAllByText('1', { selector: 'span' })).toHaveLength(3) + expect(screen.getAllByText(/通知中心会统一在标题前加上项目名称/)).toHaveLength(2) + expect(screen.getAllByText(/正文、按钮和跳转地址由外部项目自行决定/)).toHaveLength(2) + expect(screen.queryByRole('button', { name: /收件人花名/ })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: '新增接入项目' })).toBeInTheDocument() }) - test('新建项目后只展示一次项目密钥和环境变量', async () => { + test('新建项目只填名称也会直接展示 URL、projectCode 和项目密钥', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: '新增接入项目' })) - await user.type(screen.getByLabelText('项目中文名称'), '固定资产系统') - await user.type(screen.getByLabelText('projectCode'), 'fixed_assets') - await user.type(screen.getByLabelText('允许跳转域名 allowedHosts'), 'https://assets.example.com') + await user.type(screen.getByLabelText('项目名称'), '固定资产系统') await user.click(screen.getByRole('button', { name: '创建并生成密钥' })) expect(await screen.findByText('one-time-project-secret')).toBeInTheDocument() - expect(screen.getByText(/YYD_NOTIFICATION_CENTER_PROJECT_CODE=fixed_assets/)).toBeInTheDocument() + expect(screen.getByText('https://sc.jiansun.vip/notification-center')).toBeInTheDocument() + expect(screen.getByText('fixed_assets')).toBeInTheDocument() expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects', expect.objectContaining({ method: 'POST' }), ) }) - test('保存花名使用项目专属 recipients 代理接口', async () => { + test('高级设置允许按需自定义 projectCode', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') - await user.click(screen.getByRole('button', { name: /收件人花名/ })) - expect(await screen.findByDisplayValue('测试员工')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: '保存花名清单' })) + await user.click(screen.getByRole('button', { name: '新增接入项目' })) + await user.type(screen.getByLabelText('项目名称'), '固定资产系统') + await user.click(screen.getByRole('button', { name: '高级设置:自定义 projectCode' })) + await user.type(screen.getByLabelText('projectCode'), 'fixed_assets') + await user.click(screen.getByRole('button', { name: '创建并生成密钥' })) - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - '/api/admin/notification-center/projects/material_library/recipients', - expect.objectContaining({ method: 'PUT' }), - ) - }) + const request = (global.fetch as jest.Mock).mock.calls.find( + ([url, init]) => String(url) === '/api/admin/notification-center/projects' && (init as RequestInit)?.method === 'POST', + ) + + expect(request).toBeTruthy() + expect((request?.[1] as RequestInit).body).toContain('"projectCode":"fixed_assets"') }) test('密钥轮换要求二次确认且新密钥只在确认后展示', async () => { @@ -170,7 +174,7 @@ describe('NotificationCenterPanel', () => { await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) expect(screen.getByRole('heading', { name: '查看配置 / 编辑资料' })).toBeInTheDocument() - expect(screen.getByLabelText('项目中文名称')).toHaveValue('素材平台') + expect(screen.getByLabelText('项目名称')).toHaveValue('素材平台') expect(screen.getByLabelText('projectCode')).toHaveValue('material_library') expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument() @@ -189,22 +193,30 @@ describe('NotificationCenterPanel', () => { expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument() }) - test('测试通知在确认前不发送,确认后才调用 mock 接口', async () => { + test('测试通知支持直接输入准确花名并在确认后透传 recipientAlias', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: /测试通知/ })) + expect(screen.getByDisplayValue('准确花名')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('真实收件人'), { target: { value: '测试员工' } }) await user.click(screen.getByRole('button', { name: '向指定员工发送测试通知' })) - expect(global.fetch).not.toHaveBeenCalledWith(expect.stringContaining('/test'), expect.anything()) - expect(screen.getByText(/确认后会真实调用钉钉/)).toBeInTheDocument() + expect(screen.getByText(/按当前花名册精确解析 花名:测试员工/)).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '确认发送给该员工' })) await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects/material_library/test', - expect.objectContaining({ method: 'POST' }), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + recipientAlias: '测试员工', + title: '统一通知中心测试', + content: '这是一条由超级管理员发起的链路测试通知。', + actionTitle: '查看详情', + }), + }), ) }) }) diff --git a/components/admin/NotificationCenterPanel.tsx b/components/admin/NotificationCenterPanel.tsx index c2df2b9..d4c1d57 100644 --- a/components/admin/NotificationCenterPanel.tsx +++ b/components/admin/NotificationCenterPanel.tsx @@ -16,20 +16,14 @@ import { RefreshCw, Send, Settings2, - Users, X, } from 'lucide-react' gsap.registerPlugin(useGSAP) -type PanelView = 'projects' | 'recipients' | 'test' | 'deliveries' +type PanelView = 'projects' | 'test' | 'deliveries' type RecipientMode = 'userId' | 'alias' -interface NotificationRecipient { - alias: string - userId: string -} - interface DeliverySummary { lastStatus: string | null lastSentAt: string | null @@ -42,8 +36,6 @@ export interface NotificationProject { isActive: boolean allowedHosts: string[] recipientMode: RecipientMode - recipientCount: number - recipients: NotificationRecipient[] updatedAt: string | null deliverySummary: DeliverySummary } @@ -65,9 +57,8 @@ interface ProjectFormState { name: string projectCode: string isActive: boolean - allowedHostsText: string + allowedHosts: string[] recipientMode: RecipientMode - recipients: NotificationRecipient[] } interface OneTimeSecret { @@ -91,9 +82,8 @@ const EMPTY_PROJECT_FORM: ProjectFormState = { name: '', projectCode: '', isActive: true, - allowedHostsText: '', - recipientMode: 'userId', - recipients: [], + allowedHosts: [], + recipientMode: 'alias', } const DELIVERY_STATUSES = [ @@ -114,26 +104,13 @@ const VIEW_ITEMS: Array<{ description: string icon: typeof Activity }> = [ - { id: 'projects', label: '接入项目', description: '项目、域名与密钥', icon: Settings2 }, - { id: 'recipients', label: '收件人花名', description: '花名与钉钉账号', icon: Users }, + { id: 'projects', label: '接入项目', description: '项目、密钥与状态', icon: Settings2 }, { id: 'test', label: '测试通知', description: '向指定员工验证链路', icon: Send }, { id: 'deliveries', label: '投递记录', description: '状态与故障处理', icon: Activity }, ] -function normalizeRecipient(input: unknown): NotificationRecipient | null { - if (!input || typeof input !== 'object') return null - const item = input as Record - const alias = String(item.alias || item.recipientAlias || '').trim() - const userId = String(item.userId || item.recipientUserId || '').trim() - if (!alias || !userId) return null - return { alias, userId } -} - function normalizeProject(input: unknown): NotificationProject { const item = (input || {}) as Record - const recipients = Array.isArray(item.recipients) - ? item.recipients.map(normalizeRecipient).filter((value): value is NotificationRecipient => Boolean(value)) - : [] const summary = (item.deliverySummary || {}) as Record return { name: String(item.name || item.displayName || item.projectName || item.projectCode || '未命名项目'), @@ -146,8 +123,6 @@ function normalizeProject(input: unknown): NotificationProject { item.recipientMode === 'alias' || item.recipientMode === 'userId_or_alias' ? 'alias' : 'userId', - recipientCount: Number(item.recipientCount ?? recipients.length ?? 0), - recipients, updatedAt: item.updatedAt ? String(item.updatedAt) : null, deliverySummary: { lastStatus: summary.lastStatus ? String(summary.lastStatus) : null, @@ -187,25 +162,6 @@ function formatDateTime(value: string | null) { return Number.isNaN(date.getTime()) ? '暂无' : date.toLocaleString('zh-CN') } -function parseAllowedHosts(value: string) { - return Array.from( - new Set( - value - .split(/[\n,,]/) - .map((host) => { - const normalized = host.trim() - if (!normalized) return '' - try { - return new URL(normalized.includes('://') ? normalized : `https://${normalized}`).hostname.toLowerCase() - } catch { - return normalized.toLowerCase() - } - }) - .filter(Boolean), - ), - ) -} - function getStatusLabel(status: string | null) { return DELIVERY_STATUSES.find(([value]) => value === status)?.[1] || status || '暂无投递' } @@ -222,10 +178,10 @@ function getDeliveryAdvice(delivery: NotificationDelivery) { return '钉钉是否收到尚不确定。请先联系收件人或在钉钉侧人工核验,不要直接重复发送。' } if (delivery.lastErrorCode === 'RECIPIENT_NOT_FOUND') { - return '未找到已登记的收件人。请核对花名或钉钉 userId 后重新测试。' + return '当前花名未找到对应钉钉 userId。请核对准确花名,或改用精确钉钉 userId 重新测试。' } if (delivery.lastErrorCode === 'ACTION_URL_NOT_ALLOWED') { - return '按钮地址不在该项目允许的域名内。请先更新 allowedHosts。' + return '按钮地址无法发送,请检查是否包含异常字符或内容过长。' } if (delivery.status === 'failed') { return '通知发送失败。请根据下方原因修正配置,再重新发起测试。' @@ -282,11 +238,9 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente const [rotatingSecret, setRotatingSecret] = useState(false) const [revealedSecret, setRevealedSecret] = useState(null) const [revealingSecretCode, setRevealingSecretCode] = useState(null) - const [recipientProjectCode, setRecipientProjectCode] = useState('') - const [recipientRows, setRecipientRows] = useState([]) - const [savingRecipients, setSavingRecipients] = useState(false) + const [showAdvancedProjectCode, setShowAdvancedProjectCode] = useState(false) const [testProjectCode, setTestProjectCode] = useState('') - const [testRecipientMode, setTestRecipientMode] = useState('userId') + const [testRecipientMode, setTestRecipientMode] = useState('alias') const [testRecipient, setTestRecipient] = useState('') const [testTitle, setTestTitle] = useState('统一通知中心测试') const [testBody, setTestBody] = useState('这是一条由超级管理员发起的链路测试通知。') @@ -304,13 +258,13 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente setProjectsLoading(true) setProjectsError(null) try { - const data = await readJsonResponse(await fetch('/api/admin/notification-center/projects?includeRecipients=1')) + const data = await readJsonResponse(await fetch('/api/admin/notification-center/projects')) const payload = unwrapProxyData(data) const rawProjects = payload.projects || payload.data || [] const nextProjects = Array.isArray(rawProjects) ? rawProjects.map(normalizeProject) : [] + const defaultProjectCode = nextProjects.find((project) => project.isActive)?.projectCode || nextProjects[0]?.projectCode || '' setProjects(nextProjects) - setRecipientProjectCode((current) => current || nextProjects[0]?.projectCode || '') - setTestProjectCode((current) => current || nextProjects[0]?.projectCode || '') + setTestProjectCode((current) => (current && nextProjects.some((project) => project.projectCode === current) ? current : defaultProjectCode)) } catch (error) { const message = error instanceof Error ? error.message : '统一通知中心暂时不可用,请稍后重试' setProjectsError(message) @@ -351,23 +305,13 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente if (activeView === 'deliveries') void loadDeliveries() }, [activeView, loadDeliveries]) - const selectedRecipientProject = useMemo( - () => projects.find((project) => project.projectCode === recipientProjectCode) || null, - [projects, recipientProjectCode], - ) - const selectedTestProject = useMemo( () => projects.find((project) => project.projectCode === testProjectCode) || null, [projects, testProjectCode], ) - useEffect(() => { - setRecipientRows(selectedRecipientProject?.recipients || []) - }, [selectedRecipientProject]) - useEffect(() => { if (!selectedTestProject) return - setTestRecipientMode(selectedTestProject.recipientMode) setTestRecipient('') }, [selectedTestProject]) @@ -445,6 +389,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente setRevealingSecretCode(null) setDrawerOpen(false) setEditingCode(null) + setShowAdvancedProjectCode(false) setProjectForm(EMPTY_PROJECT_FORM) } @@ -465,6 +410,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente setRevealedSecret(null) setRevealingSecretCode(null) setEditingCode(null) + setShowAdvancedProjectCode(false) setProjectForm(EMPTY_PROJECT_FORM) setDrawerOpen(true) } @@ -474,13 +420,13 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente setRevealedSecret(null) setRevealingSecretCode(null) setEditingCode(project.projectCode) + setShowAdvancedProjectCode(false) setProjectForm({ name: project.name, projectCode: project.projectCode, isActive: project.isActive, - allowedHostsText: project.allowedHosts.join('\n'), + allowedHosts: project.allowedHosts, recipientMode: project.recipientMode, - recipients: project.recipients, }) setDrawerOpen(true) } @@ -524,58 +470,25 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente setRevealingSecretCode(null) } - const addProjectRecipient = () => { - setProjectForm((current) => ({ - ...current, - recipients: [...current.recipients, { alias: '', userId: '' }], - })) - } - - const updateProjectRecipient = (index: number, field: keyof NotificationRecipient, value: string) => { - setProjectForm((current) => ({ - ...current, - recipients: current.recipients.map((recipient, recipientIndex) => - recipientIndex === index ? { ...recipient, [field]: value } : recipient, - ), - })) - } - const saveProject = async () => { if (!projectForm.name.trim()) { - showToast('请填写项目中文名称', 'error') - return - } - if (!projectForm.projectCode.trim()) { - showToast('请填写 projectCode', 'error') + showToast('请填写项目名称', 'error') return } const normalizedCode = projectForm.projectCode.trim() - if (!/^[a-z][a-z0-9_-]{2,63}$/.test(normalizedCode)) { + if (normalizedCode && !/^[a-z][a-z0-9_-]{2,63}$/.test(normalizedCode)) { showToast('projectCode 需以小写字母开头,只能包含小写字母、数字、下划线和短横线', 'error') return } - const recipients = projectForm.recipients - .map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() })) - .filter((recipient) => recipient.alias || recipient.userId) - if (recipients.some((recipient) => !recipient.alias || !recipient.userId)) { - showToast('每条花名都必须同时填写花名和钉钉 userId', 'error') - return - } - const normalizedAliases = recipients.map((recipient) => recipient.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN')) - if (new Set(normalizedAliases).size !== normalizedAliases.length) { - showToast('同一项目内不能登记重复花名', 'error') - return - } setSavingProject(true) try { const payload = { name: projectForm.name.trim(), - projectCode: normalizedCode, isActive: projectForm.isActive, - allowedHosts: parseAllowedHosts(projectForm.allowedHostsText), + allowedHosts: projectForm.allowedHosts, recipientMode: projectForm.recipientMode, - recipients, + ...(normalizedCode ? { projectCode: normalizedCode } : {}), } const response = await fetch( editingCode @@ -592,10 +505,11 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente await loadProjects() if (!editingCode) { const responsePayload = unwrapProxyData(data) - setLastCreatedCode(normalizedCode) + const createdCode = String(responsePayload.projectCode || responsePayload.code || normalizedCode).trim() + if (createdCode) setLastCreatedCode(createdCode) if (responsePayload.secret) { setOneTimeSecret({ - projectCode: normalizedCode, + projectCode: createdCode, secret: String(responsePayload.secret), env: responsePayload.env && typeof responsePayload.env === 'object' ? responsePayload.env : {}, reason: 'created', @@ -637,59 +551,29 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente } } - const saveRecipients = async () => { - if (!recipientProjectCode) return - const rows = recipientRows - .map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() })) - .filter((recipient) => recipient.alias || recipient.userId) - if (rows.some((row) => !row.alias || !row.userId)) { - showToast('每条花名都必须同时填写花名和钉钉 userId', 'error') - return - } - const aliases = rows.map((row) => row.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN')) - if (new Set(aliases).size !== aliases.length) { - showToast('同一项目内不能登记重复花名', 'error') - return - } - setSavingRecipients(true) - try { - await readJsonResponse( - await fetch( - `/api/admin/notification-center/projects/${encodeURIComponent(recipientProjectCode)}/recipients`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ recipients: rows }), - }, - ), - ) - await loadProjects() - showToast('收件人花名已保存', 'success') - } catch (error) { - showToast(error instanceof Error ? error.message : '花名保存失败', 'error') - } finally { - setSavingRecipients(false) - } - } - - const resolvedTestRecipient = useMemo(() => { + const preparedTestRecipient = useMemo(() => { if (!selectedTestProject || !testRecipient.trim()) return null if (testRecipientMode === 'userId') { - return { label: `钉钉 userId:${testRecipient.trim()}`, userId: testRecipient.trim() } + return { + label: `钉钉 userId:${testRecipient.trim()}`, + recipientAlias: null, + recipientUserId: testRecipient.trim(), + } + } + return { + label: `花名:${testRecipient.trim()}`, + recipientAlias: testRecipient.trim(), + recipientUserId: null, } - const recipient = selectedTestProject.recipients.find( - (item) => item.alias === testRecipient, - ) - return recipient ? { label: `${recipient.alias}(${recipient.userId})`, userId: recipient.userId } : null }, [selectedTestProject, testRecipient, testRecipientMode]) const sendTestNotification = async () => { - if (!selectedTestProject || !resolvedTestRecipient) return + if (!selectedTestProject || !preparedTestRecipient) return setSendingTest(true) try { const payload = { - recipientAlias: testRecipientMode === 'alias' ? testRecipient : undefined, - recipientUserId: testRecipientMode === 'userId' ? testRecipient.trim() : undefined, + recipientAlias: preparedTestRecipient.recipientAlias || undefined, + recipientUserId: preparedTestRecipient.recipientUserId || undefined, title: testTitle.trim(), content: testBody.trim(), actionTitle: testButtonLabel.trim() || undefined, @@ -726,7 +610,6 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente } const enabledProjectCount = projects.filter((project) => project.isActive).length - const aliasCount = projects.reduce((total, project) => total + project.recipientCount, 0) const failedCount = projects.reduce( (total, project) => total + project.deliverySummary.recentFailedCount, 0, @@ -741,7 +624,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente 统一通知中心

- 管理由统一钉钉机器人发送员工单聊的接入项目、花名和投递状态。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。 + 这里维护统一钉钉单聊的接入项目、密钥、状态与投递记录。通知中心会统一在标题前加上项目名称,方便收件人识别来源;正文、按钮和跳转地址由外部项目自行决定。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。

) : (
-
- 项目允许跳转域名最近投递花名更新时间项目资料 +
+ 项目最近投递更新时间项目资料
{projects.map((project) => (
@@ -873,11 +755,6 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{project.projectCode}
-
- 允许域名: - {project.allowedHosts.length > 0 ? project.allowedHosts.slice(0, 2).join('、') : '未配置'} - {project.allowedHosts.length > 2 ? ` 等 ${project.allowedHosts.length} 个` : ''} -
{getStatusLabel(project.deliverySummary.lastStatus)} @@ -886,7 +763,6 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente

近期失败 {project.deliverySummary.recentFailedCount} 条

)}
-
花名:{project.recipientCount}
{formatDateTime(project.updatedAt)}
-
- ))} -
-
-
- - -
-
- )} - - )} - {activeView === 'test' && (

向指定员工发送测试通知

-

这是一次真实钉钉单聊。发送前会再次展示项目、收件人与通知内容。

+

这是一次真实钉钉单聊。通知中心会统一在标题前加上项目名称;正文、按钮和跳转地址由本次测试内容决定。

{projects.length === 0 ? (
请先新增并启用接入项目,再发送测试通知。
) : ( @@ -958,28 +798,25 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
- {testRecipientMode === 'alias' ? ( - - ) : ( - setTestRecipient(event.target.value)} placeholder="输入精确钉钉 userId" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /> - )} + setTestRecipient(event.target.value)} placeholder={testRecipientMode === 'alias' ? '输入当前花名册里的准确花名' : '输入精确钉钉 userId'} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
setTestTitle(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />