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

@@ -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',
};
}