forked from wangziqi/gongxue-base
feat: support daily practice exports
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -108,6 +108,14 @@ function watermarkText(payload: BuiltExportPayload) {
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim().slice(0, 80) : '';
|
||||
}
|
||||
|
||||
function isDailyPractice(payload: BuiltExportPayload) {
|
||||
return payload.exportType === 'daily_practice';
|
||||
}
|
||||
|
||||
function brandName(payload: BuiltExportPayload) {
|
||||
return payload.dailyPractice?.brand?.name || '恭学教育';
|
||||
}
|
||||
|
||||
function findPdfFont() {
|
||||
return PDF_FONT_CANDIDATES.find(candidate => fs.existsSync(candidate));
|
||||
}
|
||||
@@ -161,7 +169,140 @@ function writePdfQuestion(doc: PDFKit.PDFDocument, question: ExportQuestion, ind
|
||||
}
|
||||
}
|
||||
|
||||
function fitText(
|
||||
doc: PDFKit.PDFDocument,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fontSize = 10,
|
||||
options: PDFKit.Mixins.TextOptions = {},
|
||||
) {
|
||||
const content = plainText(text) || ' ';
|
||||
let nextFontSize = fontSize;
|
||||
while (nextFontSize > 6) {
|
||||
doc.fontSize(nextFontSize);
|
||||
const measured = doc.heightOfString(content, { ...options, width });
|
||||
if (measured <= height) break;
|
||||
nextFontSize -= 0.6;
|
||||
}
|
||||
doc.fontSize(nextFontSize).text(content, x, y, { ...options, width, height });
|
||||
}
|
||||
|
||||
function writeDailyPracticePdfCard(
|
||||
doc: PDFKit.PDFDocument,
|
||||
question: ExportQuestion,
|
||||
index: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
payload: BuiltExportPayload,
|
||||
) {
|
||||
const includeAnswers = boolOption(payload, 'includeAnswers');
|
||||
const includeExplanations = boolOption(payload, 'includeExplanations');
|
||||
doc.save();
|
||||
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#ffffff', '#d7dee8');
|
||||
doc.fillColor('#0f172a').fontSize(8).text(`${String(index + 1).padStart(2, '0')} ${question.typeLabel || question.type || '题目'}`, x + 10, y + 10, {
|
||||
width: width - 20,
|
||||
});
|
||||
doc.moveTo(x + 10, y + 25).lineTo(x + width - 10, y + 25).strokeColor('#e2e8f0').stroke();
|
||||
fitText(doc, String(question.content || ''), x + 10, y + 33, width - 20, height * 0.44, 10, { lineGap: 2 });
|
||||
const options = Array.isArray(question.options) ? question.options.slice(0, 4) : [];
|
||||
let optionY = y + height * 0.56;
|
||||
options.forEach((option, optionIndex) => {
|
||||
fitText(doc, optionText(option, optionIndex), x + 12, optionY, width - 24, 16, 9, { lineGap: 1 });
|
||||
optionY += 18;
|
||||
});
|
||||
const answer = includeAnswers ? answerText(question) : '';
|
||||
if (answer) {
|
||||
doc.fillColor('#0f766e').fontSize(7.5).text(`答案:${answer}`, x + 10, y + height - 31, { width: width - 20 });
|
||||
}
|
||||
if (includeExplanations && typeof question.explanation === 'string' && question.explanation.trim()) {
|
||||
doc.fillColor('#64748b').fontSize(7).text(`解析:${plainText(question.explanation).slice(0, 70)}`, x + 10, y + height - 18, { width: width - 20 });
|
||||
}
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
async function renderDailyPracticePdf(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 36, bufferPages: true, autoFirstPage: true });
|
||||
const done = collectPdf(doc);
|
||||
const fontPath = findPdfFont();
|
||||
if (fontPath) {
|
||||
doc.registerFont('TikuCjk', fontPath);
|
||||
doc.font('TikuCjk');
|
||||
}
|
||||
const watermark = watermarkText(payload) || brandName(payload);
|
||||
renderPdfWatermark(doc, watermark);
|
||||
|
||||
const daily = payload.dailyPractice;
|
||||
const title = daily?.issue || payload.title || '每日一练';
|
||||
doc.rect(0, 0, doc.page.width, doc.page.height).fill('#f6f8fb');
|
||||
renderPdfWatermark(doc, watermark);
|
||||
doc.fillColor('#0f172a').fontSize(22).text(title, 36, 34, { width: doc.page.width - 72, align: 'center' });
|
||||
doc.fillColor('#64748b').fontSize(9).text(
|
||||
`${daily?.date || payload.exportedAt.slice(0, 10)} · ${daily?.brand?.slogan || '专注高职升本'} · ${daily?.brand?.english || 'GONGXUE EDU'}`,
|
||||
36,
|
||||
64,
|
||||
{ width: doc.page.width - 72, align: 'center' },
|
||||
);
|
||||
|
||||
const gap = 10;
|
||||
const gridX = 44;
|
||||
const gridY = 96;
|
||||
const cardW = (doc.page.width - gridX * 2 - gap * 2) / 3;
|
||||
const cardH = (doc.page.height - gridY - 82 - gap * 2) / 3;
|
||||
const questionSlots = [0, 1, 2, 3, 5, 6, 7, 8];
|
||||
payload.questions.slice(0, 8).forEach((question, index) => {
|
||||
const slot = questionSlots[index];
|
||||
const row = Math.floor(slot / 3);
|
||||
const col = slot % 3;
|
||||
writeDailyPracticePdfCard(
|
||||
doc,
|
||||
question,
|
||||
index,
|
||||
gridX + col * (cardW + gap),
|
||||
gridY + row * (cardH + gap),
|
||||
cardW,
|
||||
cardH,
|
||||
payload,
|
||||
);
|
||||
});
|
||||
|
||||
const centerX = gridX + (cardW + gap);
|
||||
const centerY = gridY + (cardH + gap);
|
||||
doc.save();
|
||||
doc.roundedRect(centerX, centerY, cardW, cardH, 8).fillAndStroke('#0f172a', '#0f172a');
|
||||
doc.fillColor('#ffffff').fontSize(17).text(daily?.centerSlot.title || brandName(payload), centerX + 10, centerY + 35, {
|
||||
width: cardW - 20,
|
||||
align: 'center',
|
||||
});
|
||||
doc.fillColor('#cbd5e1').fontSize(9).text(daily?.centerSlot.subtitle || daily?.brand.ctaLine || '每日一练', centerX + 10, centerY + 72, {
|
||||
width: cardW - 20,
|
||||
align: 'center',
|
||||
});
|
||||
doc.restore();
|
||||
|
||||
doc.fillColor('#64748b').fontSize(8).text(
|
||||
`${daily?.brand?.name || '恭学教育'} · ${daily?.brand?.ctaLine || '每日一练 · 精选八题 · 稳步上岸'}`,
|
||||
36,
|
||||
doc.page.height - 44,
|
||||
{ width: doc.page.width - 72, align: 'center' },
|
||||
);
|
||||
doc.end();
|
||||
return {
|
||||
body: await done,
|
||||
mimeType: 'application/pdf',
|
||||
extension: 'pdf',
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderExportPdf(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||
if (isDailyPractice(payload)) {
|
||||
return renderDailyPracticePdf(payload);
|
||||
}
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, autoFirstPage: true });
|
||||
const done = collectPdf(doc);
|
||||
const fontPath = findPdfFont();
|
||||
@@ -255,6 +396,10 @@ function docxQuestionParagraphs(question: ExportQuestion, index: number, payload
|
||||
}
|
||||
|
||||
export async function renderExportDocx(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||
if (isDailyPractice(payload)) {
|
||||
return renderDailyPracticeDocx(payload);
|
||||
}
|
||||
|
||||
const watermark = watermarkText(payload);
|
||||
const children: Paragraph[] = [
|
||||
new Paragraph({
|
||||
@@ -338,3 +483,77 @@ export async function renderExportDocx(payload: BuiltExportPayload): Promise<Ren
|
||||
extension: 'docx',
|
||||
};
|
||||
}
|
||||
|
||||
async function renderDailyPracticeDocx(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||
const daily = payload.dailyPractice;
|
||||
const title = daily?.issue || payload.title || '每日一练';
|
||||
const children: Paragraph[] = [
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 160 },
|
||||
children: [new TextRun({ text: title, bold: true, font: 'Microsoft YaHei', size: 36 })],
|
||||
}),
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 220 },
|
||||
children: [new TextRun({
|
||||
text: `${daily?.date || payload.exportedAt.slice(0, 10)} · ${daily?.brand.slogan || '专注高职升本'} · ${daily?.brand.ctaLine || '每日一练'}`,
|
||||
color: '64748B',
|
||||
font: 'Microsoft YaHei',
|
||||
size: 19,
|
||||
})],
|
||||
}),
|
||||
];
|
||||
payload.questions.slice(0, 8).forEach((question, index) => {
|
||||
children.push(...docxQuestionParagraphs(question, index, payload));
|
||||
});
|
||||
children.push(new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 220 },
|
||||
children: [new TextRun({
|
||||
text: daily?.brand.name || '恭学教育',
|
||||
bold: true,
|
||||
color: '0F172A',
|
||||
font: 'Microsoft YaHei',
|
||||
size: 24,
|
||||
})],
|
||||
}));
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'Tiku SaaS Export Worker',
|
||||
title,
|
||||
description: 'Generated by Tiku SaaS daily practice export worker',
|
||||
sections: [
|
||||
{
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: watermarkText(payload) || brandName(payload), color: 'CBD5E1', font: 'Microsoft YaHei', size: 18 })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ children: ['第 ', PageNumber.CURRENT, ' / ', PageNumber.TOTAL_PAGES, ' 页'], font: 'Microsoft YaHei', size: 18 })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
body: await Packer.toBuffer(doc),
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
extension: 'docx',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user