forked from wangziqi/gongxue-base
feat: add crm dead-letter operations and benchmark summary
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -795,8 +795,49 @@ export interface CrmQueueItem {
|
||||
sentAt?: string | null;
|
||||
source?: string | null;
|
||||
targetUrl?: string | null;
|
||||
provider?: string | null;
|
||||
lastAttemptAt?: string | null;
|
||||
lastResponseSummary?: string | null;
|
||||
deadLetteredAt?: string | null;
|
||||
ignoredAt?: string | null;
|
||||
ignoredBy?: string | null;
|
||||
lastOperatorUserId?: string | null;
|
||||
lastOperatorAction?: string | null;
|
||||
lastOperatorNote?: string | null;
|
||||
lastOperatorAt?: string | null;
|
||||
operatorMetadata?: Record<string, unknown>;
|
||||
payload?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CrmDeadLettersPayload {
|
||||
summary?: {
|
||||
failed?: number;
|
||||
discarded?: number;
|
||||
ignored?: number;
|
||||
total?: number;
|
||||
oldestOpenAt?: string | null;
|
||||
};
|
||||
items?: CrmQueueItem[];
|
||||
}
|
||||
|
||||
export interface CrmQueueLogItem {
|
||||
id: string;
|
||||
queueId?: string | null;
|
||||
recordId?: string | null;
|
||||
httpCode?: number | null;
|
||||
outcome?: string | null;
|
||||
errorMessage?: string | null;
|
||||
leadId?: string | null;
|
||||
requestBody?: string | null;
|
||||
requestPayload?: Record<string, unknown>;
|
||||
responseSummary?: string | null;
|
||||
signedAt?: string | null;
|
||||
attempt?: number | null;
|
||||
operatorUserId?: string | null;
|
||||
operation?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CommissionSettingsItem {
|
||||
@@ -1599,6 +1640,34 @@ export async function loadCrmQueue(limit = 20, status?: string) {
|
||||
return apiRequest<{ items?: CrmQueueItem[] }>('/api/crm/queue', { query: { limit, status } });
|
||||
}
|
||||
|
||||
export async function loadCrmDeadLetters(input: {
|
||||
limit?: number;
|
||||
status?: 'failed' | 'discarded';
|
||||
source?: string;
|
||||
} = {}) {
|
||||
return apiRequest<CrmDeadLettersPayload>('/api/crm/dead-letters', {
|
||||
query: { ...input, limit: input.limit || 30 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCrmQueueLogs(queueId: string, limit = 30) {
|
||||
return apiRequest<{ item?: Record<string, unknown>; items?: CrmQueueLogItem[] }>('/api/crm/queue/logs', {
|
||||
query: { queueId, limit },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCrmQueueAction(input: {
|
||||
queueId: string;
|
||||
action: 'retry' | 'ignore';
|
||||
note?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return apiRequest<{ item?: CrmQueueItem }>('/api/crm/queue/action', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCommissionSettings() {
|
||||
return apiRequest<{ item?: CommissionSettingsItem }>('/api/commission/settings');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user