feat: resolve public bank conflicts

This commit is contained in:
Codex
2026-06-29 13:50:41 +08:00
parent 93ec1a07d2
commit 7fc92986a5
13 changed files with 547 additions and 32 deletions

View File

@@ -59,12 +59,12 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
pages/tenant-admin/workbench/index 租户后台工作台
pages/tenant-admin/dashboard/index 数据看板
pages/tenant-admin/students/index 学生与班级
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、复检、公共题库采纳/同步
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、复检、公共题库采纳/同步/冲突处理
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板
```
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、模板预览、导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入问题查看、模板预览、导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
## 当前平台后台页面

View File

@@ -13,6 +13,7 @@ import {
loadPublicQuestionBankConflicts,
loadPublicQuestionBanks,
previewContentImport,
resolvePublicQuestionBankConflict,
runImportPostCheck,
syncPublicQuestionBank,
type ContentEntryAdminItem,
@@ -23,6 +24,8 @@ import {
type ImportTemplateItem,
type ImportType,
type ImportSourceFormat,
type PublicQuestionBankConflictItem,
type PublicQuestionBankConflictsResult,
type PublicQuestionBankItem,
} from '@/services/tenantAdmin';
import '../admin.css';
@@ -100,6 +103,10 @@ function safeJsonPreview(value: unknown, maxLength = 360) {
}
}
function conflictItems(value: PublicQuestionBankConflictsResult | null) {
return Array.isArray(value?.conflicts) ? value.conflicts : [];
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
@@ -131,7 +138,7 @@ export default function TenantContentPage() {
const [template, setTemplate] = useState<ImportTemplateItem | null>(null);
const [previewResult, setPreviewResult] = useState<ImportPreviewResult | null>(null);
const [postCheck, setPostCheck] = useState<Record<string, unknown> | null>(null);
const [conflicts, setConflicts] = useState<Record<string, unknown> | null>(null);
const [conflicts, setConflicts] = useState<PublicQuestionBankConflictsResult | null>(null);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -393,6 +400,38 @@ export default function TenantContentPage() {
}
}
async function resolveConflict(item: PublicQuestionBankConflictItem, resolution: 'accept_platform' | 'keep_local') {
const adoptionId = conflicts?.adoptionId;
if (!adoptionId || !item.sourceQuestionId) {
setError('缺少冲突上下文,无法处理。');
return;
}
const ok = await confirm(
resolution === 'accept_platform' ? '采纳平台版本' : '保留租户版本',
resolution === 'accept_platform'
? '确认用平台公共题库的新版本覆盖该租户副本?系统会写入新题目版本并记录审计。'
: '确认保留租户本地改写版本?系统会记录该冲突已处理,后续同步不再反复提示同一版本。',
);
if (!ok) return;
setBusy(`resolve:${item.sourceQuestionId}:${resolution}`);
setError('');
try {
await resolvePublicQuestionBankConflict({
adoptionId,
sourceQuestionId: item.sourceQuestionId,
resolution,
});
const payload = await loadPublicQuestionBankConflicts(adoptionId);
setConflicts(payload.item || null);
Taro.showToast({ title: '已处理', icon: 'success' });
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '冲突处理失败');
} finally {
setBusy('');
}
}
const postCheckSummary = postCheck?.importPostCheck && typeof postCheck.importPostCheck === 'object'
? postCheck.importPostCheck as Record<string, unknown>
: postCheck;
@@ -564,10 +603,24 @@ export default function TenantContentPage() {
<View className='admin-section'>
<Text className='admin-section-title'></Text>
{conflicts ? (
<View className='admin-row'>
<Text className='admin-row-main'> {String(conflicts.status || conflicts.syncStatus || '-')}</Text>
<Text className='admin-row-meta'> {String(conflicts.conflictCount || 0)} · {String(conflicts.lastSyncedAt || '')}</Text>
<Text className='admin-row-meta'>{JSON.stringify(conflicts).slice(0, 300)}</Text>
<View className='admin-list'>
<View className='admin-row'>
<Text className='admin-row-main'> {String(conflicts.status || conflicts.syncStatus || '-')}</Text>
<Text className='admin-row-meta'> {String(conflicts.conflictCount || 0)} · {String(conflicts.lastSyncedAt || '')}</Text>
<Text className='admin-row-meta'></Text>
</View>
{conflictItems(conflicts).slice(0, 20).map((item, index) => (
<View className='admin-row' key={`${item.sourceQuestionId || index}-${item.targetQuestionId || ''}`}>
<Text className='admin-row-main'> {String(item.sourceQuestionId || '').slice(0, 8)}</Text>
<Text className='admin-row-meta'> {String(item.targetQuestionId || '-').slice(0, 8)} · {String(item.sourceHash || '').slice(0, 10)} · {String(item.targetHash || '').slice(0, 10)}</Text>
<Text className='admin-row-meta'> {String(item.previousSourceHash || '-').slice(0, 10)}</Text>
<View className='admin-row-actions'>
<Button className='admin-mini-button primary' loading={busy === `resolve:${item.sourceQuestionId}:accept_platform`} onClick={() => resolveConflict(item, 'accept_platform')}></Button>
<Button className='admin-mini-button' loading={busy === `resolve:${item.sourceQuestionId}:keep_local`} onClick={() => resolveConflict(item, 'keep_local')}></Button>
</View>
</View>
))}
{!conflictItems(conflicts).length ? <View className='admin-empty'></View> : null}
</View>
) : <View className='admin-empty'></View>}
</View>

View File

@@ -85,6 +85,26 @@ export interface PublicQuestionBankItem {
adoption?: Record<string, unknown> | null;
}
export interface PublicQuestionBankConflictItem {
sourceQuestionId?: string;
targetQuestionId?: string | null;
action?: string;
sourceHash?: string;
previousSourceHash?: string | null;
targetHash?: string | null;
}
export interface PublicQuestionBankConflictsResult {
adoptionId?: string;
syncStatus?: string;
status?: string;
lastSyncedAt?: string | null;
updatedAt?: string;
conflictCount?: number;
counts?: Record<string, unknown>;
conflicts?: PublicQuestionBankConflictItem[];
}
export interface ImportIssueItem {
id: string;
rowNo?: number | null;
@@ -250,11 +270,18 @@ export async function syncPublicQuestionBank(input: { adoptionId: string; copyLi
}
export async function loadPublicQuestionBankConflicts(adoptionId: string) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/conflicts', {
return apiRequest<{ item?: PublicQuestionBankConflictsResult }>('/api/tenant-content/public-question-banks/conflicts', {
query: { adoptionId },
});
}
export async function resolvePublicQuestionBankConflict(input: { adoptionId: string; sourceQuestionId: string; resolution: 'accept_platform' | 'keep_local' }) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/conflicts/resolve', {
method: 'POST',
body: input,
});
}
export async function runImportPostCheck(jobId: string) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/imports/post-check', {
method: 'POST',