| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- #!/usr/bin/env python3
- """
- Generate seating charts from Excel class data.
- Required input columns: 班级, 姓名, 性别, 总分
- """
- import argparse
- import random
- import sys
- from pathlib import Path
- try:
- from openpyxl import Workbook
- from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
- from openpyxl.utils import get_column_letter
- except ImportError:
- print("Error: openpyxl is required. Install with: pip install openpyxl")
- sys.exit(1)
- def parse_args():
- parser = argparse.ArgumentParser(
- description="根据Excel学生数据生成座位表"
- )
- parser.add_argument(
- "excel_file", type=str,
- help="输入Excel文件路径 (.xlsx)"
- )
- parser.add_argument(
- "-c", "--class", dest="class_name", type=str, required=True,
- help="要生成座位表的班级名称"
- )
- parser.add_argument(
- "-s", "--style", type=str, required=True,
- help="座位表样式,如 '2222'(4组2人桌)、'3332'(3组3人桌+1组2人桌)"
- )
- parser.add_argument(
- "-m", "--allow-mixed", action="store_true",
- help="允许男女生同桌(默认不允许)"
- )
- parser.add_argument(
- "-t", "--strategy", type=str, default="good-with-good",
- choices=["good-with-good", "good-with-bad", "random"],
- help="成绩配对策略:good-with-good(好+好)、good-with-bad(好+差)、random(无关)"
- )
- parser.add_argument(
- "-o", "--output", type=str, default=None,
- help="输出Excel文件路径(默认:<输入文件>_<班级>_座位表.xlsx)"
- )
- parser.add_argument(
- "-n", "--count", type=int, default=1,
- help="生成座位表的个数(默认1个)"
- )
- parser.add_argument(
- "--seed", type=int, default=None,
- help="随机种子(用于 random 策略的可重复性)"
- )
- parser.add_argument(
- "--usage", action="help",
- help="显示帮助信息"
- )
- return parser.parse_args()
- def read_students(filepath, class_name):
- """从Excel读取学生数据,按班级筛选。自动查找表头行。"""
- from openpyxl import load_workbook
- wb = load_workbook(filepath, data_only=True)
- ws = wb.active
- required = ["班级", "姓名", "性别", "总分"]
- indices = {}
- header_row = None
- # 自动查找包含所有必需列的表头行(最多扫描前10行)
- for r in range(1, min(ws.max_row + 1, 11)):
- row_vals = []
- for col in range(1, ws.max_column + 1):
- val = ws.cell(row=r, column=col).value
- row_vals.append(str(val).strip() if val else "")
- if all(col_name in row_vals for col_name in required):
- for col_name in required:
- indices[col_name] = row_vals.index(col_name)
- header_row = r
- break
- if header_row is None:
- # 输出前几行帮助诊断
- print("错误:Excel文件中未找到 '班级'、'姓名'、'性别'、'总分' 列。")
- for r in range(1, min(ws.max_row + 1, 6)):
- preview = [str(ws.cell(row=r, column=c).value or "") for c in range(1, ws.max_column + 1)]
- print(f" 第{r}行:{preview}")
- sys.exit(1)
- # 规范化班级名:去除"班"后缀以便灵活匹配
- class_name_clean = class_name.rstrip("班")
- data_start = header_row + 1
- students = []
- for row in range(data_start, ws.max_row + 1):
- class_val = str(ws.cell(row=row, column=indices["班级"] + 1).value or "").strip()
- class_val_clean = class_val.rstrip("班")
- if class_val_clean != class_name_clean:
- continue
- name = str(ws.cell(row=row, column=indices["姓名"] + 1).value or "").strip()
- gender = str(ws.cell(row=row, column=indices["性别"] + 1).value or "").strip()
- score_str = str(ws.cell(row=row, column=indices["总分"] + 1).value or "").strip()
- if not name:
- continue
- try:
- score = float(score_str)
- except ValueError:
- score = 0.0
- students.append({"name": name, "gender": gender, "score": score})
- wb.close()
- return students
- def interleave_high_low(items):
- """将按分数降序排列的列表重新排列为 高-低-高-低 交错顺序。"""
- result = []
- left, right = 0, len(items) - 1
- while left <= right:
- result.append(items[left])
- left += 1
- if left <= right:
- result.append(items[right])
- right -= 1
- return result
- def build_desk_groups(style_str, num_students):
- """根据样式字符串构建桌组大小列表。"""
- sizes = [int(c) for c in style_str]
- for s in sizes:
- if s < 1:
- print(f"错误:样式中的数字必须大于0,发现 '{s}'")
- sys.exit(1)
- row_capacity = sum(sizes)
- num_rows = max(1, (num_students + row_capacity - 1) // row_capacity)
- desk_groups = []
- for _ in range(num_rows):
- for size in sizes:
- desk_groups.append(size)
- return desk_groups
- def is_male(gender):
- return gender in ("男", "M", "m", "male", "Male")
- def is_female(gender):
- return gender in ("女", "F", "f", "female", "Female")
- def allocate_students(students, desk_sizes, strategy, allow_mixed, seed):
- """将学生分配到桌组。"""
- if seed is not None:
- random.seed(seed)
- males = [s for s in students if is_male(s["gender"])]
- females = [s for s in students if is_female(s["gender"])]
- others = [s for s in students if not is_male(s["gender"]) and not is_female(s["gender"])]
- # 无法识别性别的归入人数多的一方
- if others:
- if len(males) >= len(females):
- males.extend(others)
- else:
- females.extend(others)
- # --- 允许混坐:所有学生一起排 ---
- if allow_mixed:
- all_students = males + females
- if strategy == "good-with-good":
- all_students.sort(key=lambda s: s["score"], reverse=True)
- elif strategy == "good-with-bad":
- all_students.sort(key=lambda s: s["score"], reverse=True)
- all_students = interleave_high_low(all_students)
- else:
- random.shuffle(all_students)
- groups = []
- idx = 0
- for size in desk_sizes:
- group = all_students[idx:idx + size]
- if group:
- groups.append(group)
- idx += size
- if idx >= len(all_students):
- break
- return groups
- # --- 性别分开排 ---
- if strategy == "good-with-good":
- males.sort(key=lambda s: s["score"], reverse=True)
- females.sort(key=lambda s: s["score"], reverse=True)
- elif strategy == "good-with-bad":
- males.sort(key=lambda s: s["score"], reverse=True)
- males = interleave_high_low(males)
- females.sort(key=lambda s: s["score"], reverse=True)
- females = interleave_high_low(females)
- else:
- random.shuffle(males)
- random.shuffle(females)
- groups = []
- m_idx, f_idx = 0, 0
- for size in desk_sizes:
- m_rem = len(males) - m_idx
- f_rem = len(females) - f_idx
- if m_rem >= f_rem:
- take = min(size, m_rem)
- group = males[m_idx:m_idx + take]
- m_idx += take
- else:
- take = min(size, f_rem)
- group = females[f_idx:f_idx + take]
- f_idx += take
- if group:
- groups.append(group)
- return groups
- def generate_excel(groups, style_str, output_path, class_name):
- """生成座位表Excel文件。"""
- wb = Workbook()
- ws = wb.active
- ws.title = f"{class_name}座位表"
- sizes = [int(c) for c in style_str]
- num_groups_per_row = len(sizes)
- num_rows = (len(groups) + num_groups_per_row - 1) // num_groups_per_row
- total_cols = sum(sizes) + (num_groups_per_row - 1)
- thin_border = Border(
- left=Side(style="thin"), right=Side(style="thin"),
- top=Side(style="thin"), bottom=Side(style="thin"),
- )
- center_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
- male_fill = PatternFill(start_color="D6EAF8", end_color="D6EAF8", fill_type="solid")
- female_fill = PatternFill(start_color="FADBD8", end_color="FADBD8", fill_type="solid")
- podium_fill = PatternFill(start_color="D5D8DC", end_color="D5D8DC", fill_type="solid")
- podium_font = Font(bold=True, size=14)
- name_font = Font(size=14)
- # score_font kept for reference but not used separately
- # --- 讲台行 ---
- ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_cols)
- podium_cell = ws.cell(row=1, column=1, value="讲 台")
- podium_cell.font = podium_font
- podium_cell.alignment = center_align
- podium_cell.fill = podium_fill
- for c in range(1, total_cols + 1):
- ws.cell(row=1, column=c).fill = podium_fill
- ws.cell(row=1, column=c).border = thin_border
- ws.row_dimensions[1].height = 35
- # --- 空行 ---
- # (直接将学生放在第3行及以下)
- # --- 学生座位 ---
- group_idx = 0
- for row_num in range(num_rows):
- excel_row = row_num + 3 # row 1 = 讲台, row 2 = blank
- col_offset = 1
- for g in range(num_groups_per_row):
- size = sizes[g]
- if group_idx < len(groups):
- group = groups[group_idx]
- group_idx += 1
- for i in range(size):
- col = col_offset + i
- cell = ws.cell(row=excel_row, column=col)
- if i < len(group):
- student = group[i]
- cell.value = student['name']
- cell.font = name_font
- cell.alignment = center_align
- if is_male(student["gender"]):
- cell.fill = male_fill
- elif is_female(student["gender"]):
- cell.fill = female_fill
- else:
- cell.value = ""
- cell.border = thin_border
- ws.column_dimensions[get_column_letter(col)].width = 13
- else:
- for i in range(size):
- col = col_offset + i
- cell = ws.cell(row=excel_row, column=col)
- cell.border = thin_border
- ws.column_dimensions[get_column_letter(col)].width = 13
- col_offset += size
- # 过道
- if g < num_groups_per_row - 1:
- aisle_col = col_offset
- ws.column_dimensions[get_column_letter(aisle_col)].width = 4
- col_offset += 1
- ws.row_dimensions[excel_row].height = 36
- wb.save(output_path)
- print(f"座位表已保存至:{output_path}")
- def main():
- args = parse_args()
- # 验证样式
- if not args.style.isdigit() or len(args.style) < 2:
- print("错误:样式必须为纯数字字符串,至少2位,如 '2222' 或 '3332'")
- sys.exit(1)
- input_path = Path(args.excel_file)
- if not input_path.exists():
- print(f"错误:文件不存在:{args.excel_file}")
- sys.exit(1)
- if args.count < 1:
- print("错误:生成个数必须大于0")
- sys.exit(1)
- # 确定输出路径模板
- if args.output:
- output_base = args.output
- else:
- output_base = f"{input_path.stem}_{args.class_name}_座位表.xlsx"
- # 读取学生(只读一次)
- print(f"正在读取 {args.excel_file} ...")
- students = read_students(args.excel_file, args.class_name)
- if not students:
- print(f"警告:班级 '{args.class_name}' 中没有找到任何学生,将生成空座位表。")
- else:
- m_count = sum(1 for s in students if is_male(s["gender"]))
- f_count = sum(1 for s in students if is_female(s["gender"]))
- print(f"班级 '{args.class_name}' 共有 {len(students)} 名学生(男:{m_count},女:{f_count})")
- # 构建桌组(只算一次)
- desk_sizes = build_desk_groups(args.style, len(students))
- # 确定基础种子
- base_seed = args.seed if args.seed is not None else random.randint(0, 999999)
- strategy_names = {
- "good-with-good": "好+好",
- "good-with-bad": "好+差",
- "random": "与成绩无关",
- }
- # 生成多个座位表
- for i in range(args.count):
- seed = base_seed + i
- # 输出文件名:多个时加序号后缀
- if args.count > 1:
- p = Path(output_base)
- output_path = str(p.parent / f"{p.stem}_{i + 1}{p.suffix}")
- else:
- output_path = output_base
- groups = allocate_students(students, desk_sizes, args.strategy, args.allow_mixed, seed)
- generate_excel(groups, args.style, output_path, args.class_name)
- # 摘要
- print(f"\n座位表配置:")
- print(f" 样式:{args.style}({len(args.style)}组,每组{'-'.join(args.style)}人)")
- print(f" 策略:{strategy_names[args.strategy]}")
- print(f" 男女生同桌:{'允许' if args.allow_mixed else '不允许'}")
- print(f" 生成个数:{args.count}")
- print(f" 总桌组数:{len(groups)},总排数:{(len(groups) + len(args.style) - 1) // len(args.style)}")
- if __name__ == "__main__":
- main()
|