diff --git a/.autograde/generate_pdf_report.py b/.autograde/generate_pdf_report.py
index 0fbb263..e58ee3b 100644
--- a/.autograde/generate_pdf_report.py
+++ b/.autograde/generate_pdf_report.py
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
"""
-生成 PDF 成绩报告
+生成专业的 PDF 成绩报告
-合并 REPORT.md、FRONTEND.md、成绩摘要,生成完整的 PDF 报告
-使用 weasyprint 进行 HTML→PDF 转换
+适用于打印归档,包含:
+- 封面页(课程信息、学生信息)
+- 后端开发反思报告
+- 前端开发反思报告
+- 评分详情页
"""
import argparse
@@ -17,6 +20,7 @@ from pathlib import Path
try:
import markdown
from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
HAS_PDF_SUPPORT = True
except ImportError:
HAS_PDF_SUPPORT = False
@@ -42,101 +46,17 @@ def read_file(filepath):
return ""
-def generate_grade_summary(final_grade):
- """生成成绩摘要 Markdown"""
- total = final_grade.get("total_score", 0)
- max_score = final_grade.get("max_score", 100)
- breakdown = final_grade.get("breakdown", {})
-
- lines = [
- "# 📊 成绩报告",
- "",
- f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
- "",
- f"## 总分: {total:.2f} / {max_score}",
- "",
- "### 分项成绩",
- "",
- "| 项目 | 得分 | 满分 |",
- "|------|------|------|",
- ]
-
- # 编程测试
- prog = breakdown.get("programming", {})
- if prog:
- lines.append(f"| 编程测试 | {prog.get('score', 0):.2f} | {prog.get('max_score', 80)} |")
-
- # REPORT.md
- report = breakdown.get("report", {})
- if report:
- lines.append(f"| REPORT.md | {report.get('score', 0):.2f} | {report.get('max_score', 10)} |")
-
- # FRONTEND.md
- frontend = breakdown.get("frontend", {})
- if frontend:
- lines.append(f"| FRONTEND.md | {frontend.get('score', 0):.2f} | {frontend.get('max_score', 10)} |")
-
- # 编程测试详情
- if prog.get("groups"):
- lines.extend([
- "",
- "### 编程测试详情",
- "",
- "| 分组 | 通过 | 总数 | 得分 | 满分 |",
- "|------|------|------|------|------|",
- ])
- for group_name, group_info in prog["groups"].items():
- lines.append(
- f"| {group_name} | {group_info.get('passed', 0)} | "
- f"{group_info.get('total', 0)} | {group_info.get('score', 0):.2f} | "
- f"{group_info.get('max_score', 0)} |"
- )
-
- # LLM 评分详情
- for section_key, section_title in [
- ("report", "📝 REPORT.md 评分详情"),
- ("frontend", "🎨 FRONTEND.md 评分详情")
- ]:
- section = breakdown.get(section_key, {})
- criteria = section.get("criteria", [])
- if criteria:
- lines.extend([
- "",
- f"### {section_title}",
- "",
- "| 评分项 | 得分 | 评语 |",
- "|--------|------|------|",
- ])
- for c in criteria:
- reason = c.get("reason", "").replace("|", "|").replace("\n", " ")[:80]
- lines.append(f"| {c.get('id', '')} | {c.get('score', 0)} | {reason} |")
-
- confidence = section.get("confidence")
- if confidence:
- lines.append(f"\n*置信度: {confidence:.2f}*")
-
- flags = section.get("flags", [])
- if flags:
- lines.append(f"\n*标记: {', '.join(flags)}*")
-
- lines.extend(["", "---", ""])
-
- return "\n".join(lines)
-
-
def fix_image_paths(content, images_dir):
- """修复图片路径为绝对路径(用于 PDF 生成)"""
+ """修复图片路径为绝对路径"""
if not images_dir or not os.path.isdir(images_dir):
return content
abs_images_dir = os.path.abspath(images_dir)
- # 替换 Markdown 图片语法
def replace_img(match):
alt = match.group(1)
src = match.group(2)
- if not src.startswith(('http://', 'https://', '/')):
- # 相对路径,转为绝对路径
+ if not src.startswith(('http://', 'https://', 'file://', '/')):
abs_src = os.path.join(abs_images_dir, os.path.basename(src))
if os.path.exists(abs_src):
return f''
@@ -146,118 +66,607 @@ def fix_image_paths(content, images_dir):
return content
-def create_combined_markdown(args, final_grade):
- """创建合并后的 Markdown 文件"""
+def markdown_to_html(md_content):
+ """将 Markdown 转换为 HTML(仅内容部分)"""
+ extensions = ['tables', 'fenced_code', 'nl2br']
+ return markdown.markdown(md_content, extensions=extensions)
+
+
+def generate_cover_page(student_id, assignment_name="VibeVault 期末大作业"):
+ """生成封面页 HTML"""
+ current_date = datetime.now().strftime('%Y年%m月%d日')
+ current_semester = "2025年秋季学期"
- # 成绩摘要
- grade_summary = generate_grade_summary(final_grade)
+ return f'''
+
+
+
+
+
《Java 程序设计》
+ 期末大作业
+ {assignment_name}
+
+
+
+
+
+ | 学 号: |
+ {student_id or ' ' * 12} |
+
+
+ | 姓 名: |
+ |
+
+
+ | 班 级: |
+ |
+
+
+ | 提交日期: |
+ {current_date} |
+
+
+
+
+
+
+ '''
+
+
+def generate_report_section(title, content, icon="📝"):
+ """生成报告章节 HTML"""
+ if not content or content.strip() in ['', '*(未提交)*']:
+ html_content = '(未提交)
'
+ else:
+ html_content = markdown_to_html(content)
- # 读取学生报告
+ return f'''
+
+
{icon} {title}
+
+ {html_content}
+
+
+ '''
+
+
+def generate_grade_page(final_grade):
+ """生成评分详情页 HTML"""
+ total = final_grade.get("total_score", 0)
+ max_score = final_grade.get("max_score", 100)
+ breakdown = final_grade.get("breakdown", {})
+
+ # 编程测试详情
+ prog = breakdown.get("programming", {})
+ prog_rows = ""
+ if prog.get("groups"):
+ for group_name, group_info in prog["groups"].items():
+ prog_rows += f'''
+
+ | {group_name} |
+ {group_info.get('passed', 0)} / {group_info.get('total', 0)} |
+ {group_info.get('score', 0):.1f} |
+ {group_info.get('max_score', 0)} |
+
+ '''
+
+ # LLM 评分详情
+ def format_llm_details(section_data, section_name):
+ criteria = section_data.get("criteria", [])
+ if not criteria:
+ return f'无详细评分
'
+
+ rows = ""
+ for c in criteria:
+ reason = c.get("reason", "").replace("<", "<").replace(">", ">")
+ rows += f'''
+
+ | {c.get('id', '')} |
+ {c.get('score', 0)} |
+ {reason} |
+
+ '''
+
+ confidence = section_data.get("confidence")
+ flags = section_data.get("flags", [])
+ footer = ""
+ if confidence:
+ footer += f'置信度: {confidence:.2f}'
+ if flags:
+ footer += f'标记: {", ".join(flags)}'
+
+ return f'''
+
+
+ '''
+
+ report = breakdown.get("report", {})
+ frontend = breakdown.get("frontend", {})
+
+ return f'''
+
+
📊 评分详情
+
+
+
+ {total:.1f}
+ / {max_score}
+
+
总分
+
+
+
+
成绩汇总
+
+
+ | 项目 | 得分 | 满分 | 占比 |
+
+
+
+ | 编程测试 |
+ {prog.get('score', 0):.1f} |
+ {prog.get('max_score', 80)} |
+ {prog.get('max_score', 80)}% |
+
+
+ | 后端反思报告 |
+ {report.get('score', 0):.1f} |
+ {report.get('max_score', 10)} |
+ {report.get('max_score', 10)}% |
+
+
+ | 前端反思报告 |
+ {frontend.get('score', 0):.1f} |
+ {frontend.get('max_score', 10)} |
+ {frontend.get('max_score', 10)}% |
+
+
+
+
+
+
+
编程测试详情
+
+
+ | 测试组 | 通过数 | 得分 | 满分 |
+
+ {prog_rows or '| 无测试数据 |
'}
+
+
+
+
+
后端反思报告评分
+ {format_llm_details(report, 'report')}
+
+
+
+
前端反思报告评分
+ {format_llm_details(frontend, 'frontend')}
+
+
+
+
+ '''
+
+
+def get_css_styles():
+ """获取 PDF 样式"""
+ return '''
+ @page {
+ size: A4;
+ margin: 2cm 2.5cm;
+
+ @bottom-center {
+ content: counter(page);
+ font-size: 10pt;
+ color: #666;
+ }
+ }
+
+ @page cover {
+ margin: 0;
+ @bottom-center { content: none; }
+ }
+
+ @font-face {
+ font-family: 'Noto Sans CJK SC';
+ src: local('Noto Sans CJK SC'), local('Noto Sans SC'),
+ local('Source Han Sans SC'), local('Source Han Sans CN'),
+ local('PingFang SC'), local('Microsoft YaHei'),
+ local('SimHei'), local('WenQuanYi Micro Hei');
+ }
+
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ body {
+ font-family: 'Noto Sans CJK SC', 'Source Han Sans SC', 'PingFang SC',
+ 'Microsoft YaHei', 'SimHei', 'WenQuanYi Micro Hei', sans-serif;
+ font-size: 11pt;
+ line-height: 1.8;
+ color: #333;
+ }
+
+ /* 封面页样式 */
+ .cover-page {
+ page: cover;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ padding: 3cm;
+ page-break-after: always;
+ }
+
+ .cover-header {
+ margin-bottom: 4cm;
+ }
+
+ .university-name {
+ font-size: 18pt;
+ color: #1a5490;
+ letter-spacing: 0.5em;
+ font-weight: bold;
+ }
+
+ .cover-title h1 {
+ font-size: 26pt;
+ color: #1a5490;
+ margin-bottom: 0.5cm;
+ font-weight: bold;
+ }
+
+ .cover-title h2 {
+ font-size: 20pt;
+ color: #333;
+ margin-bottom: 0.3cm;
+ font-weight: normal;
+ }
+
+ .cover-title h3 {
+ font-size: 14pt;
+ color: #666;
+ font-weight: normal;
+ }
+
+ .cover-info {
+ margin-top: 3cm;
+ }
+
+ .info-table {
+ margin: 0 auto;
+ border-collapse: collapse;
+ }
+
+ .info-table td {
+ padding: 0.4cm 0.5cm;
+ font-size: 12pt;
+ }
+
+ .info-table .label {
+ text-align: right;
+ color: #333;
+ }
+
+ .info-table .value {
+ text-align: left;
+ min-width: 6cm;
+ }
+
+ .info-table .underline {
+ border-bottom: 1px solid #333;
+ }
+
+ .cover-footer {
+ margin-top: 4cm;
+ color: #666;
+ font-size: 11pt;
+ }
+
+ /* 报告章节样式 */
+ .report-section {
+ page-break-before: always;
+ }
+
+ .section-title {
+ font-size: 18pt;
+ color: #1a5490;
+ border-bottom: 2px solid #1a5490;
+ padding-bottom: 0.3cm;
+ margin-bottom: 0.8cm;
+ }
+
+ .section-content {
+ text-align: justify;
+ }
+
+ .section-content h1 {
+ font-size: 16pt;
+ color: #1a5490;
+ margin: 1cm 0 0.5cm 0;
+ }
+
+ .section-content h2 {
+ font-size: 14pt;
+ color: #333;
+ margin: 0.8cm 0 0.4cm 0;
+ }
+
+ .section-content h3 {
+ font-size: 12pt;
+ color: #555;
+ margin: 0.6cm 0 0.3cm 0;
+ }
+
+ .section-content p {
+ margin: 0.4cm 0;
+ text-indent: 2em;
+ }
+
+ .section-content ul, .section-content ol {
+ margin: 0.4cm 0 0.4cm 1.5cm;
+ }
+
+ .section-content li {
+ margin: 0.2cm 0;
+ }
+
+ .section-content img {
+ max-width: 100%;
+ height: auto;
+ margin: 0.5cm auto;
+ display: block;
+ border: 1px solid #ddd;
+ }
+
+ .section-content code {
+ font-family: 'Consolas', 'Monaco', monospace;
+ background: #f5f5f5;
+ padding: 0.1cm 0.2cm;
+ border-radius: 3px;
+ font-size: 10pt;
+ }
+
+ .section-content pre {
+ background: #f5f5f5;
+ padding: 0.5cm;
+ border-radius: 5px;
+ overflow-x: auto;
+ font-size: 9pt;
+ margin: 0.5cm 0;
+ }
+
+ .section-content blockquote {
+ border-left: 4px solid #1a5490;
+ padding-left: 0.5cm;
+ margin: 0.5cm 0;
+ color: #555;
+ background: #f9f9f9;
+ padding: 0.3cm 0.5cm;
+ }
+
+ .section-content table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.5cm 0;
+ font-size: 10pt;
+ }
+
+ .section-content th, .section-content td {
+ border: 1px solid #ddd;
+ padding: 0.3cm;
+ text-align: left;
+ }
+
+ .section-content th {
+ background: #1a5490;
+ color: white;
+ }
+
+ .section-content tr:nth-child(even) {
+ background: #f9f9f9;
+ }
+
+ .empty-notice {
+ color: #999;
+ font-style: italic;
+ text-align: center;
+ padding: 2cm;
+ }
+
+ /* 评分页样式 */
+ .grade-page {
+ page-break-before: always;
+ }
+
+ .page-title {
+ font-size: 18pt;
+ color: #1a5490;
+ text-align: center;
+ margin-bottom: 1cm;
+ }
+
+ .total-score {
+ text-align: center;
+ margin: 1cm 0;
+ }
+
+ .score-circle {
+ display: inline-block;
+ width: 4cm;
+ height: 4cm;
+ border: 4px solid #1a5490;
+ border-radius: 50%;
+ line-height: 4cm;
+ text-align: center;
+ }
+
+ .score-value {
+ font-size: 28pt;
+ font-weight: bold;
+ color: #1a5490;
+ }
+
+ .score-max {
+ font-size: 14pt;
+ color: #666;
+ }
+
+ .score-label {
+ font-size: 12pt;
+ color: #666;
+ margin-top: 0.3cm;
+ }
+
+ .grade-summary, .grade-details {
+ margin: 0.8cm 0;
+ }
+
+ .grade-summary h2, .grade-details h2 {
+ font-size: 14pt;
+ color: #333;
+ border-bottom: 1px solid #ddd;
+ padding-bottom: 0.2cm;
+ margin-bottom: 0.4cm;
+ }
+
+ .summary-table, .detail-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 10pt;
+ }
+
+ .summary-table th, .summary-table td,
+ .detail-table th, .detail-table td {
+ border: 1px solid #ddd;
+ padding: 0.25cm 0.4cm;
+ text-align: left;
+ }
+
+ .summary-table th, .detail-table th {
+ background: #1a5490;
+ color: white;
+ font-weight: normal;
+ }
+
+ .summary-table tr:nth-child(even),
+ .detail-table tr:nth-child(even) {
+ background: #f9f9f9;
+ }
+
+ .score-cell {
+ text-align: center;
+ font-weight: bold;
+ color: #1a5490;
+ }
+
+ .reason-cell {
+ font-size: 9pt;
+ color: #555;
+ max-width: 10cm;
+ }
+
+ .detail-footer {
+ font-size: 9pt;
+ color: #666;
+ margin-top: 0.2cm;
+ }
+
+ .detail-footer .confidence {
+ margin-right: 1cm;
+ }
+
+ .detail-footer .flags {
+ color: #c00;
+ }
+
+ .no-detail {
+ color: #999;
+ font-style: italic;
+ padding: 0.5cm;
+ text-align: center;
+ }
+
+ .grade-footer {
+ margin-top: 1cm;
+ padding-top: 0.5cm;
+ border-top: 1px solid #ddd;
+ font-size: 9pt;
+ color: #999;
+ text-align: center;
+ }
+
+ .grade-footer p {
+ margin: 0.1cm 0;
+ text-indent: 0;
+ }
+ '''
+
+
+def create_full_html(args, final_grade, student_id):
+ """创建完整的 HTML 文档"""
+
+ # 读取报告内容
report_content = read_file(args.report)
frontend_content = read_file(args.frontend)
# 修复图片路径
frontend_content = fix_image_paths(frontend_content, args.images)
- # 合并内容
- combined = [
- grade_summary,
- "# 📝 后端开发反思报告",
- "",
- report_content.strip() if report_content.strip() else "*(未提交)*",
- "",
- "---",
- "",
- "# 🎨 前端开发反思报告",
- "",
- frontend_content.strip() if frontend_content.strip() else "*(未提交)*",
- ]
+ # 移除报告中的标题行(避免重复)
+ report_content = re.sub(r'^#\s*后端开发反思报告.*\n', '', report_content, flags=re.MULTILINE)
+ frontend_content = re.sub(r'^#\s*前端开发反思报告.*\n', '', frontend_content, flags=re.MULTILINE)
- return "\n".join(combined)
-
-
-def markdown_to_html(md_content):
- """将 Markdown 转换为 HTML"""
- extensions = ['tables', 'fenced_code', 'codehilite']
- html_body = markdown.markdown(md_content, extensions=extensions)
-
- # 添加 CSS 样式
+ # 构建 HTML
html = f'''
-
+
-
-
+
+ Java程序设计 - 期末大作业报告
+
-{html_body}
+ {generate_cover_page(student_id)}
+ {generate_report_section("后端开发反思报告", report_content)}
+ {generate_report_section("前端开发反思报告", frontend_content, "🎨")}
+ {generate_grade_page(final_grade)}
'''
+
return html
-def convert_to_pdf(md_content, pdf_file, images_dir=None):
- """使用 weasyprint 将 Markdown 转换为 PDF"""
+def convert_to_pdf(html_content, pdf_file, images_dir=None):
+ """使用 weasyprint 生成 PDF"""
if not HAS_PDF_SUPPORT:
- print("weasyprint not available, skipping PDF generation", file=sys.stderr)
+ print("weasyprint not available", file=sys.stderr)
return False
try:
- html_content = markdown_to_html(md_content)
-
- # 设置基础 URL 用于图片加载
+ font_config = FontConfiguration()
base_url = os.path.abspath(images_dir) if images_dir else os.getcwd()
- HTML(string=html_content, base_url=base_url).write_pdf(pdf_file)
+ HTML(string=html_content, base_url=base_url).write_pdf(
+ pdf_file,
+ font_config=font_config
+ )
return True
except Exception as e:
print(f"PDF generation error: {e}", file=sys.stderr)
@@ -265,37 +674,44 @@ def convert_to_pdf(md_content, pdf_file, images_dir=None):
def main():
- parser = argparse.ArgumentParser(description="Generate PDF grade report")
+ parser = argparse.ArgumentParser(description="Generate professional PDF grade report")
parser.add_argument("--report", default="REPORT.md", help="REPORT.md file path")
parser.add_argument("--frontend", default="FRONTEND.md", help="FRONTEND.md file path")
parser.add_argument("--grade", default="final_grade.json", help="Final grade JSON file")
parser.add_argument("--images", default="images", help="Images directory")
parser.add_argument("--out", default="grade_report.pdf", help="Output PDF file")
- parser.add_argument("--student-id", default="", help="Student ID for filename")
+ parser.add_argument("--student-id", default="", help="Student ID")
args = parser.parse_args()
+ # 从环境变量获取学生 ID
+ student_id = args.student_id or os.getenv("STUDENT_ID", "")
+ if not student_id:
+ repo = os.getenv("REPO", "")
+ match = re.search(r'-stu[_-]([a-zA-Z0-9_]+)$', repo)
+ if match:
+ student_id = match.group(1)
+
# 加载成绩
final_grade = load_json(args.grade, {"total_score": 0, "max_score": 100, "breakdown": {}})
- # 创建合并的 Markdown
- combined_md = create_combined_markdown(args, final_grade)
+ # 创建 HTML
+ html_content = create_full_html(args, final_grade, student_id)
- # 保存 Markdown 文件(始终保存,作为备份)
- md_out = args.out.replace(".pdf", ".md")
- with open(md_out, "w", encoding="utf-8") as f:
- f.write(combined_md)
- print(f"📝 Markdown report saved: {md_out}")
+ # 保存 HTML(调试用)
+ html_out = args.out.replace(".pdf", ".html")
+ with open(html_out, "w", encoding="utf-8") as f:
+ f.write(html_content)
- # 尝试生成 PDF
+ # 生成 PDF
if HAS_PDF_SUPPORT:
- if convert_to_pdf(combined_md, args.out, args.images):
+ if convert_to_pdf(html_content, args.out, args.images):
print(f"✅ PDF report generated: {args.out}")
return 0
else:
- print(f"⚠️ PDF generation failed, markdown saved as fallback", file=sys.stderr)
- return 0
+ print(f"⚠️ PDF generation failed", file=sys.stderr)
+ return 1
else:
- print(f"ℹ️ PDF support not available (weasyprint not installed), markdown saved")
+ print(f"ℹ️ weasyprint not installed, HTML saved: {html_out}")
return 0
diff --git a/.gitea/workflows/autograde.yml b/.gitea/workflows/autograde.yml
index 36d6d1c..9cadbc5 100644
--- a/.gitea/workflows/autograde.yml
+++ b/.gitea/workflows/autograde.yml
@@ -32,8 +32,11 @@ jobs:
done
apt-get -o Acquire::Check-Valid-Until=false update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip rsync \
- libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info
+ libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info \
+ fonts-noto-cjk fonts-wqy-microhei
pip3 install --break-system-packages python-dotenv requests markdown weasyprint -i https://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com
+ # 刷新字体缓存
+ fc-cache -f -v > /dev/null 2>&1 || true
rm -rf /var/lib/apt/lists/*
- name: Configure Gradle mirror (Aliyun)