forked from wangziqi/gongxue-base
feat: resolve public bank conflicts
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
||||
adoptPublicQuestionBankRoute,
|
||||
publicQuestionBankConflictsRoute,
|
||||
publicQuestionBanksRoute,
|
||||
resolvePublicQuestionBankConflictRoute,
|
||||
syncPublicQuestionBankRoute,
|
||||
} from './public-banks.js';
|
||||
import {
|
||||
@@ -82,6 +83,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/public-question-banks/adopt', adoptPublicQuestionBankRoute],
|
||||
['POST', '/api/tenant-content/public-question-banks/sync', syncPublicQuestionBankRoute],
|
||||
['GET', '/api/tenant-content/public-question-banks/conflicts', publicQuestionBankConflictsRoute],
|
||||
['POST', '/api/tenant-content/public-question-banks/conflicts/resolve', resolvePublicQuestionBankConflictRoute],
|
||||
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
|
||||
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
|
||||
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],
|
||||
|
||||
@@ -70,6 +70,7 @@ interface SyncQuestionResult {
|
||||
sourceHash: string;
|
||||
previousSourceHash: string | null;
|
||||
targetHash: string | null;
|
||||
resolution?: 'keep_local';
|
||||
}
|
||||
|
||||
interface PublicQuestionBankSyncAuth {
|
||||
@@ -263,6 +264,32 @@ function snapshotMap(value: unknown): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function sourceSnapshotObject(value: unknown) {
|
||||
const snapshot = objectValue(value);
|
||||
return {
|
||||
...snapshot,
|
||||
questions: objectValue(snapshot.questions),
|
||||
};
|
||||
}
|
||||
|
||||
function setSourceSnapshotQuestion(snapshot: Record<string, unknown> & { questions: Record<string, unknown> }, sourceQuestionId: string, sourceHash: string, extra: Record<string, unknown> = {}) {
|
||||
snapshot.questions[sourceQuestionId] = {
|
||||
...objectValue(snapshot.questions[sourceQuestionId]),
|
||||
...extra,
|
||||
sourceHash,
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function lastSyncConflicts(metadata: Record<string, unknown>) {
|
||||
const lastSync = objectValue(metadata.lastSync);
|
||||
return Array.isArray(lastSync.conflicts) ? lastSync.conflicts.map(item => objectValue(item)) : [];
|
||||
}
|
||||
|
||||
function resolvedPublicBankConflicts(metadata: Record<string, unknown>) {
|
||||
return objectValue(metadata.resolvedPublicBankConflicts);
|
||||
}
|
||||
|
||||
async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
|
||||
sourceTenantId: string;
|
||||
sourceQuestionBankId: string;
|
||||
@@ -336,6 +363,7 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
|
||||
targetCollectionId: string;
|
||||
source: SourceQuestionSnapshot;
|
||||
previousSourceHash: string | null;
|
||||
conflictResolutions?: Record<string, unknown>;
|
||||
order: number;
|
||||
}) {
|
||||
const legacyId = `public:${input.sourceTenantId}:${input.source.id}`;
|
||||
@@ -360,6 +388,22 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
|
||||
const targetMatchesCurrent = !!targetHash && targetHash === input.source.sourceHash;
|
||||
const targetMatchesPrevious = !!targetHash && !!input.previousSourceHash && targetHash === input.previousSourceHash;
|
||||
if (!targetMatchesCurrent && !targetMatchesPrevious) {
|
||||
const resolved = objectValue(input.conflictResolutions?.[input.source.id]);
|
||||
if (
|
||||
resolved.decision === 'keep_local'
|
||||
&& resolved.sourceHash === input.source.sourceHash
|
||||
&& resolved.targetHash === targetHash
|
||||
) {
|
||||
return {
|
||||
sourceQuestionId: input.source.id,
|
||||
targetQuestionId: existingQuestion.id,
|
||||
action: 'skipped',
|
||||
sourceHash: input.source.sourceHash,
|
||||
previousSourceHash: input.previousSourceHash,
|
||||
targetHash,
|
||||
resolution: 'keep_local',
|
||||
} satisfies SyncQuestionResult;
|
||||
}
|
||||
return {
|
||||
sourceQuestionId: input.source.id,
|
||||
targetQuestionId: existingQuestion.id,
|
||||
@@ -568,6 +612,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
|
||||
targetCollectionId: string;
|
||||
copyLimit: number;
|
||||
previousSnapshot?: unknown;
|
||||
conflictResolutions?: Record<string, unknown>;
|
||||
}) {
|
||||
const sources = await sourceQuestionSnapshots(client, input);
|
||||
const previous = snapshotMap(input.previousSnapshot);
|
||||
@@ -583,6 +628,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
|
||||
targetCollectionId: input.targetCollectionId,
|
||||
source,
|
||||
previousSourceHash: previous[source.id] || null,
|
||||
conflictResolutions: input.conflictResolutions,
|
||||
order,
|
||||
});
|
||||
results.push(result);
|
||||
@@ -952,6 +998,7 @@ export async function executePublicQuestionBankSync(input: PublicQuestionBankSyn
|
||||
targetCollectionId: adoption.targetCollectionId,
|
||||
copyLimit,
|
||||
previousSnapshot: adoption.sourceSnapshot,
|
||||
conflictResolutions: resolvedPublicBankConflicts(adoption.metadata || {}),
|
||||
});
|
||||
const conflicts = syncResult.results.filter(item => item.action === 'conflict');
|
||||
const syncStatus = conflicts.length ? 'failed' : 'synced';
|
||||
@@ -1114,3 +1161,266 @@ export async function publicQuestionBankConflictsRoute(ctx: RequestContext) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const adoptionId = requiredString(body, 'adoptionId');
|
||||
const sourceQuestionId = requiredString(body, 'sourceQuestionId');
|
||||
const resolution = requiredString(body, 'resolution');
|
||||
if (!['accept_platform', 'keep_local'].includes(resolution)) {
|
||||
throw new HttpError(400, 'resolution must be accept_platform or keep_local', 'PUBLIC_BANK_CONFLICT_RESOLUTION_INVALID');
|
||||
}
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const adoptionResult = await client.query<AdoptionRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_id as "grantId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
target_entry_id as "targetEntryId",
|
||||
target_collection_id as "targetCollectionId",
|
||||
adoption_mode as "adoptionMode", status,
|
||||
sync_status as "syncStatus",
|
||||
source_snapshot as "sourceSnapshot",
|
||||
copied_question_count as "copiedQuestionCount",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.tenant_question_bank_adoptions
|
||||
where tenant_id = $1 and id = $2 and status <> 'archived'
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, adoptionId],
|
||||
);
|
||||
const adoption = adoptionResult.rows[0];
|
||||
if (!adoption) throw new HttpError(404, 'Question bank adoption not found', 'QUESTION_BANK_ADOPTION_NOT_FOUND');
|
||||
if (!adoption.targetQuestionBankId || !adoption.targetEntryId || !adoption.targetCollectionId) {
|
||||
throw new HttpError(409, 'Question bank adoption target is incomplete', 'QUESTION_BANK_ADOPTION_TARGET_MISSING');
|
||||
}
|
||||
|
||||
const lastConflicts = lastSyncConflicts(adoption.metadata || {});
|
||||
const conflict = lastConflicts.find(item => item.sourceQuestionId === sourceQuestionId);
|
||||
if (!conflict) {
|
||||
throw new HttpError(404, 'Public question bank conflict not found', 'PUBLIC_BANK_CONFLICT_NOT_FOUND');
|
||||
}
|
||||
const targetQuestionId = nullableString(conflict.targetQuestionId);
|
||||
if (!targetQuestionId) {
|
||||
throw new HttpError(409, 'Conflict has no target question', 'PUBLIC_BANK_CONFLICT_TARGET_MISSING');
|
||||
}
|
||||
|
||||
const grant = adoption.grantId ? await loadEligibleGrant(client, auth.tenantId, adoption.grantId) : null;
|
||||
if (!grant || grant.sourceQuestionBankId !== adoption.sourceQuestionBankId) {
|
||||
throw new HttpError(403, 'Question bank grant is not available for this tenant', 'QUESTION_BANK_GRANT_NOT_AVAILABLE');
|
||||
}
|
||||
const source = (await sourceQuestionSnapshots(client, {
|
||||
sourceTenantId: grant.sourceTenantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
copyLimit: 1000,
|
||||
})).find(question => question.id === sourceQuestionId);
|
||||
if (!source) throw new HttpError(404, 'Source question not found', 'PUBLIC_BANK_SOURCE_QUESTION_NOT_FOUND');
|
||||
|
||||
const target = await client.query<{ id: string; source_hash: string | null }>(
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
`,
|
||||
[auth.tenantId, targetQuestionId],
|
||||
);
|
||||
if (!target.rows[0]) throw new HttpError(404, 'Target question not found', 'PUBLIC_BANK_TARGET_QUESTION_NOT_FOUND');
|
||||
|
||||
const snapshot = sourceSnapshotObject(adoption.sourceSnapshot);
|
||||
const metadata: Record<string, unknown> = {
|
||||
...(adoption.metadata || {}),
|
||||
resolvedPublicBankConflicts: {
|
||||
...resolvedPublicBankConflicts(adoption.metadata || {}),
|
||||
},
|
||||
};
|
||||
const resolved = objectValue(metadata.resolvedPublicBankConflicts);
|
||||
|
||||
if (resolution === 'accept_platform') {
|
||||
const latest = await client.query<{ version_no: number }>(
|
||||
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
|
||||
[targetQuestionId],
|
||||
);
|
||||
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
|
||||
const version = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template,
|
||||
source_hash, created_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetQuestionId,
|
||||
nextVersionNo,
|
||||
source.content,
|
||||
JSON.stringify(source.options || []),
|
||||
source.correctOptionIndex,
|
||||
JSON.stringify(source.correctOptionIndices || []),
|
||||
source.answerText,
|
||||
source.explanation,
|
||||
JSON.stringify(source.subQuestions || []),
|
||||
source.codeLang,
|
||||
source.codeTemplate,
|
||||
source.sourceHash,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set question_bank_id = $3,
|
||||
entry_id = $4,
|
||||
primary_collection_id = $5,
|
||||
type = $6,
|
||||
type_label = $7,
|
||||
difficulty = $8,
|
||||
tags = $9::jsonb,
|
||||
media_url = $10,
|
||||
has_video_explanation = $11,
|
||||
status = 'published',
|
||||
current_version_id = $12,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetQuestionId,
|
||||
adoption.targetQuestionBankId,
|
||||
adoption.targetEntryId,
|
||||
adoption.targetCollectionId,
|
||||
source.type,
|
||||
source.typeLabel,
|
||||
source.difficulty,
|
||||
JSON.stringify(source.tags || []),
|
||||
source.mediaUrl,
|
||||
source.hasVideoExplanation,
|
||||
version.rows[0].id,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
insert into public.question_collection_items (
|
||||
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, null, true, $6::jsonb)
|
||||
on conflict (tenant_id, collection_id, question_id)
|
||||
do update set section_key = excluded.section_key,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
adoption.targetCollectionId,
|
||||
targetQuestionId,
|
||||
source.type,
|
||||
0,
|
||||
JSON.stringify({
|
||||
source: 'public_question_bank_adoption',
|
||||
sourceQuestionId,
|
||||
lastConflictResolution: 'accept_platform',
|
||||
}),
|
||||
],
|
||||
);
|
||||
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'accept_platform' });
|
||||
delete resolved[sourceQuestionId];
|
||||
} else {
|
||||
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'keep_local' });
|
||||
resolved[sourceQuestionId] = {
|
||||
decision: 'keep_local',
|
||||
sourceHash: source.sourceHash,
|
||||
targetHash: target.rows[0].source_hash,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolvedBy: auth.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const remainingConflicts = lastConflicts.filter(item => item.sourceQuestionId !== sourceQuestionId);
|
||||
const lastSync = {
|
||||
...objectValue((adoption.metadata || {}).lastSync),
|
||||
status: remainingConflicts.length ? 'failed' : 'resolved',
|
||||
conflictCount: remainingConflicts.length,
|
||||
conflicts: remainingConflicts,
|
||||
lastResolution: {
|
||||
sourceQuestionId,
|
||||
resolution,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
metadata.resolvedPublicBankConflicts = resolved;
|
||||
metadata.lastSync = lastSync;
|
||||
|
||||
const updated = await client.query<AdoptionRow>(
|
||||
`
|
||||
update public.tenant_question_bank_adoptions
|
||||
set sync_status = $3,
|
||||
source_snapshot = $4::jsonb,
|
||||
metadata = $5::jsonb,
|
||||
updated_by = $6,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, tenant_id as "tenantId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_id as "grantId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
target_entry_id as "targetEntryId",
|
||||
target_collection_id as "targetCollectionId",
|
||||
adoption_mode as "adoptionMode", status,
|
||||
sync_status as "syncStatus",
|
||||
source_snapshot as "sourceSnapshot",
|
||||
copied_question_count as "copiedQuestionCount",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
adoption.id,
|
||||
remainingConflicts.length ? 'failed' : 'synced',
|
||||
JSON.stringify(snapshot),
|
||||
JSON.stringify(metadata),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.public_question_bank.conflict_resolved', 'tenant_question_bank_adoption', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
adoption.id,
|
||||
JSON.stringify({
|
||||
sourceQuestionBankId: adoption.sourceQuestionBankId,
|
||||
sourceQuestionId,
|
||||
targetQuestionId,
|
||||
resolution,
|
||||
sourceHash: source.sourceHash,
|
||||
previousTargetHash: target.rows[0].source_hash,
|
||||
remainingConflictCount: remainingConflicts.length,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
adoption: updated.rows[0],
|
||||
sourceQuestionId,
|
||||
targetQuestionId,
|
||||
resolution,
|
||||
remainingConflictCount: remainingConflicts.length,
|
||||
syncStatus: updated.rows[0].syncStatus,
|
||||
};
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
@@ -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 为准,前端菜单隐藏只做体验优化。
|
||||
|
||||
## 当前平台后台页面
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user