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 // Unsettled 表示这行成绩已发布,但整组还没结算:延迟结算(分批发布)下名次与积分 // 要等该 (小项, 组别) 录完才写,此刻行上没有名次、没有流水。 // 它必须与「算出来就是 0 分、没名次」区分开(R18:占位符口径),见 eventRows。 // 与 Unpublished / NoResult 互斥——那两态是「没有值」,这一态是「值有了,名次与积分还没到」。 Unsettled 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 时导出全部小项。 // 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 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 = "分数未录入" // eventCellUnsettled 是「成绩已发布但整组未结算」行在状态列的文案。 // 只把名次 / 积分写成「—」还不够:三格里的成绩有值、状态又写「有效」, // 打印件上仍会被读成「算出来就是 0 分、没有名次」(R18 要避免的正是这个), // 所以状态列直接写「未结算」,让人一眼看出是没结算、不是没算出来。 const eventCellUnsettled = "未结算" // 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 status any = r.Status ) switch { case r.NoResult: // 连成绩行都没有:三格都没值,状态列才是「未录入」。 rank, result, points = eventCellBlank, eventCellBlank, eventCellBlank case r.Unpublished: // 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。 rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank case r.Unsettled: // 已发布没结算:成绩照常印(它确实跑出来了),名次与积分等结算, // 状态列写「未结算」把「还没到」与「就是 0 分」彻底分开。 rank, points, status = eventCellBlank, eventCellBlank, eventCellUnsettled } out = append(out, []any{ rank, r.Bib, r.TeamName, r.Participant, result, points, 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 } }