feat: add crm dead-letter operations and benchmark summary

This commit is contained in:
Codex
2026-06-30 23:07:43 +08:00
parent 90ad9e90a3
commit 9891fe9ef3
22 changed files with 1284 additions and 110 deletions

View File

@@ -99,6 +99,18 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.admin-metric-grid.compact .admin-metric {
min-height: 88px;
padding: 14px;
}
.admin-metric {
min-height: 112px;
padding: 18px;
@@ -237,6 +249,29 @@
color: #fff;
}
.admin-mini-button.danger {
border-color: #dc2626;
background: #fef2f2;
color: #b91c1c;
}
.admin-sub-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 12px;
border-radius: 8px;
background: #f8fafc;
}
.admin-sub-row {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.admin-error {
display: block;
margin-top: 12px;

View File

@@ -16,7 +16,9 @@ import {
loadCouponReport,
loadCoupons,
loadCrmConfig,
loadCrmDeadLetters,
loadCrmQueue,
loadCrmQueueLogs,
loadFeedbackReport,
loadPointActivityClaims,
loadPointActivityTasks,
@@ -28,6 +30,7 @@ import {
updateCommissionSettings,
updateCommissionSettlementProofStatus,
updateCommissionSettlementStatus,
updateCrmQueueAction,
updateMemberCommissionRate,
upsertCoupon,
upsertCrmConfig,
@@ -43,6 +46,8 @@ import {
type CouponRedemptionItem,
type CouponReport,
type CrmConfigItem,
type CrmDeadLettersPayload,
type CrmQueueLogItem,
type CrmQueueItem,
type FeedbackReport,
type PointActivityTaskItem,
@@ -296,6 +301,8 @@ export default function TenantMarketingPage() {
const [codes, setCodes] = useState<Record<string, unknown>[]>([]);
const [crmConfig, setCrmConfig] = useState<CrmConfigItem | null>(null);
const [crmQueue, setCrmQueue] = useState<CrmQueueItem[]>([]);
const [crmDeadLetters, setCrmDeadLetters] = useState<CrmDeadLettersPayload>({});
const [crmLogs, setCrmLogs] = useState<Record<string, CrmQueueLogItem[]>>({});
const [commissionSettings, setCommissionSettings] = useState<CommissionSettingsItem | null>(null);
const [commission, setCommission] = useState<CommissionSummaryItem | null>(null);
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
@@ -370,6 +377,7 @@ export default function TenantMarketingPage() {
codePayload,
crmConfigPayload,
crmPayload,
crmDeadLettersPayload,
commissionSettingsPayload,
commissionPayload,
orderPayload,
@@ -399,6 +407,7 @@ export default function TenantMarketingPage() {
loadActivationCodes(20).catch(() => ({ items: [] })),
loadCrmConfig().catch(() => ({ item: null })),
loadCrmQueue(20, crmStatus || undefined).catch(() => ({ items: [] })),
loadCrmDeadLetters({ limit: 20 }).catch(() => ({ items: [], summary: {} })),
loadCommissionSettings().catch(() => ({ item: null })),
loadCommissionSummary(period).catch(() => ({ item: null })),
loadCommissionOrders({ ...period, limit: 20 }).catch(() => ({ items: [] })),
@@ -426,6 +435,7 @@ export default function TenantMarketingPage() {
setCodes(codePayload.items || []);
setCrmConfig(nextCrm);
setCrmQueue(crmPayload.items || []);
setCrmDeadLetters(crmDeadLettersPayload || {});
setCommissionSettings(nextSettings);
setCommission(commissionPayload.item || null);
setCommissionOrders(orderPayload.items || []);
@@ -753,8 +763,12 @@ export default function TenantMarketingPage() {
setBusy('crm-queue');
setError('');
try {
const payload = await loadCrmQueue(30, status || undefined);
const [payload, deadLettersPayload] = await Promise.all([
loadCrmQueue(30, status || undefined),
loadCrmDeadLetters({ limit: 20 }),
]);
setCrmQueue(payload.items || []);
setCrmDeadLetters(deadLettersPayload || {});
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'CRM 队列加载失败');
} finally {
@@ -762,6 +776,45 @@ export default function TenantMarketingPage() {
}
}
async function showCrmQueueLogs(item: CrmQueueItem) {
setBusy(`crm-log:${item.id}`);
setError('');
try {
const payload = await loadCrmQueueLogs(item.id, 20);
setCrmLogs(prev => ({ ...prev, [item.id]: payload.items || [] }));
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'CRM 日志加载失败');
} finally {
setBusy('');
}
}
async function handleCrmQueueAction(item: CrmQueueItem, action: 'retry' | 'ignore') {
const confirmed = await Taro.showModal({
title: action === 'retry' ? '重试 CRM 任务' : '忽略 CRM 任务',
content: `${item.source || 'CRM'} · ${item.status || 'pending'} · 尝试 ${item.attempts || 0}`,
confirmText: action === 'retry' ? '重试' : '忽略',
cancelText: '取消',
});
if (!confirmed.confirm) return;
setBusy(`crm-action:${item.id}:${action}`);
setError('');
try {
await updateCrmQueueAction({
queueId: item.id,
action,
note: action === 'retry' ? '租户后台手动重试' : '租户后台确认忽略',
metadata: { source: 'taro-tenant-admin' },
});
Taro.showToast({ title: action === 'retry' ? '已重新入队' : '已忽略', icon: 'success' });
await refreshCrmQueue(crmStatus);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'CRM 队列操作失败');
} finally {
setBusy('');
}
}
async function refreshUserNotifications(override: Partial<typeof userNotificationFilter> = {}) {
const nextFilter = { ...userNotificationFilter, ...override };
setUserNotificationFilter(nextFilter);
@@ -1366,6 +1419,11 @@ export default function TenantMarketingPage() {
<View className='admin-section'>
<Text className='admin-section-title'>CRM </Text>
<View className='admin-metric-grid compact'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(crmDeadLetters.summary?.total || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(crmDeadLetters.summary?.failed || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(crmDeadLetters.summary?.ignored || 0)}</Text></View>
</View>
<View className='admin-actions compact'>
{['', 'pending', 'sent', 'failed', 'discarded'].map(status => (
<Button
@@ -1384,12 +1442,47 @@ export default function TenantMarketingPage() {
{crmQueue.slice(0, 10).map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.status || 'pending'} · {item.source || 'lead'}</Text>
<Text className='admin-row-meta'> {item.attempts || 0} · HTTP {item.lastHttpCode || '-'} · {item.sentAt || item.nextAttemptAt || item.createdAt || '-'}</Text>
<Text className='admin-row-meta'> {item.attempts || 0} · HTTP {item.lastHttpCode || '-'} · {shortDate(item.sentAt || item.nextAttemptAt || item.deadLetteredAt || item.createdAt)}</Text>
{item.provider || item.targetUrl ? <Text className='admin-row-meta break-line'>{item.provider || 'webhook'} · {item.targetUrl || '未配置目标'}</Text> : null}
{item.lastError ? <Text className='admin-row-meta break-line'>{item.lastError}</Text> : null}
{item.lastOperatorAction ? <Text className='admin-row-meta'> {item.lastOperatorAction} · {shortDate(item.lastOperatorAt)} · {item.lastOperatorNote || '无备注'}</Text> : null}
<View className='admin-actions compact'>
<Button className='admin-mini-button' loading={busy === `crm-log:${item.id}`} onClick={() => showCrmQueueLogs(item)}></Button>
{item.status === 'failed' || item.status === 'discarded' ? <Button className='admin-mini-button primary' loading={busy === `crm-action:${item.id}:retry`} onClick={() => handleCrmQueueAction(item, 'retry')}></Button> : null}
{item.status === 'failed' ? <Button className='admin-mini-button danger' loading={busy === `crm-action:${item.id}:ignore`} onClick={() => handleCrmQueueAction(item, 'ignore')}></Button> : null}
</View>
{crmLogs[item.id]?.length ? (
<View className='admin-sub-list'>
{crmLogs[item.id].slice(0, 5).map(log => (
<View className='admin-sub-row' key={log.id}>
<Text className='admin-row-meta'>{log.outcome || log.operation || 'log'} · {log.attempt || 0} · HTTP {log.httpCode || '-'} · {shortDate(log.createdAt || log.signedAt)}</Text>
{log.errorMessage ? <Text className='admin-row-meta break-line'>{log.errorMessage}</Text> : null}
{log.responseSummary ? <Text className='admin-row-meta break-line'>{log.responseSummary}</Text> : null}
</View>
))}
</View>
) : null}
</View>
))}
</View>
{!crmQueue.length ? <View className='admin-empty'> CRM </View> : null}
{crmDeadLetters.items?.length ? (
<View className='admin-list'>
<Text className='admin-section-title'></Text>
{crmDeadLetters.items.slice(0, 5).map(item => (
<View className='admin-row' key={`dead-${item.id}`}>
<Text className='admin-row-main'>{item.status || 'failed'} · {item.source || 'CRM'}</Text>
<Text className='admin-row-meta'> {shortDate(item.deadLetteredAt || item.updatedAt || item.createdAt)} · {item.attempts || 0} </Text>
{item.lastError ? <Text className='admin-row-meta break-line'>{item.lastError}</Text> : null}
<View className='admin-actions compact'>
<Button className='admin-mini-button' loading={busy === `crm-log:${item.id}`} onClick={() => showCrmQueueLogs(item)}></Button>
<Button className='admin-mini-button primary' loading={busy === `crm-action:${item.id}:retry`} onClick={() => handleCrmQueueAction(item, 'retry')}></Button>
{item.status === 'failed' ? <Button className='admin-mini-button danger' loading={busy === `crm-action:${item.id}:ignore`} onClick={() => handleCrmQueueAction(item, 'ignore')}></Button> : null}
</View>
</View>
))}
</View>
) : null}
</View>
<View className='admin-section'>