feat(task1): restructure directories for turborepo monorepo

- Move backend/ to apps/server/ via git mv
- Move frontend/ to apps/admin/ via git mv
- Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
2026-07-02 15:05:12 +08:00
parent 4704adcba1
commit 46a817503e
137 changed files with 52 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
import { Deposit } from '../entities/deposit.entity';
import * as ExcelJS from 'exceljs';
import * as PDFDocument from 'pdfkit';
import { Response } from 'express';
@Injectable()
export class BillsExportService {
constructor(
@InjectRepository(Bill) private billRepo: Repository<Bill>,
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
) {}
/**
* 导出账单列表为 Excel
*/
async exportExcel(query: { periodStart?: string; periodEnd?: string; studentId?: number; status?: string }, res: Response) {
const qb = this.billRepo.createQueryBuilder('b')
.leftJoinAndSelect('b.student', 'student')
.leftJoinAndSelect('b.items', 'items')
.orderBy('b.generatedAt', 'DESC');
if (query.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart });
if (query.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd });
if (query.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId });
if (query.status) qb.andWhere('b.status = :status', { status: query.status });
const bills = await qb.getMany();
// 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
const depMap = new Map<number, number>();
if (studentIds.length > 0) {
const deposits = await this.depositRepo.createQueryBuilder('d')
.where('d.studentId IN (:...ids)', { ids: studentIds })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
for (const d of deposits) {
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
}
}
const workbook = new ExcelJS.Workbook();
workbook.creator = '恭学教育基地管理系统';
// Sheet 1: 账单汇总
const ws = workbook.addWorksheet('账单汇总');
ws.columns = [
{ header: '账单ID', key: 'id', width: 10 },
{ header: '学生姓名', key: 'studentName', width: 14 },
{ header: '计费周期', key: 'period', width: 24 },
{ header: '分摊费用', key: 'shared', width: 12 },
{ header: '个人费用', key: 'personal', width: 12 },
{ header: '总金额', key: 'total', width: 12 },
{ header: '可用押金', key: 'deposit', width: 12 },
{ header: '押金抵扣', key: 'depositApplied', width: 12 },
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
{ header: '状态', key: 'status', width: 10 },
{ header: '生成时间', key: 'generatedAt', width: 20 },
];
// 表头样式
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { draft: '草稿', confirmed: '已确认', paid: '已结清' };
for (const bill of bills) {
const total = Number(bill.totalAmount || 0);
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
const applied = Number(Math.min(dep, total).toFixed(2));
const after = Number(Math.max(0, total - applied).toFixed(2));
ws.addRow({
id: bill.id,
studentName: (bill as any).student?.name || '-',
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
shared: Number(bill.sharedAmount),
personal: Number(bill.personalAmount),
total,
deposit: dep,
depositApplied: applied,
afterDeposit: after,
status: statusMap[bill.status] || bill.status,
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
});
}
// Sheet 2: 费用明细
const ws2 = workbook.addWorksheet('费用明细');
ws2.columns = [
{ header: '账单ID', key: 'billId', width: 10 },
{ header: '学生姓名', key: 'studentName', width: 14 },
{ header: '费用类型', key: 'expenseType', width: 12 },
{ header: '说明', key: 'description', width: 24 },
{ header: '计费天数', key: 'days', width: 10 },
{ header: '宿舍总人天', key: 'totalRoomDays', width: 12 },
{ header: '宿舍总费用', key: 'roomTotal', width: 12 },
{ header: '学生应付', key: 'studentAmount', width: 12 },
];
ws2.getRow(1).font = { bold: true };
ws2.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
for (const bill of bills) {
for (const item of bill.items || []) {
ws2.addRow({
billId: bill.id,
studentName: (bill as any).student?.name || '-',
expenseType: item.expenseType,
description: item.description,
days: item.days,
totalRoomDays: item.totalRoomDays,
roomTotal: Number(item.roomTotalAmount),
studentAmount: Number(item.studentAmount),
});
}
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=bills_${Date.now()}.xlsx`);
await workbook.xlsx.write(res);
res.end();
}
/**
* 导出单个学生的 PDF 账单
*/
async exportStudentPdf(billId: number, res: Response) {
const bill = await this.billRepo.findOne({ where: { id: billId }, relations: ['student', 'items'] });
if (!bill) { res.status(404).json({ message: '账单不存在' }); return; }
// 查询该学生的可用押金(已缴未退)
const deposits = await this.depositRepo.createQueryBuilder('d')
.where('d.studentId = :sid', { sid: bill.studentId })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0);
const totalAmount = Number(bill.totalAmount || 0);
const depositApplied = Math.min(availableDeposit, totalAmount);
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
const doc = new PDFDocument({ size: 'A4', margin: 50 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=bill_${billId}.pdf`);
doc.pipe(res);
// 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux
const fontPaths = [
'/System/Library/Fonts/PingFang.ttc', // macOS
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', // Linux Noto
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/noto-cjk/NotoSansSC-Regular.otf',
'/usr/share/fonts/wqy-microhei/wqy-microhei.ttc', // Linux WenQuanYi
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
];
let fontRegistered = false;
const fs = require('fs');
for (const fp of fontPaths) {
try {
if (fs.existsSync(fp)) {
doc.registerFont('Chinese', fp);
doc.font('Chinese');
fontRegistered = true;
break;
}
} catch {}
}
if (!fontRegistered) {
// 如果没有中文字体,使用 Helvetica中文可能乱码
doc.font('Helvetica');
}
const statusMap: Record<string, string> = { draft: '草稿', confirmed: '已确认', paid: '已结清' };
// 标题
doc.fontSize(20).text('恭学教育基地水电费账单', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#666').text(`生成时间: ${new Date().toLocaleString('zh-CN')}`, { align: 'center' });
doc.moveDown(1);
// 基本信息
doc.fontSize(12).fillColor('#000');
doc.text(`学生姓名: ${(bill as any).student?.name || '-'}`);
doc.text(`计费周期: ${bill.periodStart} ~ ${bill.periodEnd}`);
doc.text(`账单状态: ${statusMap[bill.status] || bill.status}`);
doc.moveDown(0.5);
// 金额汇总
doc.fontSize(14).text('费用汇总', { underline: true });
doc.moveDown(0.3);
doc.fontSize(12);
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
doc.text(`个人费用: ¥${Number(bill.personalAmount).toFixed(2)}`);
doc.fontSize(14).fillColor('#007AFF').text(`应付总额: ¥${totalAmount.toFixed(2)}`);
doc.moveDown(0.3);
if (availableDeposit > 0) {
doc.fontSize(11).fillColor('#52C41A').text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
doc.fontSize(11).fillColor('#FA8C16').text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
doc.fontSize(14).fillColor('#FF3B30').text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
}
doc.moveDown(1);
// 明细表格
doc.fontSize(14).fillColor('#000').text('费用明细', { underline: true });
doc.moveDown(0.5);
const items = bill.items || [];
const tableTop = doc.y;
const colWidths = [120, 180, 60, 60, 70];
const headers = ['费用类型', '说明', '天数', '总人天', '金额(元)'];
// 表头
doc.fontSize(10).fillColor('#333');
let x = 50;
for (let i = 0; i < headers.length; i++) {
doc.text(headers[i], x, tableTop, { width: colWidths[i], align: 'left' });
x += colWidths[i];
}
doc.moveDown(0.3);
doc.moveTo(50, doc.y).lineTo(540, doc.y).stroke('#ccc');
doc.moveDown(0.2);
// 数据行
for (const item of items) {
const y = doc.y;
x = 50;
doc.fontSize(9).fillColor('#000');
doc.text(item.expenseType || '', x, y, { width: colWidths[0] }); x += colWidths[0];
doc.text(item.description || '', x, y, { width: colWidths[1] }); x += colWidths[1];
doc.text(String(item.days || 0), x, y, { width: colWidths[2] }); x += colWidths[2];
doc.text(String(item.totalRoomDays || 0), x, y, { width: colWidths[3] }); x += colWidths[3];
doc.text(Number(item.studentAmount).toFixed(2), x, y, { width: colWidths[4] });
doc.moveDown(0.8);
}
doc.moveDown(2);
doc.fontSize(8).fillColor('#999').text('本账单由恭学教育基地管理系统自动生成', { align: 'center' });
doc.end();
}
}