package export import ( "database/sql" "io" "strings" "github.com/xuri/excelize/v2" ) // EventResultRow 是分项成绩表的一行。 // Rank 用字符串是为了承载「1(并列)」这种展示值。 type EventResultRow struct { Rank string Bib string TeamName string Participant string ResultText string Unit string Points float64 Status string Remark string // Unpublished 表示这行有草稿成绩但还没发布:成绩值不写出来, // 名次与积分对未发布成绩没有意义,一并换成占位符,见 eventRows。 Unpublished bool // NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。 // 与 Unpublished 互斥——有成绩行才会有发布状态。 NoResult bool } // EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。 type EventSheet struct { SportName string EventName string Category string Rows []EventResultRow } // EventsWorkbook 是「分项成绩表」的内容:一个小项可含多个组别 sheet, // 也可承载全部小项(ExportAllXLSX 用)。 type EventsWorkbook struct { Meta Meta Sheets []EventSheet } // eventColumns 的列宽按「最长实际内容」预留:名次并列时写成「3(并列)」, // 列宽 8 且这一类列不折行,右边又紧挨非空的「号码」,在 Excel 里会被硬裁;取 11 才能连 // 「10(并列)」这种两位数并列一起放下。运动员/队 是接力队名单(「王磊、赵雷、陈刚2、郑凯」 // 约 23 个显示宽度),18 会折成两行,24 够放 4~6 人名单。加宽只影响 fitToWidth=1 的打印 // 缩放比例(整表总宽 105→114,A4 横向仍是一页),不会把表推到第二页;再宽就得重新权衡。 var eventColumns = []column{ {"名次", 11, kindCenter}, {"号码", 9, kindCenter}, {"单位", 22, kindText}, {"运动员/队", 24, kindText}, {"成绩", 12, kindCenter}, {"积分", 8, kindNumber}, {"状态", 8, kindCenter}, {"备注", 20, kindText}, } // ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。 func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error { wb, err := LoadEventWorkbook(db, eventID, opts) if err != nil { return err } f, err := eventFile(wb) if err != nil { return err } return saveWorkbook(f, dst) } // WriteEventXLSX 把分项成绩表写成 xlsx 流。 func WriteEventXLSX(w io.Writer, wb EventsWorkbook) error { f, err := eventFile(wb) if err != nil { return err } return f.Write(w) } func eventFile(wb EventsWorkbook) (*excelize.File, error) { return buildWorkbook("成绩表", func(f *excelize.File, st *styleSet) error { return writeEventSheets(f, st, wb.Meta, wb.Sheets, &sheetNamer{}) }) } func writeEventSheets(f *excelize.File, st *styleSet, meta Meta, sheets []EventSheet, namer *sheetNamer) error { meta = meta.normalized() for _, sh := range sheets { name := namer.add(eventSheetBase(sh)) if err := addSheet(f, name); err != nil { return err } if err := writeSheet(f, st, name, meta, eventSheetTitle(meta, sh), eventColumns, eventRows(sh.Rows)); err != nil { return err } } return nil } // eventSheetBase 是工作表名:大项·小项·组别,够短且能区分。 func eventSheetBase(sh EventSheet) string { parts := make([]string, 0, 3) if sh.SportName != "" { parts = append(parts, sh.SportName) } parts = append(parts, sh.EventName) if sh.Category != "" { parts = append(parts, sh.Category) } return strings.Join(parts, "·") } // eventSheetTitle 是表内大标题,带赛事名。 func eventSheetTitle(meta Meta, sh EventSheet) string { base := eventSheetBase(sh) if base == "" { base = "成绩表" } return sheetTitle(meta, base) } // eventCellBlank 是「这一格还没有数值」的占位符。留着空格子会让整行读起来像缺数据, // 占位符才读得出「这是一条完整记录,只是还没发布 / 还没录」。 const eventCellBlank = "—" // eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。 const eventCellUnpublished = "分数未录入" // eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。 // // 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值, // 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。 func eventRows(rows []EventResultRow) [][]any { out := make([][]any, 0, len(rows)) for _, r := range rows { var ( rank any = r.Rank result any = displayResult(r.ResultText, r.Unit) points any = r.Points ) switch { case r.NoResult: // 连成绩行都没有:三格都没值,状态列才是「未录入」。 rank, result, points = eventCellBlank, eventCellBlank, eventCellBlank case r.Unpublished: // 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。 rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank } out = append(out, []any{ rank, r.Bib, r.TeamName, r.Participant, result, points, r.Status, r.Remark, }) } return out } // displayResult 把成绩与单位拼成「11.58 秒」;单位已含在文本里时不重复拼接。 func displayResult(text, unit string) string { text = strings.TrimSpace(text) unit = strings.TrimSpace(unit) if text == "" { return "" } if unit == "" || strings.HasSuffix(text, unit) { return text } return text + " " + unitLabel(unit) } func unitLabel(unit string) string { switch unit { case "s": return "秒" case "m": return "米" case "pts": return "分" case "次": return "次" default: return unit } }