feat: add feedback operations report

This commit is contained in:
Codex
2026-06-30 15:38:47 +08:00
parent 87f4cf9c4e
commit ddad2ac04d
17 changed files with 593 additions and 44 deletions

View File

@@ -17,6 +17,7 @@ import {
loadCoupons,
loadCrmConfig,
loadCrmQueue,
loadFeedbackReport,
loadPointActivityClaims,
loadPointActivityTasks,
loadPointExchangeItems,
@@ -43,6 +44,7 @@ import {
type CouponReport,
type CrmConfigItem,
type CrmQueueItem,
type FeedbackReport,
type PointActivityTaskItem,
type PointExchangeItem,
type PointsRiskReport,
@@ -307,6 +309,7 @@ export default function TenantMarketingPage() {
const [pointExchangeItems, setPointExchangeItems] = useState<PointExchangeItem[]>([]);
const [pointExchangeOrders, setPointExchangeOrders] = useState<Record<string, unknown>[]>([]);
const [pointsRiskReport, setPointsRiskReport] = useState<PointsRiskReport | null>(null);
const [feedbackReport, setFeedbackReport] = useState<FeedbackReport | null>(null);
const [crmForm, setCrmForm] = useState({
enabled: false,
url: '',
@@ -378,6 +381,7 @@ export default function TenantMarketingPage() {
pointExchangePayload,
pointExchangeOrderPayload,
pointsRiskPayload,
feedbackReportPayload,
] = await Promise.all([
loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })),
loadCouponReport({
@@ -411,6 +415,7 @@ export default function TenantMarketingPage() {
limit: 30,
}).catch(() => ({ items: [] })),
loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
loadFeedbackReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
]);
const nextCrm = crmConfigPayload.item || null;
const nextSettings = commissionSettingsPayload.item || null;
@@ -433,6 +438,7 @@ export default function TenantMarketingPage() {
setPointExchangeItems(pointExchangePayload.items || []);
setPointExchangeOrders(pointExchangeOrderPayload.items || []);
setPointsRiskReport(pointsRiskPayload.item || null);
setFeedbackReport(feedbackReportPayload.item || null);
if (nextCrm) {
setCrmForm({
enabled: nextCrm.enabled === true,
@@ -610,7 +616,7 @@ export default function TenantMarketingPage() {
setBusy('points');
setError('');
try {
const [taskPayload, claimPayload, exchangePayload, orderPayload, riskPayload] = await Promise.all([
const [taskPayload, claimPayload, exchangePayload, orderPayload, riskPayload, feedbackReportPayload] = await Promise.all([
loadPointActivityTasks({ status: nextFilter.taskStatus || undefined, limit: 100 }),
loadPointActivityClaims({
taskId: nextFilter.selectedTaskId || undefined,
@@ -625,12 +631,14 @@ export default function TenantMarketingPage() {
limit: 50,
}).catch(() => ({ items: [] })),
loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
loadFeedbackReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
]);
setPointTasks(taskPayload.items || []);
setPointClaims(claimPayload.items || []);
setPointExchangeItems(exchangePayload.items || []);
setPointExchangeOrders(orderPayload.items || []);
setPointsRiskReport(riskPayload.item || null);
setFeedbackReport(feedbackReportPayload.item || null);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '积分运营数据加载失败');
} finally {
@@ -980,6 +988,39 @@ export default function TenantMarketingPage() {
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'>30 </Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.total || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.pendingBacklog || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{percentLabel(feedbackReport?.summary?.handledRate)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.rewardPoints || 0)}</Text></View>
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.highPriority || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String((feedbackReport?.summary?.resolved || 0) + (feedbackReport?.summary?.closed || 0))}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.avgHandleHours || 0)}h</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{percentLabel(feedbackReport?.summary?.resolutionRate)}</Text></View>
</View>
<View className='admin-list'>
{(feedbackReport?.recentUnhandled || []).slice(0, 4).map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.title || item.type || '待处理反馈')} · {String(item.priority || 'normal')}</Text>
<Text className='admin-row-meta'>{String(item.userName || item.userPhone || item.userId || '学生')} · {String(item.status || '-')} · {String(item.ageHours || 0)}h</Text>
</View>
))}
{(feedbackReport?.topQuestions || []).slice(0, 4).map((item, index) => (
<View className='admin-row' key={String(item.questionId || index)}>
<Text className='admin-row-main'>{String(item.questionPreview || item.questionId || '高频反馈题目')}</Text>
<Text className='admin-row-meta'>{String(item.questionType || '题目')} · {String(item.reportCount || 0)} · {String(item.highPriority || 0)}</Text>
</View>
))}
</View>
{!(feedbackReport?.recentUnhandled?.length || feedbackReport?.topQuestions?.length) ? (
<View className='admin-empty'> 30 `feedback:read` </View>
) : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-grid'>

View File

@@ -606,6 +606,40 @@ export interface PointsRiskReport {
dailyTrend?: Array<Record<string, unknown>>;
}
export interface FeedbackReport {
range?: {
timeRange?: '7d' | '30d' | '90d';
startDate?: string;
endDate?: string;
};
summary?: {
total?: number;
newInRange?: number;
pending?: number;
accepted?: number;
rejected?: number;
resolved?: number;
closed?: number;
highPriority?: number;
pendingBacklog?: number;
handledInRange?: number;
rewardedReports?: number;
rewardPoints?: number;
resolutionRate?: number;
handledRate?: number;
rejectionRate?: number;
avgHandleHours?: number;
};
byStatus?: Array<Record<string, unknown>>;
byType?: Array<Record<string, unknown>>;
byCategory?: Array<Record<string, unknown>>;
byPriority?: Array<Record<string, unknown>>;
dailyTrend?: Array<Record<string, unknown>>;
topQuestions?: Array<Record<string, unknown>>;
topHandlers?: Array<Record<string, unknown>>;
recentUnhandled?: Array<Record<string, unknown>>;
}
export interface CodeBatchItem {
id: string;
name?: string | null;
@@ -1282,6 +1316,15 @@ export async function loadPointsRiskReport(query: {
});
}
export async function loadFeedbackReport(query: {
timeRange?: '7d' | '30d' | '90d';
limit?: number;
} = {}) {
return apiRequest<{ item?: FeedbackReport }>('/api/tenant-admin/feedbacks/report', {
query: { ...query, limit: query.limit || 20 },
});
}
export async function loadCodeBatches() {
return apiRequest<{ items?: CodeBatchItem[] }>('/api/tenant-admin/code-batches');
}