Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Сборка руководства пользователя (docs/user-guide.pdf)
PDF собирается автоматически: скриншоты снимаются на тестовых данных, элементы размечаются по реальным координатам
(рамка, номер, стрелка), текст — в `build_pdf.py`. После изменения интерфейса пересоберите руководство.
Нужно: Python-окружение проекта + `pip install pillow websockets pymupdf` (только для сборки руководства), Edge или Chrome (Windows),
для скриншотов Android — эмулятор с установленным приложением.
```
# 1. чистая БД с тестовыми данными и сервер на :8000
python tools/guide/seed_server.py <каталог>
DATABASE_URL=sqlite:///<каталог>/guide.db DATA_DIR=<каталог> uvicorn app.main:app --port 8000 # положите APK в <каталог>/apk
# 2. веб-интерфейс и мобильная версия (Edge headless через DevTools Protocol)
python tools/guide/shots_web.py
# 3. приложение Android (эмулятор, adb reverse tcp:8000 tcp:8000)
python tools/guide/shots_android.py
# 4. PDF
python tools/guide/build_pdf.py
```
Какие экраны снимать и что на них отмечать — в `spec.py` (подписи меток сразу попадают в легенду под рисунком).
Сервер можно поднять на другом порту: `GUIDE_BASE=http://localhost:8010` для `shots_web.py`, `GUIDE_PORT=8010` для
`shots_android.py` (в приложении адрес остаётся localhost:8000, adb reverse перенаправит). Скриншоты Android снимаются
отладочной сборкой (`assembleDebug`): релиз не пускает http.
Скриншоты, зависящие от «свежих» нарядов, снимаются на только что созданной БД: после прогона состояние нарядов меняется,
поэтому для повторного полного прогона пересоздайте БД (шаг 1).
+102
View File
@@ -0,0 +1,102 @@
"""Разметка скриншотов: рамка вокруг элемента, номерной значок и стрелка к нему."""
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
RED = (225, 29, 72)
FONT = Path(__file__).resolve().parents[2] / "app" / "fonts" / "DejaVuSans-Bold.ttf"
def _arrow(d: ImageDraw.ImageDraw, p0, p1, w, head):
d.line([p0, p1], fill=RED, width=w)
ang = math.atan2(p1[1] - p0[1], p1[0] - p0[0])
tip = p1
a1, a2 = ang + math.radians(150), ang - math.radians(150)
d.polygon([tip, (tip[0] + head * math.cos(a1), tip[1] + head * math.sin(a1)),
(tip[0] + head * math.cos(a2), tip[1] + head * math.sin(a2))], fill=RED)
def annotate(img: Image.Image, marks, scale: float = 1.0) -> Image.Image:
"""marks: [(номер, (x, y, w, h) в пикселях изображения, направление up|down|left|right|auto)]"""
img = img.convert("RGBA")
over = Image.new("RGBA", img.size, (0, 0, 0, 0))
d = ImageDraw.Draw(over)
W, H = img.size
S = max(scale, 1.0)
font = ImageFont.truetype(str(FONT), int(17 * S))
pad, r, gap, lw = int(5 * S), int(15 * S), int(44 * S), max(2, int(3 * S))
for n, (x, y, w, h), direction in marks:
box = (x - pad, y - pad, x + w + pad, y + h + pad)
d.rounded_rectangle(box, radius=int(8 * S), fill=RED + (28,), outline=RED + (255,), width=lw)
cx, cy = x + w / 2, y + h / 2
if direction == "auto": # где больше свободного места
room = {"up": box[1], "down": H - box[3], "left": box[0], "right": W - box[2]}
direction = "up" if room["up"] > 70 * S and room["up"] >= room["left"] * 0.6 else max(room, key=room.get)
if direction == "up":
bc, a0, a1 = (cx, box[1] - gap), (cx, box[1] - gap + r), (cx, box[1])
elif direction == "down":
bc, a0, a1 = (cx, box[3] + gap), (cx, box[3] + gap - r), (cx, box[3])
elif direction == "left":
bc, a0, a1 = (box[0] - gap, cy), (box[0] - gap + r, cy), (box[0], cy)
else:
bc, a0, a1 = (box[2] + gap, cy), (box[2] + gap - r, cy), (box[2], cy)
# значок не должен выходить за край кадра
nx = min(max(bc[0], r + 4), W - r - 4)
ny = min(max(bc[1], r + 4), H - r - 4)
if direction in ("up", "down"):
a0, bc = (nx, a0[1]), (nx, bc[1])
a1 = (min(max(nx, box[0] + 6), box[2] - 6), a1[1])
else:
a0, bc = (a0[0], ny), (bc[0], ny)
a1 = (a1[0], min(max(ny, box[1] + 6), box[3] - 6))
_arrow(d, a0, a1, lw, int(11 * S))
bx, by = bc
d.ellipse((bx - r - 2, by - r - 2, bx + r + 2, by + r + 2), fill=(255, 255, 255, 255))
d.ellipse((bx - r, by - r, bx + r, by + r), fill=RED + (255,))
tw = d.textlength(str(n), font=font)
d.text((bx - tw / 2, by - font.size * 0.62), str(n), font=font, fill=(255, 255, 255, 255))
return Image.alpha_composite(img, over).convert("RGB")
def annotate_side(img: Image.Image, marks, margin: int, scale: float = 1.0, bg=(238, 241, 246)) -> Image.Image:
"""Вариант для узких экранов: скриншот на светлом фоне с полями, номера стоят в полях и стрелками указывают внутрь."""
img = img.convert("RGB")
W, H = img.size
S = max(scale, 1.0)
canvas = Image.new("RGB", (W + 2 * margin, H), bg)
canvas.paste(img, (margin, 0))
over = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
d = ImageDraw.Draw(over)
d.rectangle((margin - 1, 0, margin + W, H - 1), outline=(190, 196, 208, 255), width=max(2, int(2 * S)))
font = ImageFont.truetype(str(FONT), int(17 * S))
pad, r, lw = int(4 * S), int(15 * S), max(2, int(3 * S))
last = {"left": -10**6, "right": -10**6}
items = []
for idx, (n, (x, y, w, h), direction) in enumerate(marks):
x += margin
cx = x + w / 2
if direction in ("left", "right"):
side = direction
elif abs(w) > W * 0.6:
side = "left" if idx % 2 == 0 else "right"
else:
side = "left" if cx < margin + W / 2 else "right"
items.append((y + h / 2, idx, n, (x, y, w, h), side))
for cy, idx, n, (x, y, w, h), side in sorted(items):
box = (x - pad, y - pad, x + w + pad, y + h + pad)
d.rounded_rectangle(box, radius=int(8 * S), fill=RED + (28,), outline=RED + (255,), width=lw)
by = min(max(cy, r + 4), H - r - 4)
by = max(by, last[side] + 2 * r + int(10 * S))
last[side] = by
bx = margin / 2 if side == "left" else margin + W + margin / 2
a_end_y = min(max(by, box[1] + 6), box[3] - 6)
if side == "left":
_arrow(d, (bx + r, by), (box[0], a_end_y), lw, int(11 * S))
else:
_arrow(d, (bx - r, by), (box[2], a_end_y), lw, int(11 * S))
d.ellipse((bx - r - 2, by - r - 2, bx + r + 2, by + r + 2), fill=(255, 255, 255, 255))
d.ellipse((bx - r, by - r, bx + r, by + r), fill=RED + (255,))
tw = d.textlength(str(n), font=font)
d.text((bx - tw / 2, by - font.size * 0.62), str(n), font=font, fill=(255, 255, 255, 255))
return Image.alpha_composite(canvas.convert("RGBA"), over).convert("RGB")
File diff suppressed because it is too large Load Diff
+252
View File
@@ -0,0 +1,252 @@
"""Собирает docs/presentation/presentation.pptx (редактируемые элементы, 16:9) — копия вёрстки slides.html.
Запуск: .venv\\Scripts\\python tools/guide/build_pptx.py (нужен python-pptx)
"""
from pathlib import Path
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Emu, Pt
ROOT = Path(__file__).resolve().parents[2]
IMG = ROOT / "docs" / "guide" / "img"
OUT = ROOT / "docs" / "presentation" / "presentation.pptx"
PX = 9525
FONT = "Segoe UI"
TEXT, MUTED, BLUE = RGBColor(0x1D, 0x23, 0x30), RGBColor(0x5B, 0x64, 0x75), RGBColor(0x25, 0x63, 0xEB)
WHITE, PALE = RGBColor(255, 255, 255), RGBColor(0xDB, 0xE6, 0xFF)
def px(v):
return Emu(int(v * PX))
def pt(px_size):
return Pt(px_size * 0.75)
prs = Presentation()
prs.slide_width, prs.slide_height = px(1280), px(720)
BLANK = prs.slide_layouts[6]
def bg(slide, dark):
f = slide.background.fill
f.solid()
f.fore_color.rgb = RGBColor(0x1E, 0x40, 0xAF) if dark else RGBColor(0xF5, 0xF7, 0xFB)
if dark: # лёгкий градиент как в HTML
f.gradient()
f.gradient_angle = 315
f.gradient_stops[0].color.rgb = RGBColor(0x1E, 0x3A, 0x8A)
f.gradient_stops[1].color.rgb = RGBColor(0x25, 0x63, 0xEB)
def text(slide, x, y, w, h, runs, size=20, color=TEXT, bold=False, spacing=None, anchor=MSO_ANCHOR.TOP):
tb = slide.shapes.add_textbox(px(x), px(y), px(w), px(h))
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
tf.vertical_anchor = anchor
lines = runs if isinstance(runs, list) else [runs]
for i, ln in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
if spacing:
p.line_spacing = spacing
r = p.add_run()
r.text = ln
r.font.name, r.font.size, r.font.bold = FONT, pt(size), bold
r.font.color.rgb = color
return tb
def rect(slide, x, y, w, h, fill, radius=None, line=None, alpha=None):
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE,
px(x), px(y), px(w), px(h))
if radius:
s.adjustments[0] = min(0.5, radius / min(w, h))
s.fill.solid()
s.fill.fore_color.rgb = fill
if alpha is not None:
from lxml import etree
clr = s.fill._xPr.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr")
a = etree.SubElement(clr, "{http://schemas.openxmlformats.org/drawingml/2006/main}alpha")
a.set("val", str(int(alpha * 100000)))
if line:
s.line.color.rgb = line
s.line.width = Pt(0.75)
else:
s.line.fill.background()
s.shadow.inherit = False
return s
def shot(slide, name, x, y, w, h):
"""Скриншот по ширине блока, лишнее снизу обрезается (как overflow:hidden в HTML)."""
path = IMG / name
iw, ih = Image.open(path).size
full_h = w * ih / iw
rect(slide, x - 1, y - 1, w + 2, min(h, full_h) + 2, RGBColor(0xD5, 0xDB, 0xE8), radius=12)
pic = slide.shapes.add_picture(str(path), px(x), px(y), px(w), px(min(h, full_h)))
if full_h > h:
pic.crop_bottom = 1 - h / full_h
return pic
def bullets(slide, x, y, w, items, size=21, small=17):
cpl = max(10, int(w - 30) / (small * 0.52)) # символов в строке (оценка)
for head, sub in items:
hl = 1 if len(head) < (w - 30) / (size * 0.55) else 2
sl = -(-len(sub) // int(cpl))
rect(slide, x, y + 8, 14, 14, BLUE, radius=4)
text(slide, x + 30, y, w - 30, hl * 28, head, size, TEXT, True)
text(slide, x + 30, y + hl * 28, w - 30, sl * 23, sub, small, MUTED)
y += hl * 28 + sl * 23 + 16
return y
def title(slide, h, sub, y=48):
text(slide, 64, y, 1152, 50, h, 38, TEXT, True)
text(slide, 64, y + 62, 1152, 30, sub, 20, MUTED)
def cards(slide, y, items, h=150):
w = (1152 - 40) / 3
for i, (head, body) in enumerate(items):
x = 64 + i * (w + 20)
rect(slide, x, y, w, h, WHITE, radius=16, alpha=0.14)
text(slide, x + 22, y + 22, w - 44, 60, head, 24, WHITE, True)
text(slide, x + 22, y + 22 + (60 if len(head) > 20 else 36), w - 44, h - 60, body, 17, PALE)
# 1. Титульный
s = prs.slides.add_slide(BLANK); bg(s, True)
text(s, 64, 88, 900, 28, "ДЛЯ РЕМОНТНЫХ СЛУЖБ", 20, PALE)
text(s, 64, 130, 1100, 130, ["Портал", "технической службы"], 54, WHITE, True, spacing=1.05)
text(s, 64, 290, 1100, 36, "Задачи, наряды и чек-листы, отказы, карты завода и кранов, инструмент и люди — в одной системе", 24, PALE)
cards(s, 372, [
("Отчётность", "Кто, на какой машине, какой чек-лист и когда выполнил — за любой период, в PDF и Excel"),
("Гибкая настройка", "Любые графики смен, чек-листы с разными типами ответов, автозапуск нарядов по расписанию"),
("Приложение", "Android и мобильная версия в браузере. Работает без связи, данные уходят на сервер сами")], 160)
# Вкладка «Задачи»
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Вкладка «Задачи»: всё в одном месте', 'Сотруднику не нужно ходить по разделам — главное на одном экране')
shot(s, 'd_work.png', 64, 142, 680, 500)
bullets(s, 780, 142, 436, [('Наряды', 'Мои, на проверку, просроченные — со статусом и поиском'), ('Задачи и поручения', 'Мои, поставленные мной, на сегодня или все дни'), ('Отказы и неисправности', 'Два блока, списком или на карте завода и кранов'), ('Шапка быстрых действий', 'Задача, авария, неисправность, заявка — в одно касание')], size=20, small=16)
# 2. Отчётность
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, "Отчётность: ответы за минуты, а не за часы", "«Выполнение работ» и «Выполненные наряды» — для руководителя, инженера и проверок")
bullets(s, 64, 142, 470, [
("% выполнения по каждой машине", "Суточные осмотры, чек-листы, ТО и задания; отклонения и комментарии"),
("Машина · чек-лист · исполнитель · должность · время", "Каждая выполненная проверка — одной строкой"),
("Обязательный период с точностью до минуты", "Быстрые кнопки: сегодня, вчера, 7 и 30 дней, месяц"),
("Отклонения от нормы видны сразу", "Колонка «Откл.» — число пунктов вне допуска"),
("Экспорт в PDF и CSV (Excel)", "Готовый документ с кириллицей одним нажатием")])
shot(s, "d_exec_report.png", 578, 142, 638, 490)
# Отчёт по сотрудникам
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Отчёт по сотрудникам', 'Количество и качество выполненных задач за период')
bullets(s, 64, 142, 436, [('Список и календарь', 'Сколько задач выполнено по дням и суммарная трудоёмкость'), ('Качество, а не только количество', 'В срок, просрочки, возвраты с проверки, отклонения от норм'), ('Трудоёмкость', 'Норма часов в задаче или фактическое время работы'), ('Фильтры и выгрузка', 'Период, отдел, должность, сотрудник; PDF и CSV')], size=20, small=16)
shot(s, 'd_people_cal.png', 536, 142, 680, 500)
# Анализ надёжности
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Анализ надёжности', 'Наработка на отказ, восстановление, готовность и прогноз — по реальным отказам и счётчикам')
shot(s, 'd_reliability.png', 64, 142, 680, 500)
bullets(s, 780, 142, 436, [('Показатели по машинам и парку', 'θ, λ, время восстановления, коэффициент готовности, простой'), ('Честно при малом числе отказов', 'Консервативная граница с доверием 90 % — даже без отказов'), ('Причины и признаки', 'Что ломается, почему и какие отказы можно было предотвратить'), ('Дрейф параметров и CSV', 'Прогноз выхода за норму по чек-листам; выгрузки для своих расчётов')], size=20, small=16)
# 3. Гибкая настройка
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, "Гибкая настройка под ваше производство", "Всё настраивается администратором в интерфейсе, без программиста")
shot(s, "d_schedule.png", 64, 142, 612, 520)
y = bullets(s, 712, 142, 504, [
("Конструктор графиков смен", "Дни, ночи, отсыпной, сутки через трое, любой цикл — с календарём-предпросмотром"),
("Чек-листы любой структуры", "Ответ: статус, список, число с границами нормы, текст; фото — обязательно или по желанию"),
("Наряды вручную и автоматически", "Шаблон + периодичность: ежедневно, по дням недели, раз в месяц, каждые N дней"),
("Исполнитель — человек или должность на смене", "Наряд видит тот, кто сейчас работает"),
("Роли и проверка", "Техник, инженер, модератор, администратор; приёмка или возврат на доработку")], size=20, small=16)
x = 712
for t in ("Оборудование и места", "Каталог фото", "Комментарии", "Уведомления"):
w = 22 + len(t) * 8.4
if x + w > 1216:
x, y = 712, y + 36
r = rect(s, x, y, w, 28, RGBColor(0xE6, 0xED, 0xFF), radius=14)
tf = r.text_frame
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
run = p.add_run(); run.text = t
run.font.name, run.font.size, run.font.color.rgb = FONT, pt(15), RGBColor(0x1D, 0x4E, 0xD8)
x += w + 8
# 4. Приложение
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, "Приложение всегда с техником", "Нативное Android-приложение и мобильная версия в браузере (в том числе для iPhone)")
shot(s, "a_menu.png", 64, 142, 230, 490)
shot(s, "a_task.png", 330, 142, 230, 490)
bullets(s, 600, 142, 616, [
("Полностью без связи", "Подвал, цех, подстанция: ответы, фото и комментарии копятся и уходят на сервер сами"),
("Всё для работы в поле", "Крупные кнопки, контроль нормы, фото с камеры, «Взять в работу», «Сдать наряд»"),
("Боковое меню", "Наряды, задания, отказы, инструмент и заявки — без лишних панелей"),
("Установка за минуту", "APK скачивается прямо с экрана входа; Google Play не нужен"),
("iPhone и любой браузер", "Мобильная версия /m ставится на экран «Домой» и тоже работает офлайн")])
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Карта завода: состояние машин с одного взгляда', 'План предприятия с иконками оборудования, обновляется сам')
shot(s, 'd_map.png', 64, 142, 680, 500)
bullets(s, 780, 142, 436, [('«!» — авария', 'Сотрудник зафиксировал отказ — значок появляется сразу'), ('Жёлтое свечение — пора на ТО', 'Узел машины приближается к пределу по наработке или календарю'), ('Настраивает администратор', 'Загружает план и расставляет иконки перетаскиванием'), ('Карта складов', 'Зоны складов на том же плане; нажатие показывает содержимое склада')], size=20, small=16)
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Краны и закрепление за отделами', 'Каждая служба видит своё оборудование')
shot(s, 'd_crane_map.png', 64, 142, 680, 500)
bullets(s, 780, 142, 436, [('Краны — отдельная служба', 'Свой раздел меню, список, карта кранов и отказы по кранам'), ('Включается одной галочкой', 'В настройках сервера; своя картинка карты или план завода'), ('Оборудование за отделами', 'Крановый электрик не получает списков и аварий по станкам'), ('И в приложении', 'Раздел «Краны» в Android появляется автоматически')], size=20, small=16)
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Отказы, неисправности и задачи', 'Аварии фиксируются на месте, мелкие поручения — без нарядов')
bullets(s, 64, 142, 436, [('Отказ в карточке машины', 'Что произошло, затронутые узлы, была ли замена'), ('Сообщение о неисправности', 'Машина работает, но есть проблема: оранжевый «⚠» на карте; при остановке — перевод в отказ'), ('Задачи и поручения', 'Назначен → в работе → приостановлен / выполнен / отменён'), ('Комментарий обязателен', 'При приостановке, выполнении и отмене; можно приложить фото')], size=20, small=16)
shot(s, 'd_incident.png', 536, 142, 680, 500)
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Инструмент и склады', 'Учёт инвентаря: что где лежит и что за кем числится')
shot(s, 'd_inventory.png', 64, 142, 680, 500)
bullets(s, 780, 142, 436, [('Каталог и склады', 'Поступление, выдача, возврат, перемещение, списание — с журналом'), ('Выдача лично сотруднику', 'Инструмент числится за человеком, а не за должностью'), ('Роль «Администратор склада»', 'Ведёт учёт; сотрудник видит только выданное ему'), ('Заявки на инструмент', 'Что купить, зачем, ссылка на товар; ответ и статус — автору')], size=20, small=16)
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Карточка сотрудника', 'Кто есть в смене и что умеет')
bullets(s, 64, 142, 436, [('ФИО, фото, должность', 'И отметка, на смене ли человек сейчас'), ('График', 'Ближайшие смены по графику должности'), ('«Что умею»', 'Навыки и допуски заполняет сам сотрудник'), ('Текущие задачи и инструмент', 'Задания, наряды и выданный инвентарь')], size=20, small=16)
shot(s, 'd_user_card.png', 536, 142, 680, 500)
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'AI-помощник по порталу', 'Подскажет, где что найти и как сделать, — и только по делу')
bullets(s, 64, 142, 436, [('Знает портал и права пользователя', 'Отвечает по шагам со ссылками на нужные разделы'), ('Видит только данные пользователя', 'Его задания, наряды, инструмент, состояние машин — и ничего не меняет'), ('Только по работе', 'На посторонние вопросы вежливо отказывает; лимит вопросов в час'), ('Включает администратор', 'YandexGPT, GigaChat, OpenAI-совместимые и локальные модели')], size=20, small=16)
shot(s, 'd_assistant.png', 536, 142, 680, 500)
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'История машины и обращения извне', 'Всё, что происходило с оборудованием, — в одной ленте')
shot(s, 'd_history.png', 64, 142, 680, 500)
bullets(s, 780, 142, 436, [('Хронология по дням', 'Отказы, замены узлов, наработка, наряды и задания'), ('Фильтры', 'По типу события, периоду и тексту'), ('Публичная форма /report', 'Подрядчик сообщает об аварии без входа: место → машина → описание и контакты'), ('API для внешних систем', 'Диспетчерская или SCADA заводят отказы по ключу доступа')], size=20, small=16)
s = prs.slides.add_slide(BLANK); bg(s, False)
title(s, 'Быстрый старт и сохранность данных', 'Мастер первичной настройки и резервные копии')
shot(s, 'd_setup.png', 64, 142, 680, 500)
bullets(s, 780, 142, 436, [('Мастер по шагам', 'Графики, должности, отделы, места, оборудование, сотрудники, карта, склады'), ('Пакетный ввод', 'Одна строка — одна запись; можно вставить столбцы из Excel'), ('Логины и пароли — таблицей', 'Для печати и раздачи сотрудникам'), ('Резервные копии', 'База и конфигурация, автокопии по расписанию, восстановление')], size=20, small=16)
# Итог
s = prs.slides.add_slide(BLANK); bg(s, True)
text(s, 64, 126, 1100, 60, "Готово к запуску", 44, WHITE, True)
text(s, 64, 196, 1100, 32, "Развёртывание на вашем сервере, данные остаются у вас", 22, PALE)
cards(s, 270, [
("Свой сервер", "Docker + PostgreSQL, запуск через Coolify за обратным прокси; резервное копирование стандартными средствами"),
("Демонстрационный режим", "Личный: включается только у вас, остальные работают с рабочими данными"),
("Открытый API", "REST /api/v1, ключи доступа для внешних систем, резервные копии и мастер настройки")], 190)
text(s, 64, 520, 1100, 36, "Руководство пользователя (PDF) и описание API входят в поставку.", 24, WHITE)
OUT.parent.mkdir(parents=True, exist_ok=True)
prs.save(OUT)
print("OK", OUT)
+93
View File
@@ -0,0 +1,93 @@
"""Минимальный драйвер Chrome/Edge через DevTools Protocol (без Selenium/Playwright)."""
import asyncio
import base64
import json
import os
import subprocess
import tempfile
import time
import urllib.request
import websockets
EDGE = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
class Page:
"""Страница, к которой подключаемся по websocket (Edge headless или Chrome на эмуляторе)."""
def __init__(self, ws_url):
self.ws_url, self.n, self.ws = ws_url, 0, None
async def open(self):
self.ws = await websockets.connect(self.ws_url, max_size=2**27)
await self.call("Page.enable")
await self.call("Runtime.enable")
async def close(self):
if self.ws:
await self.ws.close()
async def call(self, method, params=None):
self.n += 1
await self.ws.send(json.dumps({"id": self.n, "method": method, "params": params or {}}))
while True:
m = json.loads(await self.ws.recv())
if m.get("id") == self.n:
if "error" in m:
raise RuntimeError(f"{method}: {m['error']}")
return m.get("result", {})
async def eval(self, expr):
r = await self.call("Runtime.evaluate", {"expression": expr, "awaitPromise": True, "returnByValue": True})
if "exceptionDetails" in r:
d = r["exceptionDetails"]
raise RuntimeError("JS: " + str(d.get("exception", {}).get("description", d.get("text"))))
return r["result"].get("value")
async def goto(self, url, wait=1.2):
await self.call("Page.navigate", {"url": url})
await asyncio.sleep(wait)
async def emulate(self, width, height, dpr=1, mobile=False, ua=None):
await self.call("Emulation.setDeviceMetricsOverride", {"width": width, "height": height, "deviceScaleFactor": dpr, "mobile": mobile})
await self.call("Emulation.setTouchEmulationEnabled", {"enabled": mobile})
if ua:
await self.call("Emulation.setUserAgentOverride", {"userAgent": ua})
async def screenshot(self, path):
r = await self.call("Page.captureScreenshot", {"format": "png", "fromSurface": True})
with open(path, "wb") as f:
f.write(base64.b64decode(r["data"]))
class Browser:
def __init__(self, port=9333):
self.port, self.proc, self.tmp = port, None, None
def start(self):
self.tmp = tempfile.mkdtemp(prefix="guide_edge_")
exe = EDGE if os.path.exists(EDGE) else CHROME
self.proc = subprocess.Popen([exe, "--headless=new", f"--remote-debugging-port={self.port}", f"--user-data-dir={self.tmp}",
"--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--lang=ru-RU",
"--force-color-profile=srgb", "about:blank"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(60):
try:
urllib.request.urlopen(f"http://localhost:{self.port}/json/version", timeout=1)
return
except Exception:
time.sleep(0.5)
raise RuntimeError("браузер не запустился")
def page_ws(self):
tabs = [t for t in json.load(urllib.request.urlopen(f"http://localhost:{self.port}/json")) if t["type"] == "page"]
return tabs[0]["webSocketDebuggerUrl"]
def stop(self):
if self.proc:
self.proc.terminate()
try:
self.proc.wait(10)
except Exception:
self.proc.kill()
+29
View File
@@ -0,0 +1,29 @@
"""Готовит чистую БД с тестовыми данными (как в демо-режиме) для съёмки скриншотов руководства.
Запуск (из корня проекта): python tools/guide/seed_server.py <каталог>
Создаёт <каталог>/guide.db и <каталог>/photos; администратор admin / admin, остальные — пароль demo1234.
Затем сервер: DATABASE_URL=sqlite:///<каталог>/guide.db DATA_DIR=<каталог> uvicorn app.main:app --port 8000
"""
import os
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
root.mkdir(parents=True, exist_ok=True)
os.environ["DATABASE_URL"] = "sqlite:///" + (root / "guide.db").as_posix()
os.environ["DATA_DIR"] = str(root)
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from sqlalchemy.orm import sessionmaker # noqa: E402
from app import db as dbm, demo # noqa: E402
from app.migrate import ensure_schema # noqa: E402
from app.security import hash_password # noqa: E402
(root / "guide.db").unlink(missing_ok=True)
ensure_schema(dbm.engine)
photos = root / "photos"
photos.mkdir(exist_ok=True)
with sessionmaker(bind=dbm.engine, autoflush=False, expire_on_commit=False)() as s:
demo.seed(s, {"id": 1, "username": "admin", "full_name": "Администратор", "password_hash": hash_password("admin")}, str(photos))
print("seeded", root)
+15
View File
@@ -0,0 +1,15 @@
"""Контрольный лист: собирает несколько скриншотов в одну картинку для быстрого просмотра."""
import sys
from pathlib import Path
from PIL import Image
img = Path(__file__).resolve().parents[2] / "docs" / "guide" / "img"
names, out, cols, w = sys.argv[3:], sys.argv[1], int(sys.argv[2]), int(sys.argv[2]) and 0
ims = [Image.open(img / f"{n}.png") for n in names]
cell = 640 if cols <= 2 else 420
th = [i.resize((cell, int(i.height * cell / i.width))) for i in ims]
rows = [th[k:k + cols] for k in range(0, len(th), cols)]
H = sum(max(t.height for t in r) for r in rows); sheet = Image.new("RGB", (cell * cols, H), "white"); y = 0
for r in rows:
for c, t in enumerate(r): sheet.paste(t, (c * cell, y))
y += max(t.height for t in r)
sheet.save(out)
+341
View File
@@ -0,0 +1,341 @@
"""Скриншоты Android-приложения на эмуляторе: управление через adb, элементы для разметки — через uiautomator.
Запуск: python tools/guide/shots_android.py
Нужны: запущенный эмулятор с установленным приложением и сервер с тестовыми данными на localhost:8000.
"""
import os
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent))
from annotate import annotate_side # noqa: E402
from spec import M # noqa: E402
ADB = r"C:\Users\darko\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "ru.checklists.app"
OUT = Path(__file__).resolve().parents[2] / "docs" / "guide" / "img"
ANDROID = [] # (имя, метки) — заполняется при съёмке, нужен PDF-сборщику
MARKS = {}
def adb(*a, binary=False):
r = subprocess.run([ADB, *a], capture_output=True)
return r.stdout if binary else r.stdout.decode("utf-8", "replace")
def sleep(s):
time.sleep(s)
def dump():
for attempt in range(4): # uiautomator иногда отдаёт пустой файл, пока экран перерисовывается
adb("shell", "uiautomator", "dump", "/sdcard/ui.xml")
xml = adb("exec-out", "cat", "/sdcard/ui.xml")
try:
root = ET.fromstring(xml)
break
except ET.ParseError:
if attempt == 3:
raise
sleep(1)
out = []
for n in root.iter("node"):
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", n.get("bounds", ""))
if not m:
continue
x1, y1, x2, y2 = map(int, m.groups())
out.append({"text": n.get("text", ""), "desc": n.get("content-desc", ""), "cls": n.get("class", ""), "b": (x1, y1, x2, y2),
"click": n.get("clickable") == "true"})
return out
def find(text=None, desc=None, cls=None, nth=0, exact=False, retry=6):
for _ in range(retry):
res = []
for n in dump():
if cls and n["cls"] != cls:
continue
if text is not None and not ((n["text"] == text) if exact else (text in n["text"])):
continue
if desc is not None and desc not in n["desc"]:
continue
if n["b"][2] - n["b"][0] > 0 and n["b"][3] - n["b"][1] > 0:
res.append(n)
if len(res) > nth:
return res[nth]
sleep(1)
raise RuntimeError(f"не найден элемент text={text!r} desc={desc!r} cls={cls!r} nth={nth}")
def pick(nodes, text, nth=0):
res = [n for n in nodes if text in n["text"] and n["b"][2] > n["b"][0] and n["b"][3] > n["b"][1]]
return res[nth] if len(res) > nth else None
def btn(text, nth=0, exact=False):
"""Кликабельный контейнер (кнопка, чип, карточка) по тексту внутри него."""
t = find(text, nth=nth, exact=exact)
best = None
for n in dump():
if n["click"] and n["b"][0] <= t["b"][0] and n["b"][1] <= t["b"][1] and n["b"][2] >= t["b"][2] and n["b"][3] >= t["b"][3]:
area = (n["b"][2] - n["b"][0]) * (n["b"][3] - n["b"][1])
if best is None or area < best[0]:
best = (area, n)
return best[1] if best else t
def scroll_to(text, tries=6):
for _ in range(tries):
try:
return btn(text) if find(text, retry=1) else None
except RuntimeError:
swipe_up(700)
raise RuntimeError(f"не найден при прокрутке: {text!r}")
def tap(n):
x1, y1, x2, y2 = n["b"]
adb("shell", "input", "tap", str((x1 + x2) // 2), str((y1 + y2) // 2))
def type_text(s):
adb("shell", "input", "text", s.replace(" ", "%s"))
def drawer_item(text):
"""Пункт бокового меню: если он ниже края экрана, меню прокручивается."""
for _ in range(4):
n = btn(text, exact=True)
if n["b"][3] < 2250:
return n
adb("shell", "input", "swipe", "300", "2000", "300", "900", "450")
sleep(0.8)
return btn(text, exact=True)
def swipe_up(dist=1100):
adb("shell", "input", "swipe", "540", "1900", "540", str(1900 - dist), "450")
sleep(0.8)
def swipe_down(dist=1100):
adb("shell", "input", "swipe", "540", "700", "540", str(700 + dist), "450")
sleep(0.8)
def back():
adb("shell", "input", "keyevent", "4")
sleep(0.8)
def shot(name, marks):
"""marks: [(M, node)] — M даёт подпись и направление, node — найденный элемент."""
sleep(0.8)
OUT.mkdir(parents=True, exist_ok=True)
raw = OUT / f"{name}.raw.png"
raw.write_bytes(adb("exec-out", "screencap", "-p", binary=True))
rects = []
for i, (m, n) in enumerate(marks, 1):
x1, y1, x2, y2 = n["b"]
gx, gy = m.grow
rects.append((i, (x1, y1, x2 - x1 + gx, y2 - y1 + gy), m.dir))
img = annotate_side(Image.open(raw), rects, margin=170, scale=2.2)
img.save(OUT / f"{name}.png", optimize=True)
raw.unlink()
MARKS[name] = [m.label for m, _ in marks]
print("ok", name, img.size, flush=True)
def login(user, pw="demo1234"):
fields = [n for n in dump() if n["cls"] == "android.widget.EditText"]
tap(fields[1]); sleep(0.5)
type_text(user)
adb("shell", "input", "keyevent", "61"); type_text(pw)
back() # скрыть клавиатуру
tap(btn("Войти", exact=True))
sleep(7)
PORT = os.environ.get("GUIDE_PORT", "8000") # порт сервера на ПК; в приложении адрес всегда localhost:8000
def restart(clear=True):
adb("reverse", "tcp:8000", f"tcp:{PORT}")
if clear:
adb("shell", "pm", "clear", PKG)
adb("shell", "pm", "grant", PKG, "android.permission.POST_NOTIFICATIONS")
adb("shell", "am", "force-stop", PKG)
adb("shell", "am", "start", "-n", f"{PKG}/.MainActivity")
sleep(4)
def run():
# 1. вход
restart()
fields = [n for n in dump() if n["cls"] == "android.widget.EditText"]
tap(fields[0]); sleep(0.4); type_text("http://localhost:8000"); back()
fields = [n for n in dump() if n["cls"] == "android.widget.EditText"]
shot("a_login", [
(M("Адрес сервера — его сообщит администратор (например, https://checklists.example.ru)"), fields[0]),
(M("Ваш логин и пароль"), {"b": (fields[1]["b"][0], fields[1]["b"][1], fields[2]["b"][2], fields[2]["b"][3])}),
(M("«Войти»"), btn("Войти", exact=True)),
])
login("sidorov")
# 2. вкладка «Задачи» (открывается после входа), затем список нарядов
shot("a_work", [
(M("Шапка: быстрые действия (задача, авария, неисправность, заявка) и сводка"), btn("Задача себе")),
(M("Сводка: мои наряды и задачи, открытые аварии и неисправности"), find("мои наряды")),
(M("Блок «Наряды»: фильтры и ближайшие наряды; «Все →» — полный список"), find("Мои ·")),
(M("Меню: все разделы приложения"), find(desc="Меню")),
])
tap(find(desc="Меню")); sleep(1.2)
tap(btn("Наряды", exact=True)); sleep(3)
shot("a_list", [
(M("Время последней синхронизации"), find("Обновлено")),
(M("Меню: все разделы приложения"), find(desc="Меню")),
(M("Поиск и обновление"), {"b": (find(desc="Поиск")["b"][0], find(desc="Поиск")["b"][1], find(desc="Обновить")["b"][2], find(desc="Обновить")["b"][3])}),
(M("Фильтры: активные, мои, завершённые"), btn("Активные")),
(M("Карточка наряда: статус, срок, прогресс. «▶ можно выполнять» — наряд ваш"), btn("Проверка насосной")),
])
# 3. наряд в работе
tap(scroll_to("Насос Н-2")); sleep(3)
shot("a_order_top", [
(M("Назад к списку"), find(desc="Назад")),
(M("Статус наряда и кто назначен"), find("В работе", exact=True)),
(M("Число: введите значение, ниже — допустимая норма"), find(cls="android.widget.EditText", nth=0)),
])
swipe_up(1300)
shot("a_order_bottom", [
(M("Выбор варианта — нажатием на нужный"), btn("нет", exact=True)),
(M("Фото: откроется камера телефона"), btn("Фото")),
(M("«Сдать наряд» — когда всё заполнено"), btn("Сдать наряд", exact=True)),
])
# 4. без связи: убираем проброс порта и выбираем вариант ответа
adb("reverse", "--remove", "tcp:8000")
tap(btn("есть, незначительные", exact=True)); sleep(2)
back(); sleep(2.5) # к списку
tap(find(desc="Обновить")); sleep(5) # попытка синхронизации без связи
swipe_up(900); sleep(1)
D = dump()
shot("a_offline", [x for x in [
(M("Сверху видно, сколько нарядов ещё не отправлено: данные сохранены на телефоне и уйдут сами"), pick(D, "Не отправлено")),
(M("Приложение сообщает, что связи нет"), pick(D, "Нет связи")),
(M("Наряд помечен «не отправлено»"), pick(D, "не отправлено", 1) or pick(D, "не отправлено")),
] if x[1]])
adb("reverse", "tcp:8000", f"tcp:{PORT}")
# 5. боковое меню и разделы портала
tap(find(desc="Меню")); sleep(1.5)
shot("a_menu", [
(M("Кто вошёл: ФИО и должность"), find("Сидоров")),
(M("Работа: вкладка «Задачи», наряды, задачи и поручения"), {"b": (btn("Задачи — всё")["b"][0], btn("Задачи — всё")["b"][1], btn("Задачи и поручения")["b"][2], btn("Задачи и поручения")["b"][3])}),
(M("Оборудование: состояние машин и фиксация инцидентов"), btn("Оборудование и инциденты")),
(M("Инструмент: что выдано вам и заявки на покупку"), {"b": (btn("Мой инструмент")["b"][0], btn("Мой инструмент")["b"][1], btn("Заявки на инструмент")["b"][2], btn("Заявки на инструмент")["b"][3])}),
(M("Карта завода и сотрудники открываются в полной версии сайта (в браузере)"), btn("Карта завода")),
])
tap(btn("Задачи и поручения")); sleep(3)
shot("a_tasks", [
(M("Кнопка меню — переход к другим разделам"), find(desc="Меню")),
(M("Показать все задания или только активные"), btn("Все", exact=True)),
(M("Карточка задания: статус, день, оборудование"), btn("Замер вибрации")),
(M("Поставить задание себе"), btn("+")),
])
tap(btn("Замер вибрации")); sleep(3)
comment = find(cls="android.widget.EditText", nth=0)
shot("a_task", [
(M("Статус задания"), find("Назначен", exact=True)),
(M("Комментарий: обязателен для «приостановлен», «выполнен», «отменён»"), comment),
(M("Кнопки смены статуса"), {"b": (btn("В работу")["b"][0], btn("В работу")["b"][1], btn("Отменить")["b"][2], btn("Отменить")["b"][3])}),
(M("Фото к заданию — с камеры телефона"), btn("Сфотографировать")),
])
back(); sleep(1)
tap(find(desc="Меню")); sleep(1.2)
tap(btn("Оборудование и инциденты")); sleep(3)
tap(btn("Насос Н-2")); sleep(3)
shot("a_equipment", [
(M("Состояние машины: «Авария» — есть открытый инцидент"), find("Авария", exact=True)),
(M("Зафиксировать аварию (машина остановлена)"), btn("Зафиксировать инцидент")),
(M("Сообщить о неисправности (машина работает)"), btn("Сообщить о неисправности")),
(M("Инцидент: что случилось, узлы, была ли замена; «Закрыть» — после устранения"), btn("Течь по торцевому")),
])
tap(btn("История машины")); sleep(3)
shot("a_history", [
(M("Фильтры по типам событий"), btn("Инциденты")),
(M("События: что произошло, когда, кто"), find(cls="android.widget.TextView", nth=8)),
])
back(); sleep(1.5)
tap(btn("Зафиксировать инцидент")); sleep(1.5)
shot("a_incident", [
(M("Что произошло и подробности"), find(cls="android.widget.EditText", nth=0)),
(M("Затронутые узлы (заведены в карточке машины); отметка «заменён» сбрасывает ресурс узла"), find("Подшипники")),
(M("«Зафиксировать» — машина получит статус «Авария»"), btn("Зафиксировать", exact=True)),
])
tap(btn("Отмена", exact=True)); sleep(1)
back()
# 6. помощник (если включён на сервере)
tap(find(desc="Меню")); sleep(1.2)
try:
tap(drawer_item("Помощник")); sleep(2)
tap(btn("Как зафиксировать аварию?")); sleep(4)
shot("a_assistant", [
(M("Вопрос пользователя"), find("Как зафиксировать аварию?")),
(M("Ответ со ссылками: нажмите — откроется нужный экран"), find("Чтобы зафиксировать")),
(M("Поле вопроса"), find(cls="android.widget.EditText", nth=0)),
])
back(); sleep(1)
tap(find(desc="Меню")); sleep(1.2)
except RuntimeError as e:
print("помощник не снят:", e)
back(); sleep(1)
tap(find(desc="Меню")); sleep(1.2)
# 7. настройки
tap(drawer_item("Настройки")); sleep(2)
shot("a_settings", [
(M("Ваша должность и статус смены"), find("Дежурный механик")),
(M("Принудительная синхронизация"), btn("Синхронизировать")),
(M("Выход из аккаунта"), btn("Выйти", exact=True)),
])
# 8. инженер: проверка
tap(btn("Выйти", exact=True)); sleep(1.5)
dlg = [n for n in dump() if n["text"] in ("Выйти", "Всё равно выйти")]
tap(dlg[-1]); sleep(2.5)
login("morozova")
tap(find(desc="Меню")); sleep(1.2)
tap(btn("Наряды", exact=True)); sleep(3)
tap(btn("На проверку")); sleep(1.5)
shot("a_review_list", [
(M("Фильтр «На проверку» — наряды, которые ждут вашего решения"), btn("На проверку")),
(M("«✔ ждёт вашей проверки» — откройте наряд"), btn("ждёт вашей проверки")),
])
tap(btn("ждёт вашей проверки")); sleep(3)
for _ in range(4):
try:
find("Принять", exact=True, retry=1)
break
except RuntimeError:
swipe_up(900)
accept = btn("Принять", exact=True)
comment = [n for n in dump() if n["cls"] == "android.widget.EditText" and n["b"][3] < accept["b"][1]][-1]
shot("a_review", [
(M("Комментарий проверяющего (обязателен при возврате)"), comment),
(M("«Принять» — наряд завершён"), accept),
(M("«Вернуть» — на доработку"), btn("Вернуть", exact=True)),
])
if __name__ == "__main__":
run()
import json
(OUT / "android_marks.json").write_text(json.dumps(MARKS, ensure_ascii=False, indent=1), encoding="utf-8")
+140
View File
@@ -0,0 +1,140 @@
"""Снимает и размечает скриншоты веб-интерфейса (desktop) и мобильной версии /m.
Запуск: python tools/guide/shots_web.py [имя_скриншота ...]
Нужен запущенный сервер с тестовыми данными (tools/guide/seed_server.py) на http://localhost:8000.
"""
import asyncio
import json
import os
import sys
import urllib.request
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent))
from annotate import annotate, annotate_side # noqa: E402
from cdp import Browser, Page # noqa: E402
from spec import DESKTOP, MOBILE, PASS, Shot # noqa: E402
BASE = os.environ.get("GUIDE_BASE", "http://localhost:8000")
OUT = Path(__file__).resolve().parents[2] / "docs" / "guide" / "img"
ANDROID_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Mobile Safari/537.36"
FIND = """((sel,text,nth)=>{const els=[...document.querySelectorAll(sel)].filter(e=>{const t=(e.innerText||'')+' '+(e.value||'')+' '+(e.placeholder||'');return !text||t.includes(text)})
.filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0});const e=els[nth];if(!e)return null;const r=e.getBoundingClientRect();return [r.x,r.y,r.width,r.height]})"""
def api_login(user):
req = urllib.request.Request(BASE + "/api/v1/auth/login", data=json.dumps({"username": user, "password": PASS.get(user, "demo1234")}).encode(),
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req))
async def find(page: Page, sel, text, nth):
return await page.eval(f"{FIND}({json.dumps(sel)},{json.dumps(text)},{nth})")
async def reset(page: Page):
await page.goto("about:blank", 0.2)
await page.call("Storage.clearDataForOrigin", {"origin": BASE, "storageTypes": "all"})
async def run_steps(page: Page, shot: Shot):
mobile = shot.device == "mobile"
for st in shot.steps:
op = st[0]
if op == "reset":
await reset(page)
await page.goto(BASE + ("/m" if mobile else "/"), 1.5)
elif op == "hash":
if mobile:
await page.eval(f"location.hash={json.dumps(st[1])}")
await asyncio.sleep(0.8)
else:
await page.goto(BASE + "/" + st[1], 1.2)
elif op == "js":
await page.eval(st[1]); await asyncio.sleep(0.6)
elif op == "wait":
await asyncio.sleep(st[1])
elif op == "scroll":
await page.eval(f"""(()=>{{const els=[...document.querySelectorAll({json.dumps(st[1])})].filter(e=>!{json.dumps(st[2])}||(e.innerText||'').includes({json.dumps(st[2])}));
const e=els[{st[3]}]; if(!e) throw new Error('scroll: нет {st[1]}'); e.scrollIntoView({{block:'start'}}); window.scrollBy(0,-84);}})()""")
await asyncio.sleep(0.4)
elif op == "click":
r = await find(page, st[1], st[2], st[3])
if not r: raise RuntimeError(f"click: не найден {st[1]!r} {st[2]!r}")
await page.eval(f"""(()=>{{const els=[...document.querySelectorAll({json.dumps(st[1])})].filter(e=>!{json.dumps(st[2])}||(e.innerText||'').includes({json.dumps(st[2])}));els[{st[3]}].click();}})()""")
await asyncio.sleep(0.9)
elif op == "m_login":
await page.goto(BASE + "/m", 1.5)
await page.eval(f"""(()=>{{const [u,p]=document.querySelectorAll('input'); u.value={json.dumps(st[1])}; p.value={json.dumps(st[2])}; document.querySelector('button.block').click();}})()""")
await asyncio.sleep(4.5)
elif op == "offline":
await page.call("Network.enable")
await page.call("Network.emulateNetworkConditions", {"offline": st[1], "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1})
else:
raise ValueError(op)
async def run_shot(page: Page, shot: Shot):
mobile = shot.device == "mobile"
dpr = 2 if mobile else 1
await page.emulate(390 if mobile else 1280, shot.height or (844 if mobile else 860), dpr, mobile, ANDROID_UA if mobile else None)
await page.call("Emulation.setEmulatedMedia", {"features": [{"name": "prefers-color-scheme", "value": "light"}]})
await page.call("Network.enable")
await page.call("Network.emulateNetworkConditions", {"offline": False, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1})
first = shot.steps[0][0] if shot.steps else ""
if first != "reset" and first != "m_login":
await reset(page)
if shot.user and not mobile:
t = api_login(shot.user)
await page.goto(BASE + "/", 0.8)
await page.eval(f"localStorage.setItem('token',{json.dumps(t['access_token'])}); localStorage.setItem('refresh',{json.dumps(t['refresh_token'])});")
await page.call("Page.reload")
await asyncio.sleep(1.2)
if mobile and first == "m_login":
await reset(page)
await run_steps(page, shot)
# убрать подсветку фокуса/каретку
await page.eval("document.activeElement && document.activeElement.blur && document.activeElement.blur(); window.getSelection().removeAllRanges()")
marks = []
for i, m in enumerate(shot.marks, 1):
r = await find(page, m.sel, m.text, m.nth)
if not r:
raise RuntimeError(f"[{shot.name}] метка {i} не найдена: sel={m.sel!r} text={m.text!r} nth={m.nth}")
x, y, w, h = r
w, h = w + m.grow[0], h + m.grow[1]
marks.append((i, (x * dpr, y * dpr, w * dpr, h * dpr), m.dir))
OUT.mkdir(parents=True, exist_ok=True)
raw = OUT / f"{shot.name}.raw.png"
await page.screenshot(str(raw))
shot_img = Image.open(raw)
img = annotate_side(shot_img, marks, margin=110, scale=dpr * 0.9) if mobile else annotate(shot_img, marks, scale=1.0)
if shot.crop:
x, y, w, h = shot.crop
img = img.crop((x, y, x + w, y + h))
img.save(OUT / f"{shot.name}.png", optimize=True)
raw.unlink()
print("ok", shot.name, img.size, flush=True)
async def main(names):
b = Browser()
b.start()
try:
page = Page(b.page_ws())
await page.open()
shots = [s for s in MOBILE + DESKTOP if not names or s.name in names] # мобильные первыми: им нужны «свежие» наряды
for s in shots:
try:
await run_shot(page, s)
except Exception as e:
print("FAIL", s.name, e, flush=True)
await page.close()
finally:
b.stop()
if __name__ == "__main__":
asyncio.run(main(set(sys.argv[1:])))
+403
View File
@@ -0,0 +1,403 @@
"""Сценарии скриншотов руководства: что открыть, под кем, какие элементы отметить (метки попадают и в легенду PDF)."""
from dataclasses import dataclass, field
@dataclass
class M:
"""Метка на скриншоте. sel/text — как найти элемент; label — подпись в легенде под картинкой."""
label: str
sel: str = ""
text: str = ""
nth: int = 0
dir: str = "auto"
pad: int = 0 # доп. отступ рамки, px
grow: tuple = (0, 0) # расширить рамку вправо/вниз (для групп элементов)
@dataclass
class Shot:
name: str
device: str # desktop | mobile | android
user: str | None = None # логин (пароль: admin → admin, остальные demo1234)
steps: list = field(default_factory=list)
marks: list = field(default_factory=list)
height: int = 0 # высота окна (0 — по умолчанию устройства)
crop: tuple = () # (x, y, w, h) — обрезать итоговую картинку
PASS = {"admin": "admin"}
def order_js(title, status="", sel="location.hash='#/order/'+o.id"):
q = f"&status={status}" if status else ""
return (f"(async()=>{{const l=await api('GET','/orders?limit=500{q}'); const o=l.find(x=>x.title.includes('{title}')); "
f"if(!o) throw new Error('нет наряда {title}'); {sel};}})()")
def m_order_js(title, status=""):
q = f"&status={status}" if status else ""
return (f"(async()=>{{const l=await api('GET','/orders?limit=500{q}'); const o=l.find(x=>x.title.includes('{title}')); "
f"if(!o) throw new Error('нет наряда {title}'); location.hash='#/o/'+o.id;}})()")
# ответы для заполнения наряда «ТО компрессора» через API (давление, масло, фильтр, слив)
FILL_COMP = ("(async()=>{const l=await api('GET','/orders?limit=500&status=new'); const o=l.find(x=>x.title.includes('ТО компрессора')); "
"const d=await api('GET','/orders/'+o.id); const v=['7.2','в норме','done','done']; "
"for(let i=0;i<4;i++) await api('PUT','/orders/'+o.id+'/items/'+d.items[i].id,{value:v[i]});})()")
# фото-заглушка к пункту «Воздушный фильтр»
PHOTO_JS = ("(async()=>{const l=await api('GET','/orders?limit=500&status=in_progress'); const o=l.find(x=>x.title.includes('ТО компрессора')); "
"const d=await api('GET','/orders/'+o.id); const it=d.items.find(i=>i.photo==='required'); "
"const cv=document.createElement('canvas'); cv.width=640; cv.height=480; const g=cv.getContext('2d'); "
"const gr=g.createLinearGradient(0,0,640,480); gr.addColorStop(0,'#5b8def'); gr.addColorStop(1,'#223a66'); g.fillStyle=gr; g.fillRect(0,0,640,480); "
"g.fillStyle='#e9eef9'; g.fillRect(140,120,360,240); g.fillStyle='#223a66'; for(let i=0;i<8;i++) g.fillRect(160+i*42,140,22,200); "
"const b=await new Promise(r=>cv.toBlob(r,'image/jpeg',.85)); "
"await authFetch(API+'/orders/'+o.id+'/items/'+it.id+'/photos',{method:'POST',headers:{'Content-Type':'image/jpeg'},body:b});})()")
DESKTOP = [
Shot("d_login", "desktop", None, [("reset",), ("hash", "")], [
M("Логин и пароль, которые выдал администратор", "input", nth=0, pad=2, grow=(0, 78), dir="left"),
M("Кнопка «Войти»", "button", "Войти"),
M("Ссылка на скачивание приложения для Android (если администратор загрузил файл)", ".btn", "Скачать приложение", dir="right"),
M("Инструкция и презентация системы", "p", "Презентация", dir="right"),
], crop=(250, 40, 780, 600)),
Shot("d_orders", "desktop", "sidorov", [("hash", "#/"), ("wait", 1.5), ("js", "const s=document.querySelector('#app select'); s.value='new'; s.dispatchEvent(new Event('change'))"), ("wait", 1.2)], [
M("Боковое меню: раздел «Наряды» — список всех ваших нарядов", "#side nav a", "Наряды", dir="right"),
M("Счётчики по статусам: нажмите на плитку, чтобы отфильтровать", ".stat", nth=0, grow=(560, 0), dir="up"),
M("Строка поиска: название, оборудование или номер наряда", "input[type=search]", dir="down"),
M("Фильтр по статусу", "#app select", nth=0, dir="down"),
M("Быстрые фильтры «Мои» и «Просроченные»", "label", "Мои", grow=(120, 0), dir="left"),
M("Строка наряда — нажмите, чтобы открыть. Подсказка «можно выполнять» значит, что наряд ваш", "tbody tr", nth=0, dir="right"),
]),
Shot("d_order_top", "desktop", "sidorov", [("js", order_js("ТО компрессора", "new")), ("wait", 1.5)], [
M("Статус наряда", ".badge", nth=0, dir="right"),
M("Оборудование, место, кто назначен и проверяющий, срок выполнения", ".grid", nth=0, dir="right"),
M("Прогресс чек-листа: сколько пунктов заполнено", "h2", "Чек-лист", dir="right"),
]),
Shot("d_order_items", "desktop", "sidorov", [("js", order_js("ТО компрессора", "new")), ("wait", 1.2), ("scroll", "h2", "Чек-лист", 0)], [
M("Число: введите значение, под полем — допустимая норма. Вне нормы поле подсветится", "input[type=text]", nth=0, grow=(0, 24), dir="left"),
M("Выбор из списка: просто выберите вариант", ".item select", nth=0, dir="left"),
M("Статус пункта: «Не начато», «В работе», «Выполнено»", ".item select", nth=1, dir="left"),
M("Кнопка добавления фото (на телефоне откроет камеру)", ".photos button, .item button", "Добавить фото", dir="right"),
]),
Shot("d_order_filled", "desktop", "sidorov", [("js", FILL_COMP), ("js", PHOTO_JS), ("js", order_js("ТО компрессора", "in_progress")), ("wait", 1.6),
("scroll", "h2", "Чек-лист", 0)], [
M("Заполненные пункты", ".item select", nth=0, dir="left"),
M("Приложенное фото (нажмите, чтобы увеличить; ✕ — удалить)", ".photos .ph", nth=0, dir="right"),
M("Когда всё заполнено — «Сдать наряд»", "button", "Сдать наряд", dir="up"),
], height=900),
Shot("d_order_review_wait", "desktop", "sidorov", [("js", order_js("ТО компрессора", "in_progress", "const x=await api('POST','/orders/'+o.id+'/submit'); location.hash='#/order/'+o.id")), ("wait", 1.5)], [
M("После сдачи наряд получает статус «На проверке» и уходит проверяющему", ".badge", nth=0, dir="right"),
M("Время сдачи фиксируется автоматически", ".kv, .grid", nth=0, dir="right"),
]),
Shot("d_review", "desktop", "morozova", [("js", order_js("ТО компрессора", "review")), ("wait", 1.5), ("scroll", "h2", "Проверка", 0)], [
M("Комментарий проверяющего: обязателен, если возвращаете на доработку", "textarea", nth=0, dir="left"),
M("«Принять» — наряд завершён", "button", "Принять", dir="up"),
M("«Вернуть на доработку» — исполнитель увидит ваш комментарий", "button", "Вернуть", dir="up"),
]),
Shot("d_neworder", "desktop", "volkov", [("hash", "#/new-order"), ("wait", 1.2)], [
M("Название наряда", "input", nth=0, dir="left"),
M("Чек-лист — шаблон работ, который нужно выполнить", ".grid select", nth=0, dir="up"),
M("Оборудование и место", ".grid select", nth=1, grow=(250, 0), dir="up"),
M("Исполнитель: должность (любой, кто на смене) или конкретный человек", ".grid select", nth=3, grow=(250, 0), dir="left"),
M("Проверяющий: должность или конкретный человек", ".grid select", nth=5, grow=(250, 0), dir="right"),
M("«Создать наряд»", "button", "Создать наряд", dir="right"),
]),
Shot("d_checklist", "desktop", "volkov", [("js", "(async()=>{const l=await api('GET','/checklists'); location.hash='#/checklists/'+l.find(x=>x.name.includes('насосной')).id})()"), ("wait", 1.5)], [
M("Название чек-листа", "input", nth=0, dir="left"),
M("Тип ответа: статус, список, число, текст", ".card select", nth=0, dir="left"),
M("Режим фото: без фото, по желанию, обязательно", ".card select", nth=1, dir="left"),
M("Порядок и удаление задач", ".card button", "↑", dir="left"),
], height=900),
Shot("d_templates", "desktop", "volkov", [("hash", "#/templates"), ("wait", 1.5)], [
M("Расписание автозапуска: как часто создаётся наряд", "tbody tr", nth=0, dir="up"),
M("Время следующего запуска", "tbody tr td", nth=2, dir="down"),
M("«Создать сейчас» — разовый запуск без ожидания расписания", "button", "Создать сейчас", nth=0, dir="up"),
M("«Изменить» — настройки наряда и периодичности", "button", "Изменить", nth=0, dir="up"),
]),
Shot("d_reports", "desktop", "volkov", [("hash", "#/reports/completed-orders"), ("wait", 1.0), ("click", "button", "7 дней", 0), ("click", "button", "Показать", 0), ("wait", 1.5)], [
M("Период (дата и время) обязателен", "input[type=datetime-local]", nth=0, grow=(300, 0), dir="up"),
M("Фильтры: машина, должность исполнителя, исполнитель", ".grid select", nth=0, grow=(250, 0), dir="up"),
M("Быстрый выбор периода", "button", "Сегодня", grow=(380, 0), dir="left"),
M("«Показать» — на экран, «PDF» и «CSV» — файл", "button", "Показать", grow=(210, 0), dir="up"),
], height=900),
Shot("d_admin", "desktop", "admin", [("hash", "#/admin"), ("wait", 1.5)], [
M("Пользователи, должности и графики работы", ".tiles", nth=0, dir="up"),
M("Рабочие данные: чек-листы, оборудование, места, расписание нарядов", ".tiles", nth=1, dir="up"),
M("Сервер: мастер настройки, настройки сервера и обратная связь (администратор портала), копии, демо-режим", ".tiles", nth=2, dir="up"),
]),
Shot("d_feedback", "desktop", "admin", [("js", "(async()=>{await api('POST','/feedback',{kind:'idea',text:'Добавить в карточку машины фото паспортной таблички',page:'#/equipment/3'}); await api('POST','/feedback',{kind:'bug',text:'На телефоне карта кранов открывается не сразу',page:'#/map/cranes',client:'m'}); location.hash='#/feedback'})()"), ("wait", 2.0)], [
M("Открытые, сделанные и все сообщения", ".tabs", dir="down"),
M("Сообщение: что предложили, кто, из какого раздела", ".fbcard", nth=0, dir="right"),
M("Статус и заметка о решении", ".fbcard select", nth=0, dir="left"),
], height=900),
Shot("d_user_form", "desktop", "admin", [("hash", "#/users"), ("wait", 1.0), ("click", "button", "+ Добавить", 0), ("wait", 0.8)], [
M("Логин для входа (ФИО и пароль — рядом)", ".card .grid input", nth=0, dir="left"),
M("Должность — определяет график работы и права (техник или инженер)", ".card .grid select", nth=0, dir="right"),
M("Роли «Модератор» и «Администратор» назначает только администратор", ".card .grid input[type=checkbox]", nth=0, grow=(300, 0), dir="down"),
], height=1000),
Shot("d_schedule", "desktop", "admin", [("hash", "#/schedules"), ("wait", 1.0), ("click", "button", "+ Добавить", 0), ("wait", 1.5), ("scroll", "h2", "Новый график", 0)], [
M("Тип графика: свои шаги, N через M, по дням недели", ".card select", nth=0, dir="left"),
M("Готовые шаблоны графиков", "button", "Сутки через трое", grow=(260, 0), dir="up"),
M("Шаг цикла: смена или выходной, сколько дней подряд, время и метка", ".step", nth=0, dir="right"),
M("Первый день цикла — с какой даты начинается шаг №1", "input[type=date]", nth=0, dir="right"),
], height=1100),
Shot("d_schedule_cal", "desktop", "admin", [("hash", "#/schedules"), ("wait", 1.0), ("click", "button", "+ Добавить", 0), ("wait", 1.5), ("scroll", ".cal", "", 0)], [
M("Календарь: синие — дневные смены, фиолетовые — ночные, серые — выходные", ".cal", nth=0, dir="right"),
], height=900),
Shot("d_menu", "desktop", "admin", [("hash", "#/tasks"), ("wait", 1.2)], [
M("Ваша карточка: ФИО, должность, роль. Нажмите, чтобы открыть", ".meblock", dir="right"),
M("Работа: вкладка «Задачи», наряды, задачи и поручения, расписание, чек-листы", ".navh", "РАБОТА", grow=(0, 200), dir="right"),
M("Оборудование, карта завода, отказы, места", ".navh", "ОБОРУДОВАНИЕ", grow=(0, 140), dir="right"),
M("Инструмент: мой инструмент, заявки, склады и учёт, карта складов", ".navh", "ИНСТРУМЕНТ", grow=(0, 140), dir="right"),
M("Сотрудники, отчёты и администрирование", ".navh", "ЛЮДИ", grow=(0, 120), dir="right"),
M("Краны — отдельная служба (если включено в настройках)", ".navh", "КРАНЫ", grow=(0, 110), dir="right"),
M("Инструкция, приложение, презентация и форма для заявки", ".sidelinks", dir="right"),
M("Уведомления и выход", ".sidefoot", dir="right"),
], height=1450, crop=(0, 0, 620, 1450)),
Shot("d_tasks", "desktop", "volkov", [("hash", "#/tasks"), ("wait", 1.5)], [
M("День: по умолчанию сегодня; «Все дни» — без фильтра", "#app input[type=date]", dir="down"),
M("Фильтры по статусу и сотруднику, «Только мои»", "#app select", nth=0, grow=(330, 0), dir="down"),
M("Задание: статус, исполнитель, оборудование; последний комментарий", ".taskc", nth=0, dir="up"),
M("«+ Задание» — поставить новое", "a.btn", "+ Задание", dir="left"),
]),
Shot("d_task", "desktop", "kozlov", [("js", "(async()=>{const l=await api('GET','/tasks?limit=500'); const t=l.find(x=>x.title.includes('ремней')); if(!t) throw new Error('нет задания'); location.hash='#/task/'+t.id})()"), ("wait", 1.5)], [
M("Статус задания", ".badge", nth=0, dir="left"),
M("Оборудование или узел, к которому привязано задание", "a", "Вентилятор", dir="down"),
M("Комментарий к смене статуса", "textarea", nth=0, dir="left"),
M("Кнопки статусов: приостановить, выполнить, отменить — только с комментарием", "button", "Приостановить", grow=(300, 0), dir="up"),
M("Фото к заданию", "button", "Добавить фото", dir="right"),
], height=1000),
Shot("d_equipment", "desktop", "sidorov", [("js", "(async()=>{const l=await api('GET','/equipment'); location.hash='#/equipment/'+l.find(x=>x.name.includes('Н-2')).id})()"), ("wait", 2.0)], [
M("Иконка машины; «!» — открыт отказ", ".eqbig", dir="right"),
M("Состояние: «Авария», «Требуется ТО», «Скоро ТО» или «В норме»", ".badge.es-alarm", dir="right"),
M("Зафиксировать отказ (авария, машина стоит)", "button", "Зафиксировать отказ", dir="down"),
M("Сообщить о неисправности (машина работает, но есть проблема)", "button", "Сообщить о неисправности", dir="down"),
M("Задание по этой машине", "a.btn", "Задание по машине", dir="down"),
], height=760),
Shot("d_incident", "desktop", "sidorov", [("js", "(async()=>{const l=await api('GET','/equipment'); location.hash='#/equipment/'+l.find(x=>x.name.includes('Н-2')).id})()"), ("wait", 2.0), ("click", "button", "Зафиксировать отказ", 0), ("wait", 1.0)], [
M("Что произошло и когда", ".card .grid input", nth=0, grow=(560, 0), dir="up"),
M("Подробности", ".card textarea", nth=0, dir="left"),
M("Затронутые узлы (из заведённых в карточке). «Заменён» — ресурс узла начнётся заново", ".unitpick", nth=0, dir="left"),
M("Была ли замена и что заменено", ".chk", "Была замена", grow=(500, 0), dir="up"),
M("«Зафиксировать» — машина сразу получит состояние «Авария»", "button.bad", "Зафиксировать", nth=1, dir="right"),
], height=1150),
Shot("d_icons", "desktop", "volkov", [("js", "(async()=>{const l=await api('GET','/equipment'); location.hash='#/equipment/'+l.find(x=>x.name.includes('Компрессор')).id})()"), ("wait", 2.0), ("click", "button", "Иконка", 0), ("wait", 1.0)], [
M("Встроенный набор иконок — нажмите, чтобы выбрать", ".iconpick", dir="right"),
M("Или загрузите своё фото/картинку", "button", "Загрузить своё фото", dir="right"),
], height=900),
Shot("d_map", "desktop", "sidorov", [("hash", "#/map"), ("wait", 2.5)], [
M("Слои: оборудование и склады", ".tabs a", "Склады", dir="right"),
M("Легенда: «!» — авария, «⚠» — неисправность, жёлтое свечение — приближается лимит ТО", ".legend", dir="left"),
M("Машина с открытой неисправностью: оранжевый значок", ".mk.st-fault", nth=0, dir="down"),
M("Машина с открытым отказом: восклицательный знак", ".mk.st-alarm", nth=0, dir="down"),
M("Подсветка жёлтым: по узлам машины скоро или уже нужно ТО", ".mk.st-due, .mk.st-soon", nth=0, dir="up"),
], height=900),
Shot("d_map_edit", "desktop", "admin", [("hash", "#/map"), ("wait", 2.0), ("click", "button", "Редактировать карту", 0), ("wait", 1.0)], [
M("Загрузить или заменить картинку плана завода", "button", "Заменить картинку", dir="right"),
M("Список машин: «Поставить» и клик по карте — разместить; ✕ — убрать с карты", ".eqpick", dir="right"),
M("Иконки на карте перетаскиваются мышью или пальцем", ".mk.edit", nth=0, dir="down"),
], height=1150),
Shot("d_map_wh", "desktop", "lebedev", [("hash", "#/map/warehouses"), ("wait", 2.0), ("js", "document.querySelector('.zone').click()"), ("wait", 1.5)], [
M("Зоны складов на карте завода", ".zone", nth=0, grow=(0, 0), dir="left"),
M("Нажмите на зону — появится список того, что лежит на складе", ".card", "Открыть склад", dir="up"),
M("«Редактировать карту»: протяните прямоугольник зоны для склада", "button", "Редактировать карту", dir="right"),
], height=1150),
Shot("d_inventory", "desktop", "lebedev", [("hash", "#/inventory/stock"), ("wait", 1.8)], [
M("Вкладки: склады, каталог, выдано сотрудникам, журнал движения", ".tabs", dir="down"),
M("Операции: выдача, возврат, поступление, перемещение, списание", "button", "Выдать", grow=(560, 0), dir="down"),
M("Склад и его содержимое", "#app .card h2", nth=0, dir="right"),
M("Выдать позицию прямо из строки склада", "td button", "Выдать", nth=0, dir="left"),
], height=900),
Shot("d_issue", "desktop", "lebedev", [("hash", "#/inventory/stock"), ("wait", 1.5), ("click", "button", "→ Выдать", 0), ("wait", 0.8)], [
M("Позиция каталога и количество", ".card .grid select", nth=0, grow=(300, 0), dir="up"),
M("Со склада — конкретному сотруднику (лично, не должности)", ".card .grid select", nth=1, grow=(300, 0), dir="up"),
M("«Провести» — остатки и журнал обновятся сразу", "button", "Провести", dir="right"),
], height=900),
Shot("d_requests", "desktop", "lebedev", [("hash", "#/requests"), ("wait", 1.5), ("scroll", "h2", "Все заявки", 0)], [
M("Заявка: что купить, зачем, ссылка на товар", ".card", "Съёмник", dir="right"),
M("Статус заявки и ответ сотруднику", ".card select", nth=0, grow=(0, 0), dir="up"),
M("«Сохранить» — автор получит уведомление", "button", "Сохранить", nth=0, dir="left"),
], height=900),
Shot("d_request_form", "desktop", "ivanov", [("hash", "#/requests"), ("wait", 1.5)], [
M("Что купить (обязательно)", ".card input", nth=0, dir="up"),
M("Ссылка на конкретный товар", ".card input[type=url]", dir="up"),
M("Зачем нужно (обязательно)", ".card textarea", nth=0, dir="right"),
M("Ответ ответственного появится в карточке заявки", ".sheet", nth=0, dir="right"),
], height=1000),
Shot("d_people", "desktop", "sidorov", [("hash", "#/people"), ("wait", 1.5)], [
M("Поиск по ФИО, должности и навыкам", "input[type=search]", dir="down"),
M("Карточка сотрудника: фото, должность, на смене ли, навыки", ".person", nth=1, dir="down"),
], height=700),
Shot("d_user_card", "desktop", "volkov", [("js", "(async()=>{const l=await api('GET','/users'); location.hash='#/user/'+l.find(x=>x.username==='ivanov').id})()"), ("wait", 1.8)], [
M("Фото, ФИО, должность, на смене ли сейчас", ".ucard", dir="down"),
M("График: ближайшие смены", ".card", "График", dir="right"),
M("«Что умею» и телефон — заполняет сам сотрудник", ".card", "Что умею", dir="left"),
M("Текущие задания и наряды", ".card", "Текущие задания", dir="right"),
M("Выданный инструмент (видят сам сотрудник, склад и инженеры)", ".card", "Выданный инструмент", dir="left"),
], height=1000),
Shot("d_exec_report", "desktop", "volkov", [("hash", "#/reports/execution"), ("wait", 2.5), ("click", "tbody tr", "", 0), ("wait", 0.6)], [
M("Итог за период по видам работ", ".stats", dir="left"),
M("По машинам: % выполнения суточных осмотров, чек-листов, ТО, задач и поручений", "tbody tr", nth=0, dir="left"),
M("Отклонения и комментарии: значения вне нормы, возвраты, приостановки", "tbody tr", nth=1, dir="left"),
M("PDF и CSV для печати и Excel", "button", "PDF", grow=(130, 0), dir="right"),
], height=1000),
Shot("d_transfer", "desktop", "kozlov", [("js", "(async()=>{const l=await api('GET','/tasks?limit=500'); const t=l.find(x=>x.title.includes('ремней')); if(!t) throw new Error('нет задания'); location.hash='#/task/'+t.id})()"), ("wait", 1.5), ("scroll", "h2", "Передать задание", 0)], [
M("Сотрудник: начните вводить имя или фамилию — список сузится", ".pick input", dir="up"),
M("Причина передачи обязательна", ".card input", "", nth=1, dir="up"),
M("«Передать» — задание уйдёт другому, в истории появится запись", "button", "Передать", dir="right"),
], height=900),
Shot("d_history", "desktop", "sidorov", [("js", "(async()=>{const l=await api('GET','/equipment'); location.hash='#/equipment/'+l.find(x=>x.name.includes('Н-1')).id})()"), ("wait", 2.5), ("scroll", "h2", "История машины", 0)], [
M("Типы событий: отказы, обслуживание узлов, наработка, наряды, задания", ".hchip", nth=0, grow=(640, 0), dir="up"),
M("Период и поиск по тексту", ".card input[type=date]", nth=0, grow=(560, 0), dir="up"),
M("События по дням: что произошло, кто, ссылка на наряд или задание", ".hrow", nth=0, dir="left"),
], height=1000),
Shot("d_report_form", "desktop", None, [("reset",), ("hash", "report"), ("wait", 1.5),
("js", "(()=>{const [l,e]=document.querySelectorAll('select'); l.value=l.options[1].value; l.dispatchEvent(new Event('change'));})()")], [
M("Место: в списке машин останется только то, что стоит в этом месте", "select", nth=0, dir="left"),
M("Машина", "select", nth=1, dir="left"),
M("Описание и контакты обязательны", "textarea", dir="left"),
M("«Отправить» — техслужба сразу получит уведомление", "button", "Отправить", dir="left"),
], height=900, crop=(260, 0, 760, 900)),
Shot("d_departments", "desktop", "admin", [("hash", "#/departments"), ("wait", 1.5)], [
M("Отдел, вышестоящий отдел, руководитель, число сотрудников", "tbody tr", nth=0, dir="up"),
M("«Сотрудники» — состав отдела", "a.btn", "Сотрудники", dir="left"),
M("«+ Добавить» — новый отдел", "button", "+ Добавить", dir="left"),
], height=600),
Shot("d_setup", "desktop", "admin", [("hash", "#/setup/equipment"), ("wait", 2.5)], [
M("Шаги мастера: ✓ — выполнено, рядом — сколько записей уже есть", ".wsteps", dir="right"),
M("Пакетный ввод: одна строка — одна запись, поля через «;» (можно вставить столбцы из Excel)", ".wbody textarea", dir="up"),
M("«Вставить пример» — образец формата", "button", "Вставить пример", dir="right"),
M("Переход между шагами и отметка «шаг выполнен»", ".wbody .row", "Далее", nth=0, dir="up"),
], height=1000),
Shot("d_backups", "desktop", "admin", [("hash", "#/backups"), ("wait", 1.0), ("click", "button", "Создать копию", 0), ("wait", 2.5)], [
M("Создать копию вручную (с фото — по желанию)", "button", "Создать копию", dir="left"),
M("Автокопии: как часто и сколько хранить", ".card", "Автоматические копии", dir="left"),
M("Скачать, восстановить или удалить копию", "td.row", nth=0, dir="up"),
M("Загрузить файл копии, например с другого сервера", "button", "Загрузить файл копии", dir="left"),
], height=1000),
Shot("d_assistant", "desktop", "ivanov", [("hash", "#/tasks"), ("wait", 1.5), ("js", "(async()=>{const b=document.getElementById('ai-btn'); for(let i=0;i<20&&!b.classList.contains('on');i++){window.assistantRefresh&&window.assistantRefresh(); await new Promise(r=>setTimeout(r,300));} b.click(); const ta=document.querySelector('#ai-form textarea'); for (const q of ['Какие у меня задания?','Как зафиксировать аварию?']) {ta.value=q; document.querySelector('#ai-form button').click(); await new Promise(r=>setTimeout(r,1800));}})()"), ("wait", 4.5)], [
M("Кнопка помощника — в правом нижнем углу на всех экранах", "#ai-btn", dir="left"),
M("Ответ по данным пользователя: его задания (с его правами)", ".ai-m.a", nth=0, dir="left"),
M("Ответ «как сделать» со ссылками на разделы портала", ".ai-m.a", nth=1, dir="left"),
M("Поле вопроса; помощник отвечает только о работе в портале", "#ai-form", dir="left"),
], height=1000),
Shot("d_assistant_settings", "desktop", "admin", [("hash", "#/server"), ("wait", 1.5), ("scroll", "h2", "AI-помощник", 0)], [
M("Провайдер, адрес API, модель и ключ", ".card .grid", "Провайдер", dir="right"),
M("Указания помощнику от администратора (необязательно)", "textarea", nth=0, dir="right"),
M("«Проверить» — тестовый вопрос и проверка фильтра; затем включить и сохранить", "button", "Проверить", grow=(110, 0), dir="up"),
], height=900),
Shot("d_work", "desktop", "volkov", [("hash", "#/work"), ("wait", 3.0)], [
M("Шапка: быстрые действия (задача, наряд, авария, неисправность, заявка) и сводка", ".qtop", dir="right"),
M("Наряды: мои, на проверку, все доступные; просроченные, статус и поиск", ".quad.q-orders .qfilters", dir="left"),
M("Задачи и поручения: мои, поставленные мной, все; день и статус", ".quad.q-tasks .qfilters", dir="right"),
M("Отказы: открытые или все, мои, источник, поиск; список или карта", ".quad.q-inc .qfilters", dir="left"),
M("Сообщения о неисправности: те же фильтры; оборудование работает, но есть проблема", ".quad.q-fault .qfilters", dir="right"),
], height=1250),
Shot("d_fault", "desktop", "kozlov", [("js", "(async()=>{const l=await api('GET','/equipment'); location.hash='#/equipment/'+l.find(x=>x.name.includes('Токарный')).id+'?fault=1'})()"), ("wait", 2.5),
("scroll", "h2", "Сообщить о неисправности", 0)], [
M("Что замечено: оборудование работает, но есть проблема", ".card input", nth=0, dir="right"),
M("Какой узел (если известно); замены здесь нет", "h2", "Какой узел", dir="down"),
M("«Сообщить» — машина получит отметку «Неисправность», инженеры — уведомление", "button.fault", "Сообщить", nth=1, dir="up"),
], height=1000),
Shot("d_work_map", "desktop", "morozova", [("hash", "#/work"), ("wait", 2.5), ("click", "button", "На карте", 0), ("wait", 3.0),
("scroll", ".quad.q-inc", "", 0)], [
M("Переключатель «Список / На карте»", "button", "На карте", dir="up"),
M("Карты: завод и краны (если краны — отдельная служба); в скобках — сколько машин с отказами", ".q-inc .qlist .row", nth=0, dir="right"),
M("Машины с отказами по фильтру; нажатие открывает карточку", ".q-inc .mk", nth=0, dir="right"),
], height=1000),
Shot("d_people_report", "desktop", "volkov", [("hash", "#/reports/people"), ("wait", 2.5)], [
M("Период (обязательно), отдел, должность, сотрудник; быстрый выбор периода", ".card .grid", nth=0, dir="left"),
M("Итоги за период", ".stats", nth=0, dir="right"),
M("Список или календарь", ".tabs", nth=0, dir="right"),
M("По каждому сотруднику: выполнено, в срок, просрочки, возвраты, отклонения, трудоёмкость", "tbody tr", nth=0, dir="left"),
], height=1000),
Shot("d_people_cal", "desktop", "volkov", [("hash", "#/reports/people"), ("wait", 2.5), ("click", ".tabs a", "Календарь", 0), ("wait", 1.5),
("scroll", ".tabs", "", 0)], [
M("Календарь: в клетке — сколько выполнено за день и трудоёмкость, ч; чем темнее, тем больше часов", ".pcal tbody tr", nth=0, dir="down"),
M("Итого за период по сотруднику", ".pcal tbody tr td:last-child", nth=0, dir="left"),
], height=900),
Shot("d_cranes", "desktop", "morozova", [("hash", "#/cranes"), ("wait", 2.0)], [
M("Раздел «Краны» появляется, когда включена отдельная служба кранов", ".navh", "КРАНЫ", grow=(0, 110), dir="right"),
M("Список кранов: отдел, наработка; «Карточка» — состояние, узлы, отказы", "tbody tr", nth=0, dir="down"),
], height=800),
Shot("d_crane_map", "desktop", "orlov", [("hash", "#/map/cranes"), ("wait", 3.0)], [
M("Отдельная карта кранов: своя картинка или план завода", ".tabs a", "Краны", dir="down"),
M("Кран с открытым отказом", ".mk.st-alarm", nth=0, dir="right"),
]),
Shot("d_eq_form", "desktop", "admin", [("hash", "#/equipment"), ("wait", 1.5), ("click", "button", "+ Добавить", 0), ("wait", 1.0)], [
M("Закрепление за отделом: оборудование видят только сотрудники этого отдела и вложенных", "label", "Закреплено за отделом", grow=(300, 40), dir="right"),
M("Отметка «Кран»: машина уходит в раздел «Краны» и на карту кранов", "label", "Кран (грузо", grow=(200, 30), dir="right"),
], height=1000),
Shot("d_demo", "desktop", "admin", [("hash", "#/demo"), ("wait", 2.0)], [
M("Войти в демо самому: остальные продолжают работать с рабочими данными", "button", "Войти в демо", dir="right"),
M("Кто сейчас в демо: администратор может включить или выключить демо любому сотруднику", "table", nth=0, dir="up"),
], height=900),
Shot("d_failure_close", "desktop", "volkov", [("js", "(async()=>{const l=await api('GET','/equipment'); location.hash='#/equipment/'+l.find(x=>x.name.includes('Н-2')).id})()"), ("wait", 2.5),
("click", "button", "Закрыть отказ", 0), ("wait", 2.0), ("click", ".failform .chipf", "Замена узла", 0),
("click", ".failform .chipf", "Постепенный", 0), ("scroll", ".failform", "", 0)], [
M("Что сделано и характер отказа — два нажатия", ".failform .chipsel", nth=0, grow=(0, 70), dir="left"),
M("Время, наработка и причина уже подставлены — поправьте при необходимости", ".faildet summary", dir="left"),
M("Узел-причина и причина отказа", ".faildet select", nth=0, dir="left"),
M("Заменённые при ремонте узлы — ресурс сбросится", ".faildet label.chk", nth=0, dir="left"),
], height=1300),
Shot("d_reliability", "desktop", "morozova", [("hash", "#/reliability"), ("wait", 3.0), ("scroll", ".stats", "", 0)], [
M("Показатели по парку: отказы, наработка на отказ, её граница при доверии 90 %, восстановление, простой", ".stats", dir="down"),
M("По машинам: θ, λ, восстановление, готовность, прогноз; нажатие открывает карточку машины", ".reltable thead", dir="up"),
], height=1100),
Shot("d_reliability_bars", "desktop", "morozova", [("hash", "#/reliability"), ("wait", 3.0), ("scroll", ".relgrid", "", 0)], [
M("Причины отказов: где искать слабые места", ".relgrid .card", nth=0, dir="right"),
M("Были ли признаки заранее — отказы, которые можно было предотвратить", ".relgrid .card", nth=3, dir="left"),
], height=1000),
Shot("d_drift", "desktop", "morozova", [("hash", "#/reliability/drift"), ("wait", 3.0)], [
M("Сколько параметров приближаются к границе нормы", "h2", "Параметры под наблюдением", dir="down"),
M("Тренд, направление, дней до границы и выходы за норму", ".reltable thead", dir="up"),
], height=900),
Shot("d_server", "desktop", "admin", [("hash", "#/server"), ("wait", 1.5), ("js", "document.querySelector('.grid.form input').value='/data/photos'")], [
M("Каталог для фото и максимальный размер снимка", ".grid.form", nth=0, dir="right"),
M("Состояние: сколько фото и сколько свободно места", ".card", nth=1, dir="right"),
]),
]
MOBILE = [
Shot("m_login", "mobile", None, [("reset",), ("hash", "")], [
M("Логин и пароль", "input", nth=0, grow=(0, 62), dir="up"),
M("«Войти»", "button", "Войти"),
]),
Shot("m_list", "mobile", "sidorov", [("m_login", "sidorov", "demo1234"), ("wait", 1.5)], [
M("Название экрана и время последней синхронизации", "#bar h1", dir="down"),
M("Кнопка меню: настройки и разделы портала", "#burger", dir="down"),
M("Принудительное обновление", "#bar .ib", nth=0, dir="down"),
M("Поиск по названию, оборудованию или номеру", "input[type=search]", dir="down"),
M("Фильтры: активные, мои, завершённые", ".filters", dir="down"),
M("Карточка наряда: статус, срок, прогресс. «▶ можно выполнять» — наряд ваш", ".card.tap", nth=0, dir="up"),
]),
Shot("m_menu", "mobile", "sidorov", [("m_login", "sidorov", "demo1234"), ("js", "document.getElementById('burger').click()"), ("wait", 0.8)], [
M("Кто вошёл и состояние синхронизации", "#drawer .dhead", dir="down"),
M("Без связи работают наряды и настройки", "#drawer .navh", "БЕЗ СВЯЗИ", grow=(0, 100), dir="down"),
M("Разделы портала открываются в полной версии сайта (нужна связь)", "#drawer .navh", "ПОРТАЛ", grow=(0, 380), dir="up"),
], height=844),
Shot("m_order", "mobile", "sidorov", [("m_login", "sidorov", "demo1234"), ("js", m_order_js("Проверка насосной", "in_progress")), ("wait", 2.0), ("scroll", "h2", "Чек-лист", 0)], [
M("Число: введите значение; норма указана под полем", "input[inputmode=decimal]", nth=0, grow=(0, 28), dir="up"),
M("Выбор варианта — одним нажатием", ".item .chips", nth=0, dir="up"),
M("Фото: «Снять» откроет камеру, значок справа — выбор из галереи", ".photos", nth=0, dir="down"),
], height=844),
Shot("m_submit", "mobile", "sidorov", [("m_login", "sidorov", "demo1234"), ("js", m_order_js("ТО компрессора", "new")), ("wait", 2.0), ("scroll", "h2", "Чек-лист", 0)], [
M("Статус пункта: «Не начато», «В работе», «Выполнено»", ".item .chips", nth=1, dir="up"),
M("«Взять в работу» закрепляет наряд за вами. «Сдать наряд» отправляет его на проверку", ".actions", nth=0, dir="up"),
], height=844),
Shot("m_offline", "mobile", "sidorov", [("m_login", "sidorov", "demo1234"), ("js", m_order_js("ТО компрессора", "new")), ("wait", 2.0), ("offline", True),
("js", "document.querySelectorAll('.item')[3].querySelectorAll('.chip')[2].click()"), ("wait", 1.5),
("scroll", ".item", "Слив конденсата", 0)], [
M("Ответ записан на телефоне. Он уйдёт на сервер сам, как только появится сеть", ".item .chip.on", "Выполнено", dir="right"),
M("Кнопка «Сдать наряд» тоже работает без сети: наряд будет сдан позже", ".actions", dir="up"),
], height=844),
Shot("m_review", "mobile", "morozova", [("m_login", "morozova", "demo1234"), ("js", m_order_js("ТО компрессора", "review")), ("wait", 2.0), ("scroll", "h2", "Проверка", 0)], [
M("Комментарий проверяющего (обязателен при возврате)", "textarea[placeholder*='обязателен']", dir="up"),
M("«Принять» или «Вернуть» на доработку", ".card .row button", "Принять", grow=(190, 0), dir="up"),
], height=844),
Shot("m_settings", "mobile", "sidorov", [("m_login", "sidorov", "demo1234"), ("hash", "#/s"), ("wait", 1.5)], [
M("Синхронизировать сейчас", "button", "Синхронизировать", dir="up"),
M("Уведомления о новых нарядах", "button", "Включить", dir="up"),
M("Выход из аккаунта", "button", "Выйти", dir="up"),
], height=844),
]