feat: notify tenants about public bank syncs

This commit is contained in:
Codex
2026-06-29 16:25:13 +08:00
parent e54efbc294
commit 1bcd887731
17 changed files with 558 additions and 24 deletions

View File

@@ -13,11 +13,13 @@ import {
loadImportTemplate,
loadPublicQuestionBankConflicts,
loadPublicQuestionBanks,
loadTenantContentNotifications,
previewContentImport,
resolvePublicQuestionBankConflict,
resolvePublicQuestionBankConflicts,
runImportPostCheck,
syncPublicQuestionBank,
updateTenantContentNotificationStatus,
type ContentEntryAdminItem,
type ImportFieldMapping,
type ImportJobDetail,
@@ -30,6 +32,7 @@ import {
type PublicQuestionBankConflictItem,
type PublicQuestionBankConflictsResult,
type PublicQuestionBankItem,
type TenantContentNotificationItem,
} from '@/services/tenantAdmin';
import '../admin.css';
@@ -137,6 +140,13 @@ function conflictItems(value: PublicQuestionBankConflictsResult | null) {
return Array.isArray(value?.conflicts) ? value.conflicts : [];
}
function notificationStatusText(item: TenantContentNotificationItem) {
if (item.status === 'resolved') return '已处理';
if (item.status === 'dismissed') return '已忽略';
if (item.status === 'read') return '已读';
return '未读';
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
@@ -162,6 +172,7 @@ export default function TenantContentPage() {
const [entries, setEntries] = useState<ContentEntryAdminItem[]>([]);
const [jobs, setJobs] = useState<ImportJobItem[]>([]);
const [publicBanks, setPublicBanks] = useState<PublicQuestionBankItem[]>([]);
const [notifications, setNotifications] = useState<TenantContentNotificationItem[]>([]);
const [selectedJobId, setSelectedJobId] = useState('');
const [selectedImportType, setSelectedImportType] = useState<ImportType>('questions');
const [sourceFormat, setSourceFormat] = useState<ImportSourceFormat>('json');
@@ -187,10 +198,12 @@ export default function TenantContentPage() {
loadContentEntriesAdmin().catch(() => ({ items: [] })),
loadImportJobs(20).catch(() => ({ items: [] })),
loadPublicQuestionBanks().catch(() => ({ items: [] })),
]).then(([entryPayload, jobPayload, bankPayload]) => {
loadTenantContentNotifications({ limit: 10 }).catch(() => ({ items: [] })),
]).then(([entryPayload, jobPayload, bankPayload, notificationPayload]) => {
setEntries(entryPayload.items || []);
setJobs(jobPayload.items || []);
setPublicBanks(bankPayload.items || []);
setNotifications(notificationPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '内容数据加载失败'));
}
@@ -461,6 +474,8 @@ export default function TenantContentPage() {
copyLimit: Math.max(1, Math.min(Number(copyLimit || 1000), 1000)),
});
setConflicts(payload.sync || null);
const notificationPayload = await loadTenantContentNotifications({ limit: 10 });
setNotifications(notificationPayload.items || []);
Taro.showToast({ title: '同步完成', icon: 'success' });
reload();
} catch (nextError) {
@@ -470,6 +485,21 @@ export default function TenantContentPage() {
}
}
async function updateNotificationStatus(item: TenantContentNotificationItem, status: 'read' | 'dismissed') {
if (!item.id) return;
setBusy(`notification:${item.id}:${status}`);
setError('');
try {
await updateTenantContentNotificationStatus({ notificationIds: [item.id], status });
const payload = await loadTenantContentNotifications({ limit: 10 });
setNotifications(payload.items || []);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '通知状态更新失败');
} finally {
setBusy('');
}
}
async function showConflicts(item: PublicQuestionBankItem) {
const adoptionId = adoptionIdOf(item);
if (!adoptionId) {
@@ -508,6 +538,8 @@ export default function TenantContentPage() {
});
const payload = await loadPublicQuestionBankConflicts(adoptionId);
setConflicts(payload.item || null);
const notificationPayload = await loadTenantContentNotifications({ limit: 10 });
setNotifications(notificationPayload.items || []);
Taro.showToast({ title: '已处理', icon: 'success' });
reload();
} catch (nextError) {
@@ -545,6 +577,8 @@ export default function TenantContentPage() {
});
const payload = await loadPublicQuestionBankConflicts(adoptionId);
setConflicts(payload.item || null);
const notificationPayload = await loadTenantContentNotifications({ limit: 10 });
setNotifications(notificationPayload.items || []);
Taro.showToast({ title: '批量处理完成', icon: 'success' });
reload();
} catch (nextError) {
@@ -722,6 +756,27 @@ export default function TenantContentPage() {
) : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{notifications.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.title || '内容通知'} · {notificationStatusText(item)}</Text>
<Text className='admin-row-meta'>{item.message || '-'} · {item.createdAt ? String(item.createdAt).slice(0, 16).replace('T', ' ') : ''}</Text>
<View className='admin-row-actions'>
{item.status === 'unread' ? (
<Button className='admin-mini-button primary' loading={busy === `notification:${item.id}:read`} onClick={() => updateNotificationStatus(item, 'read')}></Button>
) : null}
{item.status !== 'dismissed' && item.status !== 'resolved' ? (
<Button className='admin-mini-button' loading={busy === `notification:${item.id}:dismissed`} onClick={() => updateNotificationStatus(item, 'dismissed')}></Button>
) : null}
</View>
</View>
))}
</View>
{!notifications.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>

View File

@@ -218,6 +218,24 @@ export interface PublicQuestionBankConflictsResult {
conflicts?: PublicQuestionBankConflictItem[];
}
export interface TenantContentNotificationItem {
id: string;
notificationType?: string;
status?: 'unread' | 'read' | 'dismissed' | 'resolved';
severity?: 'info' | 'success' | 'warning' | 'error';
adoptionId?: string | null;
sourceQuestionBankId?: string | null;
title?: string;
message?: string;
actionLabel?: string | null;
actionPath?: string | null;
metadata?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
readAt?: string | null;
resolvedAt?: string | null;
}
export interface ImportIssueItem {
id: string;
rowNo?: number | null;
@@ -651,6 +669,27 @@ export async function loadPublicQuestionBanks() {
return apiRequest<{ items?: PublicQuestionBankItem[] }>('/api/tenant-content/public-question-banks');
}
export async function loadTenantContentNotifications(input: {
status?: 'unread' | 'read' | 'dismissed' | 'resolved';
notificationType?: string;
adoptionId?: string;
limit?: number;
} = {}) {
return apiRequest<{ items?: TenantContentNotificationItem[]; summary?: Record<string, number> }>('/api/tenant-content/notifications', {
query: input,
});
}
export async function updateTenantContentNotificationStatus(input: {
notificationIds: string[];
status: 'read' | 'dismissed' | 'resolved';
}) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/notifications/status', {
method: 'POST',
body: input,
});
}
export async function adoptPublicQuestionBank(input: { grantId: string; entryName?: string; collectionName?: string; copyLimit?: number }) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/adopt', {
method: 'POST',