feat: support daily practice exports

This commit is contained in:
Codex
2026-06-29 17:17:44 +08:00
parent 506d7015a0
commit 60501a0f70
11 changed files with 455 additions and 21 deletions

View File

@@ -94,7 +94,11 @@ export interface BuiltExportPayload {
sectionCount: number;
totalScore: number;
durationMinutes: number | null;
cardCount?: number;
issue?: string;
date?: string;
};
dailyPractice?: DailyPracticeMetadata;
sections: Array<{
key: string;
title: string;
@@ -117,6 +121,38 @@ export interface BuiltExportPayload {
};
}
interface DailyPracticeBrand {
name: string;
english: string;
slogan: string;
ctaLine: string;
}
interface DailyPracticeMetadata {
issue: string;
date: string;
theme: string;
cardFormat: string;
brand: DailyPracticeBrand;
showAnswer: boolean;
centerSlot: {
type: 'cta' | 'image';
title: string;
subtitle: string;
imageUrl: string | null;
};
slots: Array<{
slot: number;
kind: 'question' | 'center';
questionId?: unknown;
subjectName?: unknown;
typeLabel?: unknown;
order?: unknown;
title?: string;
subtitle?: string;
}>;
}
function choose(value: unknown, allowed: string[], fallback: string, code: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
@@ -139,6 +175,11 @@ function exportLimit(value: unknown) {
return Math.max(1, Math.min(Math.trunc(parsed), 5000));
}
function exportLimitForType(exportType: string, value: unknown) {
const limit = exportLimit(value);
return exportType === 'daily_practice' ? Math.min(limit, 8) : limit;
}
function isBinaryExportFormat(format: string) {
return BINARY_EXPORT_FORMATS.includes(format);
}
@@ -231,6 +272,72 @@ function groupBySection(questions: ReturnType<typeof formatQuestion>[]) {
}));
}
function todayDateString() {
return new Date().toISOString().slice(0, 10);
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringValue(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 120) : fallback;
}
function dailyPracticeBrand(options: Record<string, unknown>): DailyPracticeBrand {
const brand = objectValue(options.brand);
return {
name: stringValue(brand.name, '恭学教育'),
english: stringValue(brand.english, 'GONGXUE EDU'),
slogan: stringValue(brand.slogan, '专注高职升本'),
ctaLine: stringValue(brand.ctaLine, '每日一练 · 精选八题 · 稳步上岸'),
};
}
function dailyPracticeMetadata(input: {
questions: ReturnType<typeof formatQuestion>[];
options: Record<string, unknown>;
includeAnswers: boolean;
}): DailyPracticeMetadata {
const issue = stringValue(input.options.issue, stringValue(input.options.title, '每日一练'));
const date = stringValue(input.options.date, todayDateString());
const center = objectValue(input.options.centerSlot);
const brand = dailyPracticeBrand(input.options);
const questionSlots = [0, 1, 2, 3, 5, 6, 7, 8];
const slots: DailyPracticeMetadata['slots'] = [];
input.questions.slice(0, 8).forEach((question, index) => {
slots.push({
slot: questionSlots[index],
kind: 'question',
questionId: question.id,
subjectName: question.subjectName,
typeLabel: question.typeLabel,
order: index + 1,
});
});
slots.splice(Math.min(4, slots.length), 0, {
slot: 4,
kind: 'center',
title: stringValue(center.title, brand.name),
subtitle: stringValue(center.subtitle, brand.ctaLine),
});
return {
issue,
date,
theme: stringValue(input.options.theme, 'default'),
cardFormat: stringValue(input.options.cardFormat, '1:1'),
brand,
showAnswer: input.includeAnswers,
centerSlot: {
type: stringValue(center.type, 'cta') === 'image' ? 'image' : 'cta',
title: stringValue(center.title, brand.name),
subtitle: stringValue(center.subtitle, brand.ctaLine),
imageUrl: typeof center.imageUrl === 'string' && center.imageUrl.trim() ? center.imageUrl.trim() : null,
},
slots,
};
}
function buildExportPayload(input: {
auth: TenantContentAuth;
jobId: string;
@@ -268,7 +375,23 @@ function buildExportPayload(input: {
sectionCount: sections.length,
totalScore: sections.reduce((sum, section) => sum + section.totalScore, 0),
durationMinutes: input.scope.durationMinutes,
...(input.exportType === 'daily_practice'
? {
cardCount: Math.min(input.questions.length, 8),
issue: stringValue(input.options.issue, stringValue(input.options.title, '每日一练')),
date: stringValue(input.options.date, todayDateString()),
}
: {}),
},
...(input.exportType === 'daily_practice'
? {
dailyPractice: dailyPracticeMetadata({
questions: input.questions,
options: input.options,
includeAnswers: input.includeAnswers,
}),
}
: {}),
sections,
questions: input.questions,
};
@@ -284,7 +407,7 @@ function buildExportPayload(input: {
},
],
renderHints: {
pdfLayout: input.exportType === 'paper' ? 'paper' : 'practice',
pdfLayout: input.exportType === 'daily_practice' ? 'daily_practice' : input.exportType === 'paper' ? 'paper' : 'practice',
pageSize: 'A4',
answerPlacement: input.includeAnswers ? 'inline_or_appendix' : 'hidden',
frontendRenderer: 'apps/taro admin export renderer',
@@ -606,11 +729,16 @@ export async function createQuestionExportRoute(ctx: RequestContext) {
const scopeType = choose(body.scopeType, SCOPE_TYPES, 'collection', 'INVALID_EXPORT_SCOPE') as ExportScope['scopeType'];
const scopeId = requiredString(body, 'scopeId');
const format = choose(body.format, EXPORT_FORMATS, 'json', 'INVALID_EXPORT_FORMAT');
const exportType = choose(body.exportType, EXPORT_TYPES, format === 'paper_json' ? 'paper' : 'questions', 'INVALID_EXPORT_TYPE');
const exportType = choose(
body.exportType,
EXPORT_TYPES,
format === 'paper_json' || isBinaryExportFormat(format) ? 'paper' : 'questions',
'INVALID_EXPORT_TYPE',
);
const includeAnswers = boolValue(body.includeAnswers, true);
const includeExplanations = boolValue(body.includeExplanations, includeAnswers);
const includeVideoRefs = boolValue(body.includeVideoRefs, false);
const limit = exportLimit(body.limit);
const limit = exportLimitForType(exportType, body.limit);
const options = body.options && typeof body.options === 'object' && !Array.isArray(body.options) ? body.options as Record<string, unknown> : {};
const result = await transaction(async client => {