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

@@ -675,7 +675,10 @@ function sanitizeOAuthProfilePayload(value: unknown): unknown {
normalized.includes('refreshtoken') ||
normalized.includes('sessionkey') ||
normalized.includes('secret') ||
normalized.includes('clientsecret')
normalized.includes('clientsecret') ||
normalized.includes('avatar') ||
normalized.includes('headimg') ||
normalized.includes('figureurl')
) {
continue;
}
@@ -764,16 +767,13 @@ async function callWechatWebUserInfo(input: {
}
const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['headimgurl', 'avatarUrl', 'avatar_url']);
const unionId = stringField(raw, ['unionid', 'unionId']);
return {
nickname,
avatarUrl,
unionId,
profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname,
avatarUrl,
},
};
}
@@ -934,7 +934,6 @@ export async function wechatWebLoginRoute(ctx: RequestContext) {
...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
};
return transaction(async client => {
@@ -1069,14 +1068,11 @@ async function callQqUserInfo(input: {
}
const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['figureurl_qq_2', 'figureurl_qq_1', 'figureurl_2', 'figureurl_1', 'avatarUrl']);
return {
nickname,
avatarUrl,
profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname,
avatarUrl,
},
};
}
@@ -1135,7 +1131,6 @@ export async function qqLoginRoute(ctx: RequestContext) {
...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
};
return transaction(async client => {

View File

@@ -56,6 +56,36 @@ export function hashSmsCode(tenantId: string, phone: string, purpose: string, co
.digest('hex');
}
function stripUnsupportedOAuthProfileFields(value: unknown): unknown {
if (Array.isArray(value)) return value.map(item => stripUnsupportedOAuthProfileFields(item));
if (!value || typeof value !== 'object') return value;
const sanitized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
if (
normalized.includes('accesstoken') ||
normalized.includes('refreshtoken') ||
normalized.includes('sessionkey') ||
normalized.includes('secret') ||
normalized.includes('clientsecret') ||
normalized.includes('avatar') ||
normalized.includes('headimg') ||
normalized.includes('figureurl')
) {
continue;
}
sanitized[key] = stripUnsupportedOAuthProfileFields(child);
}
return sanitized;
}
function publicOAuthProfile(profile: Record<string, unknown> | undefined) {
const sanitized = stripUnsupportedOAuthProfileFields(profile || {});
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
? sanitized as Record<string, unknown>
: {};
}
export function createSessionToken() {
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
}
@@ -300,7 +330,7 @@ export async function upsertOAuthUser(
return { user: matchedUser, isNewUser: false };
}
const profile = input.profile || {};
const profile = publicOAuthProfile(input.profile);
const nickname =
typeof profile.nickname === 'string'
? profile.nickname
@@ -309,17 +339,11 @@ export async function upsertOAuthUser(
: typeof profile.name === 'string'
? profile.name
: null;
const avatarUrl =
typeof profile.avatarUrl === 'string'
? profile.avatarUrl
: typeof profile.avatar_url === 'string'
? profile.avatar_url
: null;
const userResult = await client.query<PlatformUserSummary>(
`
insert into public.platform_users (username, phone, email, name, avatar_url, primary_role, raw_profile)
values ($1, $2, $3::citext, $4, $5, 'student', $6::jsonb)
insert into public.platform_users (username, phone, email, name, primary_role, raw_profile)
values ($1, $2, $3::citext, $4, 'student', $5::jsonb)
returning id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt"
`,
@@ -328,7 +352,6 @@ export async function upsertOAuthUser(
input.phone || null,
input.email || null,
nickname,
avatarUrl,
JSON.stringify({
source: input.provider,
profile,

View File

@@ -22,6 +22,7 @@ import { tenantUserNotificationsRoute } from './notifications.js';
import {
tenantExamDatesRoute,
tenantFeedbackEventsRoute,
tenantFeedbackReportRoute,
tenantFeedbacksRoute,
updateTenantFeedbackStatusRoute,
upsertTenantExamDateRoute,
@@ -131,6 +132,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute],
['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute],
['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute],
['GET', '/api/tenant-admin/feedbacks/report', tenantFeedbackReportRoute],
['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute],
['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute],
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],

View File

@@ -14,6 +14,11 @@ type JsonBody = Record<string, unknown>;
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
const FEEDBACK_REPORT_RANGES: Record<string, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
};
const REPORT_STATUS_LABELS: Record<string, string> = {
pending: '待处理',
accepted: '已受理',
@@ -35,10 +40,71 @@ function intValue(value: unknown, fallback: number) {
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
}
function numberValue(value: unknown, fallback = 0) {
const numberValue = Number(value ?? fallback);
return Number.isFinite(numberValue) ? numberValue : fallback;
}
function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
function boundedIntParam(ctx: RequestContext, name: string, fallback: number, min: number, max: number) {
const parsed = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, Math.trunc(parsed)));
}
function shanghaiDateParts(date = new Date()) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])) as {
year: string;
month: string;
day: string;
};
}
function shanghaiDateKey(date = new Date()) {
const parts = shanghaiDateParts(date);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function addDaysKey(dateKey: string, days: number) {
const date = new Date(`${dateKey}T00:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + days);
return shanghaiDateKey(date);
}
function shanghaiDayStartIso(dateKey: string) {
return new Date(`${dateKey}T00:00:00+08:00`).toISOString();
}
function feedbackReportRange(value: string) {
const timeRange = value || '30d';
const days = FEEDBACK_REPORT_RANGES[timeRange];
if (!days) throw new HttpError(400, 'Unsupported feedback report range', 'INVALID_FEEDBACK_REPORT_RANGE');
const endDate = shanghaiDateKey();
const startDate = addDaysKey(endDate, -(days - 1));
return {
timeRange,
days,
startDate,
endDate,
startAt: shanghaiDayStartIso(startDate),
endAt: shanghaiDayStartIso(addDaysKey(endDate, 1)),
};
}
function ratio(numerator: number, denominator: number) {
if (!denominator) return 0;
return Number((numerator / denominator).toFixed(4));
}
function feedbackNotificationSeverity(status: string) {
if (status === 'resolved') return 'success';
if (status === 'rejected') return 'warning';
@@ -246,6 +312,307 @@ export async function tenantFeedbacksRoute(ctx: RequestContext) {
return { items };
}
export async function tenantFeedbackReportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'feedback:read');
const range = feedbackReportRange(stringParam(ctx, 'timeRange'));
const limit = boundedIntParam(ctx, 'limit', 20, 1, 100);
const baseParams = [auth.tenantId, range.startAt, range.endAt];
const [
summary,
byStatus,
byType,
byCategory,
byPriority,
dailyTrend,
topQuestions,
topHandlers,
recentUnhandled,
] = await Promise.all([
query<Record<string, unknown>>(
`
with created_reports as (
select *
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
),
handled_reports as (
select *
from public.reports
where tenant_id = $1
and handled_at >= $2::timestamptz
and handled_at < $3::timestamptz
and status <> 'pending'
),
feedback_rewards as (
select e.*
from public.user_score_events e
join public.reports r on r.tenant_id = e.tenant_id and r.id = e.source_id
where e.tenant_id = $1
and e.created_at >= $2::timestamptz
and e.created_at < $3::timestamptz
and e.event_type = 'feedback_reward'
and e.source_type = 'reports'
)
select
(select count(*)::int from created_reports) as total,
(select count(*)::int from created_reports where status = 'pending') as pending,
(select count(*)::int from created_reports where status = 'accepted') as accepted,
(select count(*)::int from created_reports where status = 'rejected') as rejected,
(select count(*)::int from created_reports where status = 'resolved') as resolved,
(select count(*)::int from created_reports where status = 'closed') as closed,
(select count(*)::int from created_reports where priority in ('high', 'urgent')) as "highPriority",
(select count(*)::int from public.reports where tenant_id = $1 and status in ('pending', 'accepted')) as "pendingBacklog",
(select count(*)::int from handled_reports) as "handledInRange",
(select count(distinct source_id)::int from feedback_rewards where source_id is not null) as "rewardedReports",
(select coalesce(sum(points), 0)::int from feedback_rewards where points > 0) as "rewardPoints",
(
select coalesce(avg(extract(epoch from (handled_at - created_at)) / 3600), 0)::numeric
from handled_reports
where handled_at is not null
and handled_at >= created_at
) as "avgHandleHours"
`,
baseParams,
),
query<Record<string, unknown>>(
`
select status,
case status
when 'pending' then '待处理'
when 'accepted' then '已受理'
when 'rejected' then '未采纳'
when 'resolved' then '已解决'
when 'closed' then '已关闭'
else status
end as label,
count(*)::int as count,
count(*) filter (where priority in ('high', 'urgent'))::int as "highPriority"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by status
order by case status
when 'pending' then 1
when 'accepted' then 2
when 'resolved' then 3
when 'rejected' then 4
when 'closed' then 5
else 9
end
`,
baseParams,
),
query<Record<string, unknown>>(
`
select coalesce(type, 'other') as type,
count(*)::int as count,
max(created_at) as "latestAt"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by coalesce(type, 'other')
order by count desc, "latestAt" desc
`,
baseParams,
),
query<Record<string, unknown>>(
`
select coalesce(nullif(category, ''), 'uncategorized') as category,
count(*)::int as count,
max(created_at) as "latestAt"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by coalesce(nullif(category, ''), 'uncategorized')
order by count desc, "latestAt" desc
limit $4
`,
[...baseParams, limit],
),
query<Record<string, unknown>>(
`
select priority,
count(*)::int as count
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by priority
order by case priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
when 'low' then 4
else 9
end
`,
baseParams,
),
query<Record<string, unknown>>(
`
with days as (
select generate_series(
$2::timestamptz,
$3::timestamptz - interval '1 day',
interval '1 day'
) as day_start
),
created_daily as (
select (created_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as total,
count(*) filter (where status = 'pending')::int as pending,
count(*) filter (where status = 'resolved')::int as resolved,
count(*) filter (where priority in ('high', 'urgent'))::int as "highPriority"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by (created_at at time zone 'Asia/Shanghai')::date
),
handled_daily as (
select (handled_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as handled
from public.reports
where tenant_id = $1
and handled_at >= $2::timestamptz
and handled_at < $3::timestamptz
and status <> 'pending'
group by (handled_at at time zone 'Asia/Shanghai')::date
)
select (d.day_start at time zone 'Asia/Shanghai')::date::text as date,
coalesce(cd.total, 0)::int as total,
coalesce(cd.pending, 0)::int as pending,
coalesce(cd.resolved, 0)::int as resolved,
coalesce(cd."highPriority", 0)::int as "highPriority",
coalesce(hd.handled, 0)::int as handled
from days d
left join created_daily cd on cd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
left join handled_daily hd on hd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
order by date asc
`,
baseParams,
),
query<Record<string, unknown>>(
`
select r.question_id as "questionId",
q.type as "questionType",
left(coalesce(nullif(q.type_label, ''), nullif(v.content, ''), q.legacy_id, r.question_id::text), 120) as "questionPreview",
count(*)::int as "reportCount",
count(*) filter (where r.priority in ('high', 'urgent'))::int as "highPriority",
max(r.created_at) as "latestAt"
from public.reports r
join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
left join public.question_versions v on v.id = q.current_version_id
where r.tenant_id = $1
and r.question_id is not null
and r.created_at >= $2::timestamptz
and r.created_at < $3::timestamptz
group by r.question_id, q.type, q.type_label, q.legacy_id, v.content
order by "reportCount" desc, "highPriority" desc, "latestAt" desc
limit $4
`,
[...baseParams, limit],
),
query<Record<string, unknown>>(
`
select r.handled_by as "handlerUserId",
handler.name as "handlerName",
handler.phone as "handlerPhone",
count(*)::int as "handledCount",
count(*) filter (where r.status = 'resolved')::int as resolved,
count(*) filter (where r.status = 'rejected')::int as rejected,
count(*) filter (where r.status = 'closed')::int as closed,
coalesce(avg(extract(epoch from (r.handled_at - r.created_at)) / 3600), 0)::numeric as "avgHandleHours",
max(r.handled_at) as "latestHandledAt"
from public.reports r
left join public.platform_users handler on handler.id = r.handled_by
where r.tenant_id = $1
and r.handled_by is not null
and r.handled_at >= $2::timestamptz
and r.handled_at < $3::timestamptz
and r.status <> 'pending'
group by r.handled_by, handler.name, handler.phone
order by "handledCount" desc, "latestHandledAt" desc
limit $4
`,
[...baseParams, limit],
),
query<Record<string, unknown>>(
`
select r.id, r.question_id as "questionId", q.type as "questionType",
r.user_id as "userId", u.name as "userName", u.phone as "userPhone",
r.type, r.category, r.title, r.status, r.priority,
extract(epoch from (now() - r.created_at)) / 3600 as "ageHours",
r.created_at as "createdAt", r.updated_at as "updatedAt"
from public.reports r
left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
left join public.platform_users u on u.id = r.user_id
where r.tenant_id = $1
and r.status in ('pending', 'accepted')
order by case r.priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
else 4
end, r.created_at asc
limit $2
`,
[auth.tenantId, limit],
),
]);
const summaryRow = summary[0] || {};
const total = intValue(summaryRow.total, 0);
const resolved = intValue(summaryRow.resolved, 0);
const closed = intValue(summaryRow.closed, 0);
const rejected = intValue(summaryRow.rejected, 0);
const handledInRange = intValue(summaryRow.handledInRange, 0);
return {
item: {
range,
summary: {
total,
newInRange: total,
pending: intValue(summaryRow.pending, 0),
accepted: intValue(summaryRow.accepted, 0),
rejected,
resolved,
closed,
highPriority: intValue(summaryRow.highPriority, 0),
pendingBacklog: intValue(summaryRow.pendingBacklog, 0),
handledInRange,
rewardedReports: intValue(summaryRow.rewardedReports, 0),
rewardPoints: intValue(summaryRow.rewardPoints, 0),
resolutionRate: ratio(resolved + closed, total),
handledRate: ratio(handledInRange, total),
rejectionRate: ratio(rejected, total),
avgHandleHours: Number(numberValue(summaryRow.avgHandleHours, 0).toFixed(2)),
},
byStatus,
byType,
byCategory,
byPriority,
dailyTrend,
topQuestions,
topHandlers: topHandlers.map(item => ({
...item,
avgHandleHours: Number(numberValue(item.avgHandleHours, 0).toFixed(2)),
})),
recentUnhandled: recentUnhandled.map(item => ({
...item,
ageHours: Number(numberValue(item.ageHours, 0).toFixed(2)),
})),
},
};
}
export async function tenantFeedbackEventsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'feedback:read');

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');
}