Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -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).
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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:])))
|
||||
@@ -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),
|
||||
]
|
||||
Reference in New Issue
Block a user