feat: simplify notification center onboarding
This commit is contained in:
parent
9f1006c3f8
commit
b917c09402
@ -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', '花名解析失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
@ -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(<NotificationCenterPanel showToast={showToast} />)
|
||||
|
||||
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(<NotificationCenterPanel showToast={showToast} />)
|
||||
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(<NotificationCenterPanel showToast={showToast} />)
|
||||
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(<NotificationCenterPanel showToast={showToast} />)
|
||||
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: '查看详情',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
const recipients = Array.isArray(item.recipients)
|
||||
? item.recipients.map(normalizeRecipient).filter((value): value is NotificationRecipient => Boolean(value))
|
||||
: []
|
||||
const summary = (item.deliverySummary || {}) as Record<string, unknown>
|
||||
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<RevealedSecret | null>(null)
|
||||
const [revealingSecretCode, setRevealingSecretCode] = useState<string | null>(null)
|
||||
const [recipientProjectCode, setRecipientProjectCode] = useState('')
|
||||
const [recipientRows, setRecipientRows] = useState<NotificationRecipient[]>([])
|
||||
const [savingRecipients, setSavingRecipients] = useState(false)
|
||||
const [showAdvancedProjectCode, setShowAdvancedProjectCode] = useState(false)
|
||||
const [testProjectCode, setTestProjectCode] = useState('')
|
||||
const [testRecipientMode, setTestRecipientMode] = useState<RecipientMode>('userId')
|
||||
const [testRecipientMode, setTestRecipientMode] = useState<RecipientMode>('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
|
||||
统一通知中心
|
||||
</h2>
|
||||
<p className="mt-1 max-w-[72ch] text-[14px] leading-6 text-[#66758b]">
|
||||
管理由统一钉钉机器人发送员工单聊的接入项目、花名和投递状态。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。
|
||||
这里维护统一钉钉单聊的接入项目、密钥、状态与投递记录。通知中心会统一在标题前加上项目名称,方便收件人识别来源;正文、按钮和跳转地址由外部项目自行决定。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@ -755,11 +638,10 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-2 divide-x divide-y divide-[#e2e8f0] overflow-hidden rounded-[10px] border border-[#e2e8f0] bg-[#f8fafc] lg:grid-cols-4 lg:divide-y-0">
|
||||
<dl className="mt-5 grid grid-cols-1 divide-y divide-[#e2e8f0] overflow-hidden rounded-[10px] border border-[#e2e8f0] bg-[#f8fafc] sm:grid-cols-3 sm:divide-x sm:divide-y-0">
|
||||
{[
|
||||
['接入项目', String(projects.length), '当前已登记'],
|
||||
['正在使用', String(enabledProjectCount), '已启用项目'],
|
||||
['收件人花名', String(aliasCount), '已登记映射'],
|
||||
['近期失败', String(failedCount), failedCount > 0 ? '需要处理' : '未发现异常'],
|
||||
].map(([label, value, hint]) => (
|
||||
<div key={label} className="min-w-0 px-4 py-3.5">
|
||||
@ -827,7 +709,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 id="notification-projects-heading" className="text-[16px] font-semibold text-[#101828]">接入项目</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">项目密钥默认隐藏,超级管理员可按需查看、复制;查看不会轮换或使旧密钥失效。</p>
|
||||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">这里只维护接入项目、启停状态和密钥;通知中心会统一在标题前加上项目名称,正文、按钮和跳转地址由外部项目自行决定。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@ -847,22 +729,22 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
<div className="mt-5 rounded-[10px] border border-dashed border-[#cfd8e3] px-5 py-10 text-center">
|
||||
<Settings2 className="mx-auto h-6 w-6 text-[#66758b]" aria-hidden="true" />
|
||||
<p className="mt-3 text-[14px] font-semibold text-[#101828]">还没有接入项目</p>
|
||||
<p className="mt-1 text-[13px] text-[#66758b]">新增项目后即可生成项目密钥、登记花名并发送测试通知。</p>
|
||||
<p className="mt-1 text-[13px] text-[#66758b]">新增项目后即可生成接入 URL、projectCode 和项目密钥,并向指定员工发送测试通知。</p>
|
||||
<button type="button" onClick={openCreateProject} className="mt-4 min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">
|
||||
新增第一个接入项目
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
|
||||
<div className="hidden grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(150px,0.9fr)_80px_130px_168px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-2.5 text-[12px] font-semibold text-[#66758b] md:grid">
|
||||
<span>项目</span><span>允许跳转域名</span><span>最近投递</span><span>花名</span><span>更新时间</span><span>项目资料</span>
|
||||
<div className="hidden grid-cols-[minmax(180px,1.2fr)_minmax(150px,0.9fr)_130px_168px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-2.5 text-[12px] font-semibold text-[#66758b] md:grid">
|
||||
<span>项目</span><span>最近投递</span><span>更新时间</span><span>项目资料</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[#e2e8f0] bg-white">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.projectCode}
|
||||
data-project-code={project.projectCode}
|
||||
className="grid min-w-0 gap-3 px-4 py-4 md:grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(150px,0.9fr)_80px_130px_168px] md:items-center"
|
||||
className="grid min-w-0 gap-3 px-4 py-4 md:grid-cols-[minmax(180px,1.2fr)_minmax(150px,0.9fr)_130px_168px] md:items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@ -873,11 +755,6 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
</div>
|
||||
<code className="mt-1 block truncate text-[12px] text-[#66758b]">{project.projectCode}</code>
|
||||
</div>
|
||||
<div className="min-w-0 text-[12px] leading-5 text-[#4f6077]">
|
||||
<span className="font-medium text-[#66758b] md:hidden">允许域名:</span>
|
||||
{project.allowedHosts.length > 0 ? project.allowedHosts.slice(0, 2).join('、') : '未配置'}
|
||||
{project.allowedHosts.length > 2 ? ` 等 ${project.allowedHosts.length} 个` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<span className={`inline-flex rounded-[6px] px-2 py-1 text-[11px] font-medium ${getStatusClassName(project.deliverySummary.lastStatus)}`}>
|
||||
{getStatusLabel(project.deliverySummary.lastStatus)}
|
||||
@ -886,7 +763,6 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
<p className="mt-1 text-[11px] text-[#b42318]">近期失败 {project.deliverySummary.recentFailedCount} 条</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[13px] text-[#4f6077]"><span className="font-medium text-[#66758b] md:hidden">花名:</span>{project.recipientCount}</div>
|
||||
<div className="text-[12px] leading-5 text-[#66758b]">{formatDateTime(project.updatedAt)}</div>
|
||||
<button
|
||||
type="button"
|
||||
@ -905,46 +781,10 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeView === 'recipients' && (
|
||||
<section aria-labelledby="notification-recipients-heading">
|
||||
<h3 id="notification-recipients-heading" className="text-[16px] font-semibold text-[#101828]">收件人花名</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">花名必须由管理员明确登记,系统不会模糊猜测收件人。</p>
|
||||
{projects.length === 0 ? (
|
||||
<div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-6 text-[13px] text-[#66758b]">请先在“接入项目”中新建项目,再登记收件人花名。</div>
|
||||
) : (
|
||||
<div className="mt-5 max-w-3xl">
|
||||
<label htmlFor="recipient-project" className="block text-[13px] font-semibold text-[#344054]">所属项目</label>
|
||||
<select id="recipient-project" value={recipientProjectCode} onChange={(event) => setRecipientProjectCode(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] text-[#101828] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
|
||||
{projects.map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name} · {project.projectCode}</option>)}
|
||||
</select>
|
||||
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
|
||||
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2 bg-[#f8fafc] px-3 py-2 text-[12px] font-semibold text-[#66758b]"><span>花名</span><span>钉钉 userId</span><span className="sr-only">删除</span></div>
|
||||
<div className="divide-y divide-[#e2e8f0]">
|
||||
{recipientRows.length === 0 && <p className="px-4 py-6 text-center text-[13px] text-[#66758b]">暂无花名。点击下方按钮登记明确的花名与钉钉 userId。</p>}
|
||||
{recipientRows.map((recipient, index) => (
|
||||
<div key={`${index}-${recipient.alias}`} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2 px-3 py-2">
|
||||
<label className="sr-only" htmlFor={`recipient-alias-${index}`}>第 {index + 1} 条花名</label>
|
||||
<input id={`recipient-alias-${index}`} value={recipient.alias} onChange={(event) => setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, alias: event.target.value } : row))} placeholder="例如:小王" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
|
||||
<label className="sr-only" htmlFor={`recipient-user-${index}`}>第 {index + 1} 条钉钉 userId</label>
|
||||
<input id={`recipient-user-${index}`} value={recipient.userId} onChange={(event) => setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, userId: event.target.value } : row))} placeholder="精确钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
|
||||
<button type="button" onClick={() => setRecipientRows((current) => current.filter((_, rowIndex) => rowIndex !== index))} aria-label={`删除花名 ${recipient.alias || index + 1}`} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setRecipientRows((current) => [...current, { alias: '', userId: '' }])} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><Plus className="h-4 w-4" aria-hidden="true" />登记花名</button>
|
||||
<button type="button" onClick={() => void saveRecipients()} disabled={savingRecipients} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:opacity-50">{savingRecipients ? '保存中…' : '保存花名清单'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeView === 'test' && (
|
||||
<section aria-labelledby="notification-test-heading" className="max-w-3xl">
|
||||
<h3 id="notification-test-heading" className="text-[16px] font-semibold text-[#101828]">向指定员工发送测试通知</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">这是一次真实钉钉单聊。发送前会再次展示项目、收件人与通知内容。</p>
|
||||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">这是一次真实钉钉单聊。通知中心会统一在标题前加上项目名称;正文、按钮和跳转地址由本次测试内容决定。</p>
|
||||
{projects.length === 0 ? (
|
||||
<div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-6 text-[13px] text-[#66758b]">请先新增并启用接入项目,再发送测试通知。</div>
|
||||
) : (
|
||||
@ -958,28 +798,25 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
<div>
|
||||
<label htmlFor="test-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">收件人方式</label>
|
||||
<select id="test-recipient-mode" value={testRecipientMode} onChange={(event) => { setTestRecipientMode(event.target.value as RecipientMode); setTestRecipient('') }} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
|
||||
<option value="userId">精确钉钉 userId</option><option value="alias">已登记花名</option>
|
||||
<option value="alias">准确花名</option><option value="userId">精确钉钉 userId</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="test-recipient" className="block text-[13px] font-semibold text-[#344054]">真实收件人</label>
|
||||
{testRecipientMode === 'alias' ? (
|
||||
<select id="test-recipient" value={testRecipient} onChange={(event) => setTestRecipient(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
|
||||
<option value="">请选择已登记花名</option>
|
||||
{(selectedTestProject?.recipients || []).map((recipient) => <option key={recipient.alias} value={recipient.alias}>{recipient.alias} · {recipient.userId}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input id="test-recipient" value={testRecipient} onChange={(event) => 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" />
|
||||
)}
|
||||
<input id="test-recipient" value={testRecipient} onChange={(event) => 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" />
|
||||
</div>
|
||||
<div className="sm:col-span-2"><label htmlFor="test-title" className="block text-[13px] font-semibold text-[#344054]">通知标题</label><input id="test-title" value={testTitle} onChange={(event) => 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" /></div>
|
||||
<div className="sm:col-span-2"><label htmlFor="test-body" className="block text-[13px] font-semibold text-[#344054]">通知正文</label><textarea id="test-body" value={testBody} onChange={(event) => setTestBody(event.target.value)} rows={4} className="mt-2 w-full resize-y rounded-[8px] border border-[#cfd8e3] px-3 py-2 text-[14px] leading-6 focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /></div>
|
||||
<div><label htmlFor="test-button" className="block text-[13px] font-semibold text-[#344054]">按钮文字</label><input id="test-button" value={testButtonLabel} onChange={(event) => setTestButtonLabel(event.target.value)} placeholder="可选" 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" /></div>
|
||||
<div><label htmlFor="test-url" className="block text-[13px] font-semibold text-[#344054]">按钮地址</label><input id="test-url" value={testActionUrl} onChange={(event) => setTestActionUrl(event.target.value)} placeholder="需属于 allowedHosts" 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" /></div>
|
||||
<div><label htmlFor="test-url" className="block text-[13px] font-semibold text-[#344054]">按钮地址</label><input id="test-url" value={testActionUrl} onChange={(event) => setTestActionUrl(event.target.value)} placeholder="可选,由外部项目自行决定" 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" /></div>
|
||||
<div className="sm:col-span-2 rounded-[8px] border border-[#d7e8fb] bg-[#f5f9ff] px-4 py-3 text-[13px] leading-6 text-[#315f91]">
|
||||
{resolvedTestRecipient ? `将通过“${selectedTestProject?.name}”向 ${resolvedTestRecipient.label} 发送一条真实钉钉单聊。` : '请选择或填写明确收件人。系统不会根据姓名模糊猜人。'}
|
||||
{preparedTestRecipient
|
||||
? testRecipientMode === 'alias'
|
||||
? `将通过“${selectedTestProject?.name}”按当前花名册精确解析 ${preparedTestRecipient.label},并发送一条真实钉钉单聊。`
|
||||
: `将通过“${selectedTestProject?.name}”向 ${preparedTestRecipient.label} 发送一条真实钉钉单聊。`
|
||||
: '请填写准确花名或精确钉钉 userId。系统不会根据姓名模糊猜人。'}
|
||||
</div>
|
||||
<div className="sm:col-span-2"><button type="button" onClick={() => setConfirmTestOpen(true)} disabled={!resolvedTestRecipient || !testTitle.trim() || !testBody.trim()} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"><Send className="h-4 w-4" aria-hidden="true" />向指定员工发送测试通知</button></div>
|
||||
<div className="sm:col-span-2"><button type="button" onClick={() => setConfirmTestOpen(true)} disabled={!preparedTestRecipient || !testTitle.trim() || !testBody.trim()} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"><Send className="h-4 w-4" aria-hidden="true" />向指定员工发送测试通知</button></div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@ -1008,16 +845,29 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
<div className="fixed inset-0 z-[70] flex justify-end bg-[#06192d]/35" onMouseDown={(event) => { if (event.currentTarget === event.target) closeDrawer() }}>
|
||||
<section data-nc-drawer role="dialog" aria-modal="true" aria-labelledby="project-drawer-title" className="h-full w-full max-w-[560px] overflow-y-auto bg-white shadow-[0_18px_48px_rgba(6,25,45,0.2)]">
|
||||
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-[#e2e8f0] bg-white px-5 py-4">
|
||||
<div><h3 id="project-drawer-title" className="text-[18px] font-semibold text-[#101828]">{editingCode ? '查看配置 / 编辑资料' : '新增接入项目'}</h3><p className="mt-1 text-[12px] text-[#66758b]">{editingCode ? '查看或修改名称、域名、状态和收件人方式。' : '创建后生成长期有效、默认隐藏的项目密钥。'}</p></div>
|
||||
<div><h3 id="project-drawer-title" className="text-[18px] font-semibold text-[#101828]">{editingCode ? '查看配置 / 编辑资料' : '新增接入项目'}</h3><p className="mt-1 text-[12px] text-[#66758b]">{editingCode ? '查看或修改项目名称与启停状态,密钥按需查看。' : '只需填写项目名称;通知中心会自动生成接入 code 和项目密钥。'}</p></div>
|
||||
<button type="button" onClick={closeDrawer} aria-label="关闭项目编辑" className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#4f6077] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-5 w-5" aria-hidden="true" /></button>
|
||||
</div>
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
<div><label htmlFor="project-name" className="block text-[13px] font-semibold text-[#344054]">项目中文名称</label><input id="project-name" autoFocus value={projectForm.name} onChange={(event) => setProjectForm((current) => ({ ...current, name: event.target.value }))} placeholder="例如:固定资产系统" 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" /></div>
|
||||
<div><label htmlFor="project-code" className="block text-[13px] font-semibold text-[#344054]">projectCode</label><input id="project-code" disabled={Boolean(editingCode)} value={projectForm.projectCode} onChange={(event) => setProjectForm((current) => ({ ...current, projectCode: event.target.value }))} placeholder="例如:fixed_assets" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 font-mono text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20 disabled:bg-[#f2f4f7] disabled:text-[#66758b]" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">创建后不可修改,用于签名和调用方环境变量。</p></div>
|
||||
<div><label htmlFor="project-hosts" className="block text-[13px] font-semibold text-[#344054]">允许跳转域名 allowedHosts</label><textarea id="project-hosts" rows={4} value={projectForm.allowedHostsText} onChange={(event) => setProjectForm((current) => ({ ...current, allowedHostsText: event.target.value }))} placeholder={'example.com\ninternal.example.com'} className="mt-2 w-full resize-y rounded-[8px] border border-[#cfd8e3] px-3 py-2 font-mono text-[13px] leading-6 focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">每行一个主机名,不含协议、端口或路径。通知按钮只能跳转到这些域名。</p></div>
|
||||
<div><label htmlFor="project-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">默认收件人方式</label><select id="project-recipient-mode" value={projectForm.recipientMode} onChange={(event) => setProjectForm((current) => ({ ...current, recipientMode: event.target.value as RecipientMode }))} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="userId">精确钉钉 userId</option><option value="alias">已登记花名</option></select></div>
|
||||
<div><label htmlFor="project-name" className="block text-[13px] font-semibold text-[#344054]">项目名称</label><input id="project-name" autoFocus value={projectForm.name} onChange={(event) => setProjectForm((current) => ({ ...current, name: event.target.value }))} placeholder="例如:固定资产系统" 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" /></div>
|
||||
{editingCode ? (
|
||||
<div><label htmlFor="project-code" className="block text-[13px] font-semibold text-[#344054]">projectCode</label><input id="project-code" disabled value={projectForm.projectCode} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 font-mono text-[13px] text-[#66758b] focus:outline-none disabled:bg-[#f2f4f7]" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">创建后固定,用于接入 URL、签名和调用方环境变量。</p></div>
|
||||
) : (
|
||||
<div className="rounded-[8px] border border-[#e2e8f0] bg-[#f8fafc] p-4">
|
||||
<button type="button" onClick={() => setShowAdvancedProjectCode((current) => !current)} aria-expanded={showAdvancedProjectCode} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-2 text-[13px] font-medium text-[#344054] hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">
|
||||
{showAdvancedProjectCode ? '收起高级设置' : '高级设置:自定义 projectCode'}
|
||||
</button>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[#66758b]">默认留空,由通知中心后端自动生成。</p>
|
||||
{showAdvancedProjectCode && (
|
||||
<div className="mt-3">
|
||||
<label htmlFor="project-code" className="block text-[13px] font-semibold text-[#344054]">projectCode</label>
|
||||
<input id="project-code" value={projectForm.projectCode} onChange={(event) => setProjectForm((current) => ({ ...current, projectCode: event.target.value }))} placeholder="例如:fixed_assets" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 font-mono text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-3 rounded-[8px] border border-[#e2e8f0] px-3 py-3 text-[13px] font-medium text-[#344054]"><input type="checkbox" checked={projectForm.isActive} onChange={(event) => setProjectForm((current) => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 rounded border-[#b8c2ce] text-[#1677ff] focus:ring-[#1677ff]" />保存后启用该项目</label>
|
||||
{!editingCode && projectForm.recipientMode === 'alias' && <div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">初始花名清单</span><button type="button" onClick={addProjectRecipient} className="inline-flex min-h-10 items-center gap-1 rounded-[8px] px-2 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><Plus className="h-4 w-4" aria-hidden="true" />添加花名</button></div><div className="mt-2 space-y-2">{projectForm.recipients.map((recipient, index) => <div key={index} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2"><input aria-label={`第 ${index + 1} 条花名`} value={recipient.alias} onChange={(event) => updateProjectRecipient(index, 'alias', event.target.value)} placeholder="花名" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><input aria-label={`第 ${index + 1} 条钉钉 userId`} value={recipient.userId} onChange={(event) => updateProjectRecipient(index, 'userId', event.target.value)} placeholder="钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><button type="button" aria-label={`删除第 ${index + 1} 条花名`} onClick={() => setProjectForm((current) => ({ ...current, recipients: current.recipients.filter((_, rowIndex) => rowIndex !== index) }))} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button></div>)}</div></div>}
|
||||
<div className="rounded-[8px] border border-[#d7e8fb] bg-[#f5f9ff] px-4 py-3 text-[12px] leading-5 text-[#315f91]">通知中心会统一在标题前加上项目名称,正文、按钮和跳转地址由外部项目后端自行决定。</div>
|
||||
{editingCode && (
|
||||
<div className="rounded-[8px] border border-[#e2e8f0] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
@ -1102,9 +952,9 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
||||
|
||||
{rotateProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="rotate-secret-title" className="w-full max-w-md rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#fff7e8] text-[#9a4d08]"><AlertTriangle className="h-5 w-5" aria-hidden="true" /></div><h3 id="rotate-secret-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认轮换“{rotateProject.name}”的项目密钥?</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">轮换完成后,所有仍使用旧密钥的调用会立即鉴权失败。请确认你能够马上更新调用方环境变量。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setRotateProject(null)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void confirmRotateSecret()} disabled={rotatingSecret} className="min-h-10 rounded-[8px] bg-[#b42318] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{rotatingSecret ? '轮换中…' : '确认轮换,旧密钥立即失效'}</button></div></section></div>}
|
||||
|
||||
{confirmTestOpen && resolvedTestRecipient && selectedTestProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="confirm-test-title" className="w-full max-w-lg rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e6f4ff] text-[#1677ff]"><Send className="h-5 w-5" aria-hidden="true" /></div><h3 id="confirm-test-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认发送真实测试通知</h3><dl className="mt-4 grid gap-3 rounded-[8px] bg-[#f8fafc] p-4 text-[13px]"><div><dt className="text-[#66758b]">项目</dt><dd className="mt-1 font-medium text-[#101828]">{selectedTestProject.name} · {selectedTestProject.projectCode}</dd></div><div><dt className="text-[#66758b]">真实收件人</dt><dd className="mt-1 font-medium text-[#101828]">{resolvedTestRecipient.label}</dd></div><div><dt className="text-[#66758b]">标题</dt><dd className="mt-1 font-medium text-[#101828]">{testTitle}</dd></div></dl><p className="mt-3 text-[13px] leading-6 text-[#9a4d08]">确认后会真实调用钉钉,不是页面预览。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setConfirmTestOpen(false)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void sendTestNotification()} disabled={sendingTest} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{sendingTest ? '正在提交…' : '确认发送给该员工'}</button></div></section></div>}
|
||||
{confirmTestOpen && preparedTestRecipient && selectedTestProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="confirm-test-title" className="w-full max-w-lg rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e6f4ff] text-[#1677ff]"><Send className="h-5 w-5" aria-hidden="true" /></div><h3 id="confirm-test-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认发送真实测试通知</h3><dl className="mt-4 grid gap-3 rounded-[8px] bg-[#f8fafc] p-4 text-[13px]"><div><dt className="text-[#66758b]">项目</dt><dd className="mt-1 font-medium text-[#101828]">{selectedTestProject.name} · {selectedTestProject.projectCode}</dd></div><div><dt className="text-[#66758b]">真实收件人</dt><dd className="mt-1 font-medium text-[#101828]">{preparedTestRecipient.label}</dd></div><div><dt className="text-[#66758b]">标题</dt><dd className="mt-1 font-medium text-[#101828]">{testTitle}</dd></div></dl><p className="mt-3 text-[13px] leading-6 text-[#9a4d08]">通知中心会统一在标题前加上项目名称;确认后会真实调用钉钉,不是页面预览。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setConfirmTestOpen(false)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void sendTestNotification()} disabled={sendingTest} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{sendingTest ? '正在提交…' : '确认发送给该员工'}</button></div></section></div>}
|
||||
|
||||
{oneTimeSecret && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-[#06192d]/45 px-4"><section data-secret-reveal role="dialog" aria-modal="true" aria-labelledby="one-time-secret-title" className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.22)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e8f7f2] text-[#087052]"><Check className="h-5 w-5" aria-hidden="true" /></div><h3 id="one-time-secret-title" className="mt-4 text-[18px] font-semibold text-[#101828]">{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">这是当前有效的项目密钥。关闭后仍可在“查看配置 / 编辑资料”中按需查看;请只复制到调用方后端的安全环境变量。</p><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">项目密钥</span><button type="button" onClick={() => void copyText(oneTimeSecret.secret, '项目密钥')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制</button></div><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.secret}</code></div><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '环境变量')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制全部</button></div><pre className="mt-2 overflow-x-auto rounded-[8px] border border-[#d9e0e7] bg-[#06192d] p-3 text-[12px] leading-6 text-[#eaf1f8]">{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}</pre></div><div className="mt-5 flex justify-end"><button type="button" onClick={() => setOneTimeSecret(null)} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white">关闭</button></div></section></div>}
|
||||
{oneTimeSecret && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-[#06192d]/45 px-4"><section data-secret-reveal role="dialog" aria-modal="true" aria-labelledby="one-time-secret-title" className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.22)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e8f7f2] text-[#087052]"><Check className="h-5 w-5" aria-hidden="true" /></div><h3 id="one-time-secret-title" className="mt-4 text-[18px] font-semibold text-[#101828]">{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">请把下面的 URL、projectCode 和项目密钥保存到调用方后端的安全环境变量中。关闭后仍可在“查看配置 / 编辑资料”里按需查看当前密钥。</p><div className="mt-5 grid gap-4 sm:grid-cols-2"><div className="sm:col-span-2"><span className="text-[13px] font-semibold text-[#344054]">统一通知中心 URL</span><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.env.YYD_NOTIFICATION_CENTER_URL || '后端未返回'}</code></div><div><span className="text-[13px] font-semibold text-[#344054]">projectCode</span><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.projectCode || oneTimeSecret.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE || '后端未返回'}</code></div><div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">项目密钥</span><button type="button" onClick={() => void copyText(oneTimeSecret.secret, '项目密钥')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制</button></div><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.secret}</code></div></div><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '环境变量')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制全部</button></div><pre className="mt-2 overflow-x-auto rounded-[8px] border border-[#d9e0e7] bg-[#06192d] p-3 text-[12px] leading-6 text-[#eaf1f8]">{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}</pre></div><div className="mt-5 flex justify-end"><button type="button" onClick={() => setOneTimeSecret(null)} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white">关闭</button></div></section></div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
listNotificationCenterProjectRecipients,
|
||||
listNotificationCenterProjects,
|
||||
revealNotificationCenterProjectSecret,
|
||||
resolveNotificationCenterRecipientAlias,
|
||||
rotateNotificationCenterProjectSecret,
|
||||
sendNotificationCenterProjectTest,
|
||||
updateNotificationCenterProject,
|
||||
@ -24,6 +25,7 @@ import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-c
|
||||
import { POST as postProjectTestRoute } from '@/app/api/admin/notification-center/projects/[code]/test/route'
|
||||
import { GET as getDeliveriesRoute } from '@/app/api/admin/notification-center/deliveries/route'
|
||||
import { GET as getAuditsRoute } from '@/app/api/admin/notification-center/audits/route'
|
||||
import { POST as postRecipientResolveRoute } from '@/app/api/internal/notification-center/recipients/resolve/route'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
@ -60,11 +62,13 @@ jest.mock('@/lib/notification-center-admin', () => ({
|
||||
sendNotificationCenterProjectTest: jest.fn(),
|
||||
listNotificationCenterDeliveries: jest.fn(),
|
||||
listNotificationCenterAudits: jest.fn(),
|
||||
resolveNotificationCenterRecipientAlias: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('notification-center admin routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret'
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
@ -240,6 +244,68 @@ describe('notification-center admin routes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /internal/notification-center/recipients/resolve 仅允许服务端 Bearer 调用并返回最小结果', async () => {
|
||||
;(resolveNotificationCenterRecipientAlias as jest.Mock).mockResolvedValue({
|
||||
recipientUserId: 'ding-user-1',
|
||||
})
|
||||
|
||||
const response = await postRecipientResolveRoute(
|
||||
new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer server-admin-secret',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ recipientAlias: '测试员工' }),
|
||||
}) as any,
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||||
expect(body).toEqual({ recipientUserId: 'ding-user-1' })
|
||||
expect(resolveNotificationCenterRecipientAlias).toHaveBeenCalledWith('测试员工')
|
||||
})
|
||||
|
||||
test('POST /internal/notification-center/recipients/resolve 对未授权和未命中返回明确定义错误', async () => {
|
||||
const unauthorized = await postRecipientResolveRoute(
|
||||
new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer wrong-secret',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ recipientAlias: '测试员工' }),
|
||||
}) as any,
|
||||
)
|
||||
const unauthorizedBody = await unauthorized.json()
|
||||
|
||||
expect(unauthorized.status).toBe(401)
|
||||
expect(unauthorizedBody).toEqual({
|
||||
error: { code: 'UNAUTHORIZED', message: '未授权' },
|
||||
})
|
||||
expect(resolveNotificationCenterRecipientAlias).not.toHaveBeenCalled()
|
||||
|
||||
;(resolveNotificationCenterRecipientAlias as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const notFound = await postRecipientResolveRoute(
|
||||
new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer server-admin-secret',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ recipientAlias: '查无此人' }),
|
||||
}) as any,
|
||||
)
|
||||
const notFoundBody = await notFound.json()
|
||||
|
||||
expect(notFound.status).toBe(404)
|
||||
expect(notFoundBody).toEqual({
|
||||
error: { code: 'RECIPIENT_ALIAS_NOT_FOUND', message: '花名未找到对应钉钉 userId' },
|
||||
})
|
||||
})
|
||||
|
||||
test('下游错误会返回中文错误且不泄漏管理员密钥', async () => {
|
||||
const error = new NotificationCenterAdminError('通知中心管理员鉴权失败,请检查服务端密钥配置', {
|
||||
statusCode: 502,
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
performanceSurveyEmployeeProfile: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
createNotificationCenterProject,
|
||||
listNotificationCenterProjectRecipients,
|
||||
listNotificationCenterProjects,
|
||||
NotificationCenterAdminError,
|
||||
revealNotificationCenterProjectSecret,
|
||||
resolveNotificationCenterRecipientAlias,
|
||||
rotateNotificationCenterProjectSecret,
|
||||
sendNotificationCenterProjectTest,
|
||||
updateNotificationCenterProject,
|
||||
} from '@/lib/notification-center-admin'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
describe('notification-center-admin client', () => {
|
||||
const actor = {
|
||||
@ -21,12 +31,40 @@ describe('notification-center-admin client', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset()
|
||||
process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL = 'https://notify-admin.example.com'
|
||||
process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret'
|
||||
delete process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS
|
||||
global.fetch = jest.fn()
|
||||
})
|
||||
|
||||
test('精确花名解析只返回唯一有效的钉钉 userId', async () => {
|
||||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([
|
||||
{ dingtalkUserId: ' ding-user-1 ' },
|
||||
{ dingtalkUserId: 'ding-user-1' },
|
||||
{ dingtalkUserId: null },
|
||||
])
|
||||
|
||||
await expect(resolveNotificationCenterRecipientAlias(' 测试员工 ')).resolves.toEqual({
|
||||
recipientUserId: 'ding-user-1',
|
||||
})
|
||||
|
||||
expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith({
|
||||
where: { flowerName: '测试员工' },
|
||||
select: { dingtalkUserId: true },
|
||||
take: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test('花名对应多个不同钉钉 userId 时不返回模糊结果', async () => {
|
||||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([
|
||||
{ dingtalkUserId: 'ding-user-1' },
|
||||
{ dingtalkUserId: 'ding-user-2' },
|
||||
])
|
||||
|
||||
await expect(resolveNotificationCenterRecipientAlias('重名员工')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('列表查询会透传操作人身份并剥离 project/admin secret', async () => {
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
type NotificationCenterAdminMethod = 'GET' | 'POST' | 'PUT' | 'PATCH'
|
||||
|
||||
export type NotificationCenterAdminActor = {
|
||||
@ -58,6 +60,40 @@ function clean(value: unknown) {
|
||||
return text || null
|
||||
}
|
||||
|
||||
export async function resolveNotificationCenterRecipientAlias(alias: string): Promise<{
|
||||
recipientUserId: string
|
||||
} | null> {
|
||||
const normalizedAlias = clean(alias)
|
||||
if (!normalizedAlias) {
|
||||
throw new NotificationCenterAdminError('花名不能为空', {
|
||||
statusCode: 422,
|
||||
code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION',
|
||||
})
|
||||
}
|
||||
|
||||
const rows = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: { flowerName: normalizedAlias },
|
||||
select: { dingtalkUserId: true },
|
||||
take: 5,
|
||||
})
|
||||
|
||||
const candidateUserIds = Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((row) => clean(row.dingtalkUserId))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
)
|
||||
|
||||
if (candidateUserIds.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
recipientUserId: candidateUserIds[0],
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const timeoutCandidate = Number(process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS || '')
|
||||
return {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user