feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
173
src/shared/lib/excel.ts
Normal file
173
src/shared/lib/excel.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import "server-only"
|
||||
|
||||
import ExcelJS from "exceljs"
|
||||
|
||||
export type ExcelColumn = {
|
||||
header: string
|
||||
key: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
export type ExcelSheet = {
|
||||
name: string
|
||||
columns: ExcelColumn[]
|
||||
rows: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export type TemplateColumn = ExcelColumn & {
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type TemplateSheet = {
|
||||
name: string
|
||||
columns: TemplateColumn[]
|
||||
sampleRows?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export type ParsedSheet = {
|
||||
sheetName: string
|
||||
rows: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出数据到 Excel Buffer
|
||||
*/
|
||||
export async function exportToExcel(params: {
|
||||
sheets: ExcelSheet[]
|
||||
}): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
|
||||
for (const sheet of params.sheets) {
|
||||
const worksheet = workbook.addWorksheet(sheet.name, {
|
||||
views: [{ state: "frozen", ySplit: 1 }],
|
||||
})
|
||||
|
||||
worksheet.columns = sheet.columns.map((col) => ({
|
||||
header: col.header,
|
||||
key: col.key,
|
||||
width: col.width ?? 20,
|
||||
}))
|
||||
|
||||
// Header style
|
||||
const headerRow = worksheet.getRow(1)
|
||||
headerRow.font = { bold: true }
|
||||
headerRow.fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFE0E7FF" },
|
||||
}
|
||||
headerRow.alignment = { vertical: "middle", horizontal: "left" }
|
||||
|
||||
for (const row of sheet.rows) {
|
||||
worksheet.addRow(row)
|
||||
}
|
||||
|
||||
worksheet.autoFilter = {
|
||||
from: { row: 1, column: 1 },
|
||||
to: { row: 1, column: sheet.columns.length },
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await workbook.xlsx.writeBuffer()
|
||||
return Buffer.from(arrayBuffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Buffer 解析 Excel
|
||||
*/
|
||||
export async function parseExcel(buffer: Buffer): Promise<ParsedSheet[]> {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(buffer as unknown as ArrayBuffer)
|
||||
|
||||
const result: ParsedSheet[] = []
|
||||
|
||||
workbook.worksheets.forEach((worksheet) => {
|
||||
if (worksheet.rowCount === 0) {
|
||||
result.push({ sheetName: worksheet.name, rows: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const headerRow = worksheet.getRow(1)
|
||||
const headers: string[] = []
|
||||
const keys: string[] = []
|
||||
|
||||
headerRow.eachCell((cell, colNumber) => {
|
||||
const header = String(cell.value ?? "").trim()
|
||||
headers.push(header)
|
||||
keys.push(header || `column_${colNumber}`)
|
||||
})
|
||||
|
||||
const rows: Record<string, unknown>[] = []
|
||||
for (let rowNum = 2; rowNum <= worksheet.rowCount; rowNum++) {
|
||||
const row = worksheet.getRow(rowNum)
|
||||
const record: Record<string, unknown> = {}
|
||||
let hasValue = false
|
||||
|
||||
keys.forEach((key, idx) => {
|
||||
const cell = row.getCell(idx + 1)
|
||||
const value = cell.value
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
record[key] = typeof value === "object" && "text" in value
|
||||
? String((value as { text: string }).text)
|
||||
: value
|
||||
hasValue = true
|
||||
} else {
|
||||
record[key] = ""
|
||||
}
|
||||
})
|
||||
|
||||
if (hasValue) rows.push(record)
|
||||
}
|
||||
|
||||
result.push({ sheetName: worksheet.name, rows })
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成导入模板 Buffer
|
||||
*/
|
||||
export async function generateTemplate(params: {
|
||||
sheets: TemplateSheet[]
|
||||
}): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
|
||||
for (const sheet of params.sheets) {
|
||||
const worksheet = workbook.addWorksheet(sheet.name)
|
||||
|
||||
worksheet.columns = sheet.columns.map((col) => ({
|
||||
header: col.header,
|
||||
key: col.key,
|
||||
width: col.width ?? 22,
|
||||
}))
|
||||
|
||||
const headerRow = worksheet.getRow(1)
|
||||
headerRow.font = { bold: true }
|
||||
headerRow.fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFE0E7FF" },
|
||||
}
|
||||
|
||||
// Notes row (row 2)
|
||||
const noteRow = worksheet.getRow(2)
|
||||
sheet.columns.forEach((col, idx) => {
|
||||
const cell = noteRow.getCell(idx + 1)
|
||||
cell.value = col.note ?? ""
|
||||
cell.font = { italic: true, color: { argb: "FF6B7280" } }
|
||||
})
|
||||
|
||||
// Sample rows (starting from row 3)
|
||||
const sampleRows = sheet.sampleRows ?? []
|
||||
sampleRows.forEach((row) => worksheet.addRow(row))
|
||||
|
||||
// Empty row for user input
|
||||
if (sampleRows.length === 0) {
|
||||
worksheet.addRow({})
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await workbook.xlsx.writeBuffer()
|
||||
return Buffer.from(arrayBuffer)
|
||||
}
|
||||
Reference in New Issue
Block a user