diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a3216e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# AGENTS.md + +本文档面向 AI 编程助手,介绍本项目的结构、技术栈与开发约定。 + +## 项目概述 + +本项目是一个 [AstrBot](https://github.com/AstrBotDevs/AstrBot) 插件,目录名为 `astrbot_plugin_njupt_suan`,但当前代码是基于 AstrBot 官方插件模板的初始状态。插件仅实现了一个简单的 `helloworld` 指令,用于演示 AstrBot 插件的基本写法。 + +- **技术栈**:Python 3(异步 asyncio) +- **运行方式**:不能独立运行,必须由 AstrBot 主程序动态加载 +- **项目规模**:极简单文件插件,目前仅包含一个入口模块 + +## 项目结构 + +``` +. +├── main.py # 插件主代码,注册指令与事件处理逻辑 +├── metadata.yaml # AstrBot 插件元数据(名称、版本、作者等) +├── README.md # 面向用户的项目说明 +├── LICENSE # 许可证文件 +├── .gitignore # Python 通用 gitignore +└── AGENTS.md # 本文档 +``` + +> 注意:项目中**不存在** `pyproject.toml`、`setup.py`、`requirements.txt`、`package.json` 等常规构建配置文件,因为 AstrBot 插件是直接在宿主环境中运行的动态模块。 + +## 核心文件说明 + +### `main.py` +- 定义插件类 `MyPlugin`,继承自 `astrbot.api.star.Star` +- 使用 `@register(...)` 装饰器向 AstrBot 注册插件 +- 使用 `@filter.command("helloworld")` 注册聊天指令 `/helloworld` +- 实现了可选的生命周期方法 `initialize()`(初始化)和 `terminate()`(卸载) + +### `metadata.yaml` +- AstrBot 识别插件的核心配置文件 +- 关键字段:`name`(唯一标识名)、`version`、`author`、`desc`、`repo` +- 当前内容仍为模板默认值(`helloworld`),后续开发需根据实际功能更新 + +## 开发与修改约定 + +1. **单文件扩展**:当前所有逻辑集中在 `main.py`。若功能变复杂,可在同目录下新增 `.py` 模块并在 `main.py` 中导入。 +2. **命名规范**: + - 插件唯一名(`metadata.yaml` 中的 `name`)建议以 `astrbot_plugin_` 开头 + - 类名使用大驼峰(如 `MyPlugin`) + - 指令名使用小写英文+下划线 +3. **注释语言**:代码内注释以中文为主,README 为中英混合。新增注释建议保持中文。 +4. **依赖管理**:如需引入第三方 Python 包,请在 `metadata.yaml` 同级目录下创建 `requirements.txt`,AstrBot 会在安装插件时尝试自动安装。但目前项目中尚未创建该文件。 + +## 构建与测试 + +- **无独立构建步骤**:插件无需编译或打包成 wheel。 +- **测试方式**:需在本地部署 AstrBot 主程序,将本目录放入 AstrBot 的 `plugins/` 文件夹(或通过 AstrBot 插件市场/控制台安装),然后启动 AstrBot 进行联调。 +- **目前无单元测试**:目录内没有 `tests/` 文件夹或测试框架配置。 + +## 部署流程 + +1. 修改 `metadata.yaml` 中的 `name`、`display_name`、`desc`、`version`、`author`、`repo` 为实际信息。 +2. 修改 `main.py` 中的 `@register(...)` 参数与 `metadata.yaml` 保持一致。 +3. 如需额外依赖,添加 `requirements.txt`。 +4. 将代码推送到远程仓库后,可在 AstrBot 插件市场提交或通过 Git 链接直接安装。 + +## 安全提示 + +- 插件运行在 AstrBot 主程序的 Python 进程中,拥有与宿主相同的权限。 +- 避免在插件中执行不可信的外部命令或随意 `eval` 用户输入。 +- 若需存储持久化数据,建议使用 AstrBot 提供的配置/数据目录 API,而不是随意写入当前工作目录。 diff --git a/main.py b/main.py index 9ac3429..988e545 100644 --- a/main.py +++ b/main.py @@ -8,8 +8,8 @@ from astrbot.api import logger from astrbot.core import AstrBotConfig from astrbot.core.message.message_event_result import MessageEventResult -from schedule_utils import convert_tuple_schedule_to_dict -from schedule_renderer import ScheduleRenderer +from .schedule_utils import convert_tuple_schedule_to_dict +from .schedule_renderer import ScheduleRenderer class SuanPlugin(Star): @@ -96,7 +96,7 @@ class SuanPlugin(Star): image_data = await self.renderer.render_to_png( html_content, debug_path='/home/fanfan/test_daily.html', - width=880 + width=680 ) # 返回图片消息 diff --git a/schedule_renderer.py b/schedule_renderer.py index 0235203..a3961bc 100644 --- a/schedule_renderer.py +++ b/schedule_renderer.py @@ -5,7 +5,7 @@ import tempfile import subprocess from astrbot.api import logger -from schedule_utils import get_period_time, format_weeks +from .schedule_utils import get_period_time, format_weeks class ScheduleRenderer: @@ -15,11 +15,14 @@ class ScheduleRenderer: self.config = config or {} def generate_html(self, schedule: list[dict], title: str) -> str: - """生成课程表 HTML""" + """生成课程表 HTML(支持 rowspan 合并连续节次)""" weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] weekday_short = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - grid = [[[] for _ in range(7)] for _ in range(12)] + + # grid 存储 (course_key, html_content) 或 None(被合并的单元格) + # course_key 用于识别同一门课:name|teacher|classroom|weeks_str + grid = [[None for _ in range(7)] for _ in range(12)] for course in schedule: name = course.get('name', '未知课程') @@ -27,34 +30,43 @@ class ScheduleRenderer: classroom = course.get('classroom') or '' weeks = course.get('weeks', []) day = course.get('day', 1) - classes = course.get('classes', []) + classes = sorted(course.get('classes', [])) day_idx = day - 1 - if day_idx < 0 or day_idx > 6: + if day_idx < 0 or day_idx > 6 or not classes: continue - + + # 生成课程 key 和 HTML 内容 + weeks_str = format_weeks(weeks) + course_key = f"{name}|{teacher}|{classroom}|{weeks_str}" + course_text = f"{name}" if teacher: course_text += f"
{teacher}" if classroom: course_text += f"
@{classroom}" if weeks: - course_text += f"
第{format_weeks(weeks)}周" - - for class_idx in classes: - if 1 <= class_idx <= 12: - grid[class_idx - 1][day_idx].append(course_text) + course_text += f"
第{weeks_str}周" + + # 填充到 grid(只填第一个节次) + first_period = min(classes) + if 1 <= first_period <= 12: + grid[first_period - 1][day_idx] = (course_key, course_text, len(classes)) # 判断是否隐藏空行空列 hide_void = self.config.get('hide_void_columns_rows', True) - # 计算有课的行和列 + # 计算有课的行和列(考虑 rowspan) active_rows = set() active_cols = set() for row_idx in range(12): for col_idx in range(7): if grid[row_idx][col_idx]: active_rows.add(row_idx) + # rowspan 占据的行也算有课 + rowspan = grid[row_idx][col_idx][2] + for r in range(row_idx, min(row_idx + rowspan, 12)): + active_rows.add(r) active_cols.add(col_idx) # 如果没有课,默认显示全部 @@ -149,6 +161,8 @@ class ScheduleRenderer: padding: 15px 10px; border-radius: 12px; text-align: center; + height: 190px; + box-sizing: border-box; }} td.time-col .period-num {{ @@ -168,21 +182,23 @@ class ScheduleRenderer: border-radius: 12px; padding: 10px; vertical-align: top; - height: 110px; + height: 190px; + box-sizing: border-box; }} .course-item {{ background: #00838f; border-radius: 10px; padding: 6px; - margin: 2px 0; + margin: 0; color: #e0f7fa; line-height: 1.15; font-size: 17px; - height: 180px; + height: 100%; text-align: center; display: table; width: 100%; + box-sizing: border-box; }} .course-item-inner {{ @@ -244,10 +260,14 @@ class ScheduleRenderer: return html def _generate_table_body(self, grid: list, active_rows: list, active_cols: list) -> str: - """生成表格主体 HTML""" + """生成表格主体 HTML(支持 rowspan)""" rows = [] course_type_counter = 0 type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e'] + + # 跟踪哪些单元格被 rowspan 占用了 + # occupied[row][col] = True 表示该单元格被上方的 rowspan 占用 + occupied = [[False for _ in range(7)] for _ in range(12)] for period in active_rows: start_time = get_period_time(period + 1) @@ -259,17 +279,23 @@ class ScheduleRenderer: ''' for day in active_cols: - courses = grid[period][day] - if courses: - cell_content = '' - for c in courses: - type_class = type_classes[course_type_counter % len(type_classes)] - course_type_counter += 1 - cell_content += f'
{c}
' + # 检查该单元格是否被上方的 rowspan 占用 + if occupied[period][day]: + continue + + cell_data = grid[period][day] + if cell_data: + course_key, course_text, rowspan = cell_data + type_class = type_classes[course_type_counter % len(type_classes)] + course_type_counter += 1 + + # 标记下方被占用的单元格 + for r in range(period, min(period + rowspan, 12)): + occupied[r][day] = True + + row_html += f'
{course_text}
' else: - cell_content = '' - - row_html += f'{cell_content}' + row_html += f'' row_html += '' rows.append(row_html) @@ -277,31 +303,44 @@ class ScheduleRenderer: return '\n'.join(rows) def generate_daily_html(self, schedule: list[dict], title: str, weekday_name: str = "") -> str: - """生成单日课程表 HTML - 垂直卡片布局""" + """生成单日课程表 HTML - 表格布局(时间列高度根据节数调整)""" # 按节次排序课程 sorted_courses = sorted(schedule, key=lambda x: min(x.get('classes', [99])) if x.get('classes') else 99) type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e'] - cards_html = [] course_type_counter = 0 + rows_html = [] for course in sorted_courses: name = course.get('name', '未知课程') teacher = course.get('teacher') or '' classroom = course.get('classroom') or '' weeks = course.get('weeks', []) - classes = course.get('classes', []) + classes = sorted(course.get('classes', [])) if not classes: continue - # 获取第一节课的节次和时间 first_period = min(classes) + last_period = max(classes) + period_count = len(classes) start_time = get_period_time(first_period) - end_time = get_period_time(max(classes)) + end_time = get_period_time(last_period) - # 构建课程信息文本 + # 节次显示 + if first_period == last_period: + period_display = str(first_period) + else: + period_display = f"{first_period}-{last_period}" + + # 计算行高:基础120px,每多一节+100px + row_height = 120 + (period_count - 1) * 100 + + type_class = type_classes[course_type_counter % len(type_classes)] + course_type_counter += 1 + + # 构建课程信息 info_lines = [] if teacher: info_lines.append(f"
👤 {teacher}
") @@ -310,26 +349,29 @@ class ScheduleRenderer: if weeks: info_lines.append(f"
📅 第{format_weeks(weeks)}周
") - type_class = type_classes[course_type_counter % len(type_classes)] - course_type_counter += 1 - - card_html = f''' -
-
-
{first_period}
-
{start_time}-{end_time}
-
-
-
{name}
- {''.join(info_lines)} -
-
+ row_html = f''' + + +
+
{period_display}
+
{start_time}-{end_time}
+
+ + +
+
{name}
+ {''.join(info_lines)} +
+ + ''' - cards_html.append(card_html) + rows_html.append(row_html) # 如果没有课程 - if not cards_html: - cards_html.append('
今日暂无课程安排 🎉
') + if not rows_html: + table_body = '今日暂无课程安排 🎉' + else: + table_body = '\n'.join(rows_html) subtitle = f"{weekday_name} 课程安排" if weekday_name else "课程安排" @@ -352,7 +394,7 @@ class ScheduleRenderer: }} .container {{ - width: 800px; + width: 600px; background: #1a1a2e; border-radius: 20px; padding: 40px; @@ -375,51 +417,74 @@ class ScheduleRenderer: margin-top: 8px; }} - .cards-wrapper {{ - display: flex; - flex-direction: column; - gap: 16px; + table {{ + width: 100%; + border-collapse: separate; + border-spacing: 0 12px; }} - .course-card {{ - display: flex; - align-items: center; - background: #00838f; - border-radius: 16px; - padding: 20px; - min-height: 120px; + tr {{ + height: 120px; }} - .course-card.type-a {{ background: linear-gradient(135deg, #0277bd 0%, #01579b 100%); }} - .course-card.type-b {{ background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%); }} - .course-card.type-c {{ background: linear-gradient(135deg, #7b1fa2 0%, #4a148c 100%); }} - .course-card.type-d {{ background: linear-gradient(135deg, #c6278e 0%, #880e4f 100%); }} - .course-card.type-e {{ background: linear-gradient(135deg, #ef6c00 0%, #e65100 100%); }} + .time-cell {{ + width: 100px; + padding: 0; + vertical-align: middle; + }} - .time-badge {{ - flex-shrink: 0; - width: 80px; + .time-inner {{ + background: #00796b; + border-radius: 12px; + padding: 15px 10px; text-align: center; - padding-right: 20px; - border-right: 2px solid rgba(255, 255, 255, 0.3); - margin-right: 20px; + height: 100%; + display: table; + width: 100%; }} - .time-badge .period-num {{ + .time-inner > div {{ + display: table-cell; + vertical-align: middle; + }} + + .time-cell .period-num {{ font-size: 32px; font-weight: bold; color: #ffffff; - line-height: 1; + display: block; }} - .time-badge .time-range {{ - font-size: 12px; - color: rgba(255, 255, 255, 0.8); + .time-cell .time-range {{ + font-size: 13px; + color: #b2ebf2; margin-top: 4px; + display: block; }} - .course-content {{ - flex: 1; + .course-cell {{ + padding: 0 0 0 12px; + vertical-align: middle; + }} + + .course-inner {{ + background: #00838f; + border-radius: 12px; + padding: 20px; + height: 100%; + display: table; + width: 100%; + }} + + .course-cell.type-a .course-inner {{ background: linear-gradient(135deg, #0277bd 0%, #01579b 100%); }} + .course-cell.type-b .course-inner {{ background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%); }} + .course-cell.type-c .course-inner {{ background: linear-gradient(135deg, #7b1fa2 0%, #4a148c 100%); }} + .course-cell.type-d .course-inner {{ background: linear-gradient(135deg, #c6278e 0%, #880e4f 100%); }} + .course-cell.type-e .course-inner {{ background: linear-gradient(135deg, #ef6c00 0%, #e65100 100%); }} + + .course-inner > div {{ + display: table-cell; + vertical-align: middle; }} .course-name {{ @@ -435,7 +500,7 @@ class ScheduleRenderer: margin-top: 4px; }} - .empty-message {{ + .empty-cell {{ text-align: center; font-size: 20px; color: #80deea; @@ -458,9 +523,11 @@ class ScheduleRenderer:

{title}

{subtitle}
-
- {''.join(cards_html)} -
+ + + {table_body} + +