mirror of
https://github.com/EDeev/tablo.git
synced 2026-10-07 20:49:31 +03:00
Upd Add export / Fix bugs
This commit is contained in:
parent
3946aeb81e
commit
d102136615
11 changed files with 639 additions and 4 deletions
|
|
@ -22,10 +22,11 @@ def create_app():
|
|||
login_manager.login_message = 'Войдите для доступа'
|
||||
login_manager.login_message_category = 'warning'
|
||||
|
||||
from app.routes import auth, schedules, subjects, shares
|
||||
from app.routes import auth, schedules, subjects, shares, export
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(schedules.bp)
|
||||
app.register_blueprint(subjects.bp)
|
||||
app.register_blueprint(shares.bp)
|
||||
app.register_blueprint(export.bp)
|
||||
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -10,5 +10,9 @@ class Config:
|
|||
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True,
|
||||
'pool_recycle': 1800,
|
||||
}
|
||||
MAX_CONTENT_LENGTH = 20 * 1024 * 1024
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
|
|
|||
95
app/routes/export.py
Normal file
95
app/routes/export.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import urllib.parse
|
||||
from flask import Blueprint, Response, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app.models.schedule import Schedule
|
||||
from app.models.share import Share
|
||||
from app import db
|
||||
|
||||
bp = Blueprint('export', __name__)
|
||||
|
||||
|
||||
def _can_view(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
share = Share.query.filter_by(schedule_id=schedule.id).first()
|
||||
return share is not None
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/export/json')
|
||||
@login_required
|
||||
def export_json(schedule_id: int):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_view(schedule):
|
||||
flash('Нет доступа', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
from app.services.export import export_json as svc_json
|
||||
data = svc_json(schedule)
|
||||
filename = _safe_name(schedule.name) + '.json'
|
||||
return Response(
|
||||
data,
|
||||
mimetype='application/json',
|
||||
headers={'Content-Disposition': _cd(filename)},
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/export/csv')
|
||||
@login_required
|
||||
def export_csv(schedule_id: int):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_view(schedule):
|
||||
flash('Нет доступа', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
from app.services.export import export_csv as svc_csv
|
||||
data = svc_csv(schedule)
|
||||
filename = _safe_name(schedule.name) + '.csv'
|
||||
return Response(
|
||||
data,
|
||||
mimetype='text/csv; charset=utf-8-sig',
|
||||
headers={'Content-Disposition': _cd(filename)},
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/export/pdf')
|
||||
@login_required
|
||||
def export_pdf(schedule_id: int):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_view(schedule):
|
||||
flash('Нет доступа', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
from app.services.export import export_pdf as svc_pdf
|
||||
data = svc_pdf(schedule)
|
||||
filename = _safe_name(schedule.name) + '.pdf'
|
||||
return Response(
|
||||
data,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': _cd(filename)},
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/export/png')
|
||||
@login_required
|
||||
def export_png(schedule_id: int):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_view(schedule):
|
||||
flash('Нет доступа', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
from app.services.export import export_png as svc_png
|
||||
data = svc_png(schedule)
|
||||
filename = _safe_name(schedule.name) + '.png'
|
||||
return Response(
|
||||
data,
|
||||
mimetype='image/png',
|
||||
headers={'Content-Disposition': _cd(filename)},
|
||||
)
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
safe = ''.join(c if c.isalnum() or c in ' ._-' else '_' for c in name)
|
||||
return safe.strip() or 'schedule'
|
||||
|
||||
|
||||
def _cd(filename: str) -> str:
|
||||
"""Content-Disposition с поддержкой Unicode (RFC 5987)."""
|
||||
ascii_name = filename.encode('ascii', 'replace').decode('ascii')
|
||||
utf8_name = urllib.parse.quote(filename, safe='')
|
||||
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}"
|
||||
158
app/services/export.py
Normal file
158
app/services/export.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from flask import current_app, render_template
|
||||
|
||||
DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
|
||||
|
||||
|
||||
def _flatten_schedule(schedule_data: list) -> list[dict]:
|
||||
"""Разворачивает вложенную JSON-структуру расписания в плоский список строк."""
|
||||
rows = []
|
||||
for item in schedule_data:
|
||||
subject = item.get('subject', '')
|
||||
for type_name, type_data in item.get('types', {}).items():
|
||||
if isinstance(type_data, dict) and 'dates' in type_data:
|
||||
date_ranges = type_data['dates']
|
||||
else:
|
||||
date_ranges = type_data
|
||||
for period, slots in date_ranges.items():
|
||||
for slot in slots:
|
||||
rows.append({
|
||||
'subject': subject,
|
||||
'type': type_name,
|
||||
'period': period,
|
||||
'day': slot[0] if len(slot) > 0 else '',
|
||||
'time': slot[1] if len(slot) > 1 else '',
|
||||
'classroom': slot[2] if len(slot) > 2 else '',
|
||||
'teacher': slot[3] if len(slot) > 3 else '',
|
||||
})
|
||||
rows.sort(key=lambda r: (
|
||||
r['subject'],
|
||||
r['type'],
|
||||
r['period'],
|
||||
DAYS_ORDER.index(r['day'].lower()) if r['day'].lower() in DAYS_ORDER else 99,
|
||||
r['time'],
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def export_json(schedule) -> bytes:
|
||||
return json.dumps(schedule.data, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
|
||||
|
||||
def export_csv(schedule) -> bytes:
|
||||
rows = _flatten_schedule(schedule.data or [])
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(
|
||||
buf,
|
||||
fieldnames=['subject', 'type', 'period', 'day', 'time', 'classroom', 'teacher'],
|
||||
extrasaction='ignore',
|
||||
)
|
||||
writer.writerow({
|
||||
'subject': 'Предмет',
|
||||
'type': 'Тип занятия',
|
||||
'period': 'Период',
|
||||
'day': 'День',
|
||||
'time': 'Время',
|
||||
'classroom': 'Кабинет',
|
||||
'teacher': 'Преподаватель',
|
||||
})
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue().encode('utf-8-sig') # BOM для корректного открытия в Excel
|
||||
|
||||
|
||||
def _build_week_view_active(schedule_data: list) -> dict:
|
||||
"""Слоты по дням — только из периодов, которые ещё не закончились на сегодня."""
|
||||
from datetime import date
|
||||
from app.services.schedule_helpers import _parse_date_range
|
||||
today = date.today()
|
||||
days = {i: [] for i in range(7)}
|
||||
for item in schedule_data:
|
||||
subject = item.get('subject', '')
|
||||
for type_name, type_data in item.get('types', {}).items():
|
||||
if isinstance(type_data, dict) and 'dates' in type_data:
|
||||
color = type_data.get('color', 'gray')
|
||||
date_ranges = type_data['dates']
|
||||
else:
|
||||
color = 'gray'
|
||||
date_ranges = type_data
|
||||
for period, slots in date_ranges.items():
|
||||
start, end = _parse_date_range(period)
|
||||
# Пропускаем период только если у него есть явная дата конца и она уже прошла
|
||||
if end is not None and end < today:
|
||||
continue
|
||||
for slot in slots:
|
||||
day_name = (slot[0] if slot else '').lower()
|
||||
if day_name not in DAYS_ORDER:
|
||||
continue
|
||||
day_idx = DAYS_ORDER.index(day_name)
|
||||
days[day_idx].append({
|
||||
'subject': subject,
|
||||
'type': type_name,
|
||||
'color': color,
|
||||
'time': slot[1] if len(slot) > 1 else '',
|
||||
'classroom': slot[2] if len(slot) > 2 else '',
|
||||
'teacher': slot[3] if len(slot) > 3 else '',
|
||||
'period': period,
|
||||
})
|
||||
# Убираем дубликаты одного слота (предмет+день+время из нескольких периодов)
|
||||
for i in days:
|
||||
seen = set()
|
||||
unique = []
|
||||
for s in days[i]:
|
||||
key = (s['subject'], s['type'], s['time'])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(s)
|
||||
days[i] = sorted(unique, key=lambda s: s.get('time') or '')
|
||||
return days
|
||||
|
||||
|
||||
def _render_html(schedule, fmt: str) -> str:
|
||||
rows = _flatten_schedule(schedule.data or [])
|
||||
return render_template(
|
||||
'export/schedule_print.html',
|
||||
schedule=schedule,
|
||||
rows=rows,
|
||||
fmt=fmt,
|
||||
)
|
||||
|
||||
|
||||
def _render_html_png(schedule) -> str:
|
||||
week_view = _build_week_view_active(schedule.data or [])
|
||||
days_display = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']
|
||||
return render_template(
|
||||
'export/schedule_png.html',
|
||||
schedule=schedule,
|
||||
week_view=week_view,
|
||||
days_display=days_display,
|
||||
)
|
||||
|
||||
|
||||
def export_pdf(schedule) -> bytes:
|
||||
from playwright.sync_api import sync_playwright
|
||||
html = _render_html(schedule, 'pdf')
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
page.set_content(html, wait_until='load')
|
||||
pdf_bytes = page.pdf(format='A4', landscape=True, print_background=True)
|
||||
browser.close()
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
def export_png(schedule) -> bytes:
|
||||
from playwright.sync_api import sync_playwright
|
||||
html = _render_html_png(schedule)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page(viewport={'width': 1200, 'height': 800})
|
||||
page.set_content(html, wait_until='load')
|
||||
# Подгоняем высоту под реальный контент
|
||||
height = page.evaluate('document.documentElement.scrollHeight')
|
||||
page.set_viewport_size({'width': 1200, 'height': height})
|
||||
png_bytes = page.screenshot(full_page=True)
|
||||
browser.close()
|
||||
return png_bytes
|
||||
|
|
@ -49,9 +49,11 @@
|
|||
gap: 5px;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.t-cp {
|
||||
aspect-ratio: 1;
|
||||
aspect-ratio: unset;
|
||||
min-height: 36px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
|
|
@ -84,7 +86,7 @@
|
|||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-cp-fraction { font-size: 14px; }
|
||||
.t-cps { grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 4px; }
|
||||
.t-cp { font-size: 10px; border-radius: 6px; }
|
||||
.t-cp { font-size: 10px; border-radius: 6px; aspect-ratio: unset; min-height: 32px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4: подписи ── */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -265,9 +265,36 @@ body.edit-mode .t-slot-edit-controls { display: flex; }
|
|||
.t-sched-card-title { font-size: 15px; font-weight: 700; }
|
||||
.t-sched-card-meta { font-size: 11.5px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.t-sched-card-meta span { display: flex; align-items: center; gap: 3px; }
|
||||
.t-sched-card-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.t-sched-card-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; align-items: stretch; }
|
||||
.t-sched-card-actions > form,
|
||||
.t-sched-card-actions > .t-export-dropdown { display: flex; }
|
||||
.t-sched-card-actions > form > .t-btn,
|
||||
.t-sched-card-actions > .t-export-dropdown > .t-btn { align-self: stretch; }
|
||||
.t-sched-card-footer { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; }
|
||||
|
||||
/* Выпадающее меню экспорта */
|
||||
.t-export-dropdown { position: relative; display: inline-block; }
|
||||
.t-export-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 110px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.18);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-export-item {
|
||||
display: block;
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.t-export-item:hover { background: var(--surface); }
|
||||
|
||||
/* Объединение расписаний */
|
||||
.t-merge-panel {
|
||||
background: var(--surface);
|
||||
|
|
|
|||
193
app/templates/export/schedule_png.html
Normal file
193
app/templates/export/schedule_png.html
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ schedule.name }}</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--green: #3D7A5F;
|
||||
--mint: #E8F5EE;
|
||||
--bg: #F9F9F8;
|
||||
--surface: #FFFFFF;
|
||||
--surface2:#F4F4F3;
|
||||
--ink: #1A1A1A;
|
||||
--muted: #6B7280;
|
||||
--border: #E5E7EB;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
padding: 28px 24px;
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
/* ── Заголовок ── */
|
||||
.t-ph { margin-bottom: 22px; }
|
||||
.t-ph-title { font-size: 19px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.t-ph-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
|
||||
/* ── Сетка дней ── */
|
||||
.t-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Карточка дня ── */
|
||||
.t-day-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-day-card-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.t-day-name { font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* ── Слот ── */
|
||||
.t-slot {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.t-slot:last-child { border-bottom: none; }
|
||||
|
||||
.t-slot-time {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
min-width: 66px;
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.t-slot-body { flex: 1; min-width: 0; }
|
||||
.t-slot-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.t-slot-room {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.t-slot-teacher-row {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.t-slot-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Бейджи типов ── */
|
||||
.t-type {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-type-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.t-type-green { background: #dcfce7; color: #166534; }
|
||||
.t-type-yellow { background: #fef9c3; color: #854d0e; }
|
||||
.t-type-purple { background: #ede9fe; color: #5b21b6; }
|
||||
.t-type-orange { background: #ffedd5; color: #9a3412; }
|
||||
.t-type-red { background: #fee2e2; color: #b91c1c; }
|
||||
.t-type-teal { background: #ccfbf1; color: #0f766e; }
|
||||
.t-type-gray { background: #f1f5f9; color: #475569; }
|
||||
|
||||
.t-empty-day {
|
||||
padding: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="t-ph">
|
||||
<div class="t-ph-title">{{ schedule.name }}</div>
|
||||
<div class="t-ph-sub">Расписание · экспорт</div>
|
||||
</div>
|
||||
|
||||
{% set TYPE_SHORT = {
|
||||
'Лекция': 'Лек',
|
||||
'Практика': 'Пр',
|
||||
'Лаб. работа': 'Лаб',
|
||||
'Лабораторная': 'Лаб',
|
||||
'Семинар': 'Сем',
|
||||
'Не указано': ''
|
||||
} %}
|
||||
|
||||
<div class="t-grid">
|
||||
{% for i in range(7) %}
|
||||
{% set slots = week_view[i] %}
|
||||
{% if slots %}
|
||||
<div class="t-day-card">
|
||||
<div class="t-day-card-head">
|
||||
<span class="t-day-name">{{ days_display[i] }}</span>
|
||||
</div>
|
||||
{% for slot in slots %}
|
||||
<div class="t-slot">
|
||||
<div class="t-slot-time">{{ slot.time or '—' }}</div>
|
||||
<div class="t-slot-body">
|
||||
<span class="t-slot-name">{{ slot.subject }}</span>
|
||||
{% if slot.classroom %}
|
||||
<div class="t-slot-room">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ slot.classroom }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if slot.teacher %}
|
||||
<div class="t-slot-teacher-row">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{{ slot.teacher }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="t-slot-right">
|
||||
{% set t_short = TYPE_SHORT.get(slot.type, slot.type[:3] if slot.type and slot.type|length > 3 else slot.type) %}
|
||||
{% if t_short and slot.type != 'Не указано' %}
|
||||
<span class="t-type t-type-{{ slot.color or 'gray' }}">{{ t_short }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
122
app/templates/export/schedule_print.html
Normal file
122
app/templates/export/schedule_print.html
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ schedule.name }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1.2cm 1.5cm;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'DejaVu Sans', Arial, sans-serif;
|
||||
font-size: 10px;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: #0f172a;
|
||||
}
|
||||
.meta {
|
||||
font-size: 9px;
|
||||
color: #64748b;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 5px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 4px 6px;
|
||||
vertical-align: top;
|
||||
line-height: 1.4;
|
||||
}
|
||||
tr:nth-child(even) td { background: #f8fafc; }
|
||||
|
||||
/* Цветовые бейджи типов занятий */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-green { background: #dcfce7; color: #166534; }
|
||||
.badge-yellow { background: #fef9c3; color: #854d0e; }
|
||||
.badge-purple { background: #ede9fe; color: #5b21b6; }
|
||||
.badge-orange { background: #ffedd5; color: #9a3412; }
|
||||
.badge-red { background: #fee2e2; color: #b91c1c; }
|
||||
.badge-teal { background: #ccfbf1; color: #0f766e; }
|
||||
.badge-gray { background: #f1f5f9; color: #475569; }
|
||||
|
||||
.day-cell { white-space: nowrap; }
|
||||
.time-cell { white-space: nowrap; font-family: monospace; font-size: 9.5px; }
|
||||
.muted { color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ schedule.name }}</h1>
|
||||
<div class="meta">Экспортировано {{ schedule.created_at.strftime('%d.%m.%Y') }} · {{ rows | length }} занятий</div>
|
||||
|
||||
{% if rows %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Предмет</th>
|
||||
<th>Тип</th>
|
||||
<th>Период</th>
|
||||
<th>День</th>
|
||||
<th>Время</th>
|
||||
<th>Кабинет</th>
|
||||
<th>Преподаватель</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set color_map = {
|
||||
'Лекция': 'blue',
|
||||
'Практика': 'green',
|
||||
'Лаб. работа': 'purple',
|
||||
'Лабораторная': 'purple',
|
||||
'Семинар': 'teal',
|
||||
'Не указано': 'gray'
|
||||
} %}
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row.subject }}</td>
|
||||
<td>
|
||||
{% set color = color_map.get(row.type, 'gray') %}
|
||||
<span class="badge badge-{{ color }}">{{ row.type }}</span>
|
||||
</td>
|
||||
<td>{{ row.period }}</td>
|
||||
<td class="day-cell">{{ row.day | capitalize }}</td>
|
||||
<td class="time-cell">{{ row.time or '' }}</td>
|
||||
<td>{{ row.classroom or '<span class="muted">—</span>' }}</td>
|
||||
<td>{{ row.teacher or '<span class="muted">—</span>' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Расписание пустое.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -29,6 +29,17 @@
|
|||
</div>
|
||||
<div class="t-sched-card-actions">
|
||||
<a class="t-btn t-btn-primary t-btn-sm" href="{{ url_for('schedules.view', schedule_id=s.id) }}">Открыть</a>
|
||||
<div class="t-export-dropdown">
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-sm t-export-toggle" title="Экспортировать расписание" aria-haspopup="true" aria-expanded="false">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<div class="t-export-menu" hidden>
|
||||
<a class="t-export-item" href="{{ url_for('export.export_json', schedule_id=s.id) }}">JSON</a>
|
||||
<a class="t-export-item" href="{{ url_for('export.export_csv', schedule_id=s.id) }}">CSV</a>
|
||||
<a class="t-export-item" href="{{ url_for('export.export_pdf', schedule_id=s.id) }}">PDF</a>
|
||||
<a class="t-export-item" href="{{ url_for('export.export_png', schedule_id=s.id) }}">PNG</a>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-sm share-btn" data-schedule-id="{{ s.id }}" title="Поделиться расписанием">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</button>
|
||||
|
|
@ -169,6 +180,26 @@ document.querySelectorAll('.copy-btn').forEach(btn => {
|
|||
});
|
||||
});
|
||||
|
||||
/* Дропдаун экспорта */
|
||||
document.querySelectorAll('.t-export-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const menu = btn.nextElementSibling;
|
||||
const open = !menu.hidden;
|
||||
// закрыть все остальные
|
||||
document.querySelectorAll('.t-export-menu').forEach(m => { m.hidden = true; });
|
||||
document.querySelectorAll('.t-export-toggle').forEach(b => b.setAttribute('aria-expanded', 'false'));
|
||||
if (!open) {
|
||||
menu.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
document.querySelectorAll('.t-export-menu').forEach(m => { m.hidden = true; });
|
||||
document.querySelectorAll('.t-export-toggle').forEach(b => b.setAttribute('aria-expanded', 'false'));
|
||||
});
|
||||
|
||||
/* Объединение расписаний */
|
||||
const mergePanel = document.getElementById('merge-panel');
|
||||
const mergeIds = document.getElementById('merge-ids');
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ openai==1.58.1
|
|||
python-dotenv==1.0.1
|
||||
Werkzeug==3.1.3
|
||||
Pillow==11.0.0
|
||||
playwright>=1.40
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue