基本完成课表生成
This commit is contained in:
67
AGENTS.md
Normal file
67
AGENTS.md
Normal file
@@ -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,而不是随意写入当前工作目录。
|
||||
6
main.py
6
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
|
||||
)
|
||||
|
||||
# 返回图片消息
|
||||
|
||||
@@ -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"<b>{name}</b>"
|
||||
if teacher:
|
||||
course_text += f"<br><small>{teacher}</small>"
|
||||
if classroom:
|
||||
course_text += f"<br><small>@{classroom}</small>"
|
||||
if weeks:
|
||||
course_text += f"<br><small>第{format_weeks(weeks)}周</small>"
|
||||
course_text += f"<br><small>第{weeks_str}周</small>"
|
||||
|
||||
for class_idx in classes:
|
||||
if 1 <= class_idx <= 12:
|
||||
grid[class_idx - 1][day_idx].append(course_text)
|
||||
# 填充到 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,11 +260,15 @@ 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:
|
||||
</td>'''
|
||||
|
||||
for day in active_cols:
|
||||
courses = grid[period][day]
|
||||
if courses:
|
||||
cell_content = ''
|
||||
for c in courses:
|
||||
# 检查该单元格是否被上方的 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
|
||||
cell_content += f'<div class="course-item {type_class}"><div class="course-item-inner">{c}</div></div>'
|
||||
else:
|
||||
cell_content = ''
|
||||
|
||||
row_html += f'<td class="day-cell">{cell_content}</td>'
|
||||
# 标记下方被占用的单元格
|
||||
for r in range(period, min(period + rowspan, 12)):
|
||||
occupied[r][day] = True
|
||||
|
||||
row_html += f'<td class="day-cell" rowspan="{rowspan}"><div class="course-item {type_class}"><div class="course-item-inner">{course_text}</div></div></td>'
|
||||
else:
|
||||
row_html += f'<td class="day-cell"></td>'
|
||||
|
||||
row_html += '</tr>'
|
||||
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"<div class='info-line'>👤 {teacher}</div>")
|
||||
@@ -310,26 +349,29 @@ class ScheduleRenderer:
|
||||
if weeks:
|
||||
info_lines.append(f"<div class='info-line'>📅 第{format_weeks(weeks)}周</div>")
|
||||
|
||||
type_class = type_classes[course_type_counter % len(type_classes)]
|
||||
course_type_counter += 1
|
||||
|
||||
card_html = f'''
|
||||
<div class="course-card {type_class}">
|
||||
<div class="time-badge">
|
||||
<div class="period-num">{first_period}</div>
|
||||
row_html = f'''
|
||||
<tr style="height: {row_height}px;">
|
||||
<td class="time-cell">
|
||||
<div class="time-inner">
|
||||
<div class="period-num">{period_display}</div>
|
||||
<div class="time-range">{start_time}-{end_time}</div>
|
||||
</div>
|
||||
<div class="course-content">
|
||||
</td>
|
||||
<td class="course-cell {type_class}">
|
||||
<div class="course-inner">
|
||||
<div class="course-name">{name}</div>
|
||||
{''.join(info_lines)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
'''
|
||||
cards_html.append(card_html)
|
||||
rows_html.append(row_html)
|
||||
|
||||
# 如果没有课程
|
||||
if not cards_html:
|
||||
cards_html.append('<div class="empty-message">今日暂无课程安排 🎉</div>')
|
||||
if not rows_html:
|
||||
table_body = '<tr><td colspan="2" class="empty-cell">今日暂无课程安排 🎉</td></tr>'
|
||||
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:
|
||||
<h1>{title}</h1>
|
||||
<div class="subtitle">{subtitle}</div>
|
||||
</div>
|
||||
<div class="cards-wrapper">
|
||||
{''.join(cards_html)}
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{table_body}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">Powered by <芒果酸> suan.mangofanfan.cn</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user