Add Dispatch_V0.1.1
This commit is contained in:
9
Dispatch_V0.1.1/ui/cards/__init__.py
Normal file
9
Dispatch_V0.1.1/ui/cards/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/ui/cards/__init__.py
|
||||
|
||||
"""Карточки задач Ticket."""
|
||||
|
||||
from .task_card import TaskCard
|
||||
from .task_card_view import TaskCardView
|
||||
|
||||
__all__ = ["TaskCard", "TaskCardView"]
|
||||
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/task_card.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/task_card.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
Dispatch_V0.1.1/ui/cards/task_card.py
Normal file
11
Dispatch_V0.1.1/ui/cards/task_card.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/ui/cards/task_card.py
|
||||
|
||||
"""Тонкий entry-point карточки Ticket."""
|
||||
|
||||
from .task_card_view import TaskCardView
|
||||
|
||||
|
||||
class TaskCard(TaskCardView):
|
||||
"""Runtime-entry карточки задачи на доске Ticket."""
|
||||
|
||||
129
Dispatch_V0.1.1/ui/cards/task_card_pixmap_factory.py
Normal file
129
Dispatch_V0.1.1/ui/cards/task_card_pixmap_factory.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/ui/cards/task_card_pixmap_factory.py
|
||||
|
||||
"""Pixmap-хелперы для карточки Ticket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPixmap
|
||||
|
||||
|
||||
def compute_square_size(width: int, height: int, padding: int = 0) -> int:
|
||||
"""Вернуть квадратный размер по меньшей стороне с учётом внутреннего отступа."""
|
||||
available = min(width, height) - padding
|
||||
return max(0, available)
|
||||
|
||||
|
||||
def build_avatar_pixmap(source: QPixmap, size: int) -> QPixmap:
|
||||
"""Построить круглый avatar-pixmap из исходной фотографии."""
|
||||
avatar = QPixmap(size, size)
|
||||
avatar.fill(Qt.GlobalColor.transparent)
|
||||
|
||||
painter = QPainter(avatar)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
|
||||
outer_padding = 1
|
||||
outer_size = size - (outer_padding * 2)
|
||||
outer_path = QPainterPath()
|
||||
outer_path.addEllipse(outer_padding, outer_padding, outer_size, outer_size)
|
||||
painter.fillPath(outer_path, QColor("#A3A3A3"))
|
||||
|
||||
inner_padding = outer_padding + 2
|
||||
inner_size = size - (inner_padding * 2)
|
||||
inner_path = QPainterPath()
|
||||
inner_path.addEllipse(inner_padding, inner_padding, inner_size, inner_size)
|
||||
painter.setClipPath(inner_path)
|
||||
|
||||
scaled = source.scaled(
|
||||
inner_size,
|
||||
inner_size,
|
||||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
source_x = max(0, (scaled.width() - inner_size) // 2)
|
||||
source_y = max(0, (scaled.height() - inner_size) // 2)
|
||||
painter.drawPixmap(inner_padding, inner_padding, scaled, source_x, source_y, inner_size, inner_size)
|
||||
painter.end()
|
||||
|
||||
return avatar
|
||||
|
||||
|
||||
def build_placeholder_avatar_pixmap(size: int) -> QPixmap:
|
||||
"""Построить круглый placeholder-аватар с вопросительным знаком."""
|
||||
avatar = QPixmap(size, size)
|
||||
avatar.fill(Qt.GlobalColor.transparent)
|
||||
|
||||
painter = QPainter(avatar)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
|
||||
outer_padding = 1
|
||||
circle_size = size - (outer_padding * 2)
|
||||
circle_path = QPainterPath()
|
||||
circle_path.addEllipse(outer_padding, outer_padding, circle_size, circle_size)
|
||||
painter.fillPath(circle_path, QColor("#F6A493"))
|
||||
painter.setPen(QColor("#FFFFFF"))
|
||||
painter.drawPath(circle_path)
|
||||
|
||||
font = QFont()
|
||||
font.setPixelSize(18)
|
||||
font.setBold(True)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#172B4D"))
|
||||
painter.drawText(outer_padding, outer_padding, circle_size, circle_size, Qt.AlignmentFlag.AlignCenter, "?")
|
||||
painter.end()
|
||||
|
||||
return avatar
|
||||
|
||||
|
||||
def load_scaled_icon_pixmap(image_path: str, width: int, height: int, padding: int = 0) -> QPixmap | None:
|
||||
"""Загрузить и отмасштабировать stage-icon по фактическому размеру ячейки."""
|
||||
if not image_path:
|
||||
return None
|
||||
pixmap = QPixmap(image_path)
|
||||
if pixmap.isNull():
|
||||
return None
|
||||
icon_size = compute_square_size(width, height, padding)
|
||||
if icon_size <= 0:
|
||||
return None
|
||||
return pixmap.scaled(
|
||||
icon_size,
|
||||
icon_size,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
|
||||
|
||||
def load_tinted_icon_pixmap(
|
||||
image_path: str,
|
||||
width: int,
|
||||
height: int,
|
||||
color_hex: str,
|
||||
padding: int = 0,
|
||||
) -> QPixmap | None:
|
||||
"""Загрузить stage-icon и перекрасить его в нужный цвет."""
|
||||
scaled = load_scaled_icon_pixmap(image_path, width, height, padding)
|
||||
if scaled is None:
|
||||
return None
|
||||
tinted = QPixmap(scaled.size())
|
||||
tinted.fill(Qt.GlobalColor.transparent)
|
||||
|
||||
painter = QPainter(tinted)
|
||||
painter.drawPixmap(0, 0, scaled)
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
|
||||
painter.fillRect(tinted.rect(), QColor(color_hex))
|
||||
painter.end()
|
||||
return tinted
|
||||
|
||||
|
||||
def load_avatar_pixmap(image_path: str, width: int, height: int, padding: int = 0) -> QPixmap | None:
|
||||
"""Загрузить фото специалиста и подготовить круглый avatar-pixmap."""
|
||||
if not image_path:
|
||||
return None
|
||||
pixmap = QPixmap(image_path)
|
||||
if pixmap.isNull():
|
||||
return None
|
||||
avatar_size = compute_square_size(width, height, padding)
|
||||
if avatar_size <= 0:
|
||||
return None
|
||||
return build_avatar_pixmap(pixmap, avatar_size)
|
||||
209
Dispatch_V0.1.1/ui/cards/task_card_view.py
Normal file
209
Dispatch_V0.1.1/ui/cards/task_card_view.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/ui/cards/task_card_view.py
|
||||
|
||||
"""Ticket task card view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import QSizePolicy
|
||||
|
||||
from gui.components import Label
|
||||
from gui.containers import SContainer, VContainer
|
||||
|
||||
from domain import TicketTaskSnapshot
|
||||
from domain.ticket_constants import (
|
||||
STATE_COMPLETED,
|
||||
STATE_CONFIRMATION,
|
||||
STATE_IN_PROGRESS,
|
||||
STATE_REFUSED,
|
||||
STATE_TODO,
|
||||
)
|
||||
from ui.task_view_formatters import (
|
||||
build_specialist_card_text,
|
||||
build_specialist_photo_path,
|
||||
build_task_fault_title,
|
||||
)
|
||||
from .task_card_pixmap_factory import (
|
||||
build_placeholder_avatar_pixmap,
|
||||
compute_square_size,
|
||||
load_avatar_pixmap,
|
||||
)
|
||||
|
||||
|
||||
class TaskCardView(SContainer):
|
||||
"""Compact Ticket card without action logic."""
|
||||
|
||||
card_clicked = Signal(object)
|
||||
card_height_changed = Signal()
|
||||
|
||||
_ROOT_STYLE_BY_STATE = {
|
||||
STATE_TODO: "TICKET_TASK_CARD_ROOT_TODO",
|
||||
STATE_IN_PROGRESS: "TICKET_TASK_CARD_ROOT_IN_PROGRESS",
|
||||
STATE_CONFIRMATION: "TICKET_TASK_CARD_ROOT_CONFIRMATION",
|
||||
STATE_COMPLETED: "TICKET_TASK_CARD_ROOT_COMPLETED",
|
||||
STATE_REFUSED: "TICKET_TASK_CARD_ROOT_REFUSED",
|
||||
}
|
||||
_DARK_TEXT_STATES = {STATE_TODO, STATE_CONFIRMATION, STATE_REFUSED}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task: TicketTaskSnapshot,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(
|
||||
width_percent=100,
|
||||
margin=0,
|
||||
content_fit=True,
|
||||
parent=parent,
|
||||
)
|
||||
self._card_id = task.task_id
|
||||
self._fault_title_label: Label | None = None
|
||||
self._avatar_label: Label | None = None
|
||||
self._current_task: TicketTaskSnapshot | None = None
|
||||
self._height_sync_in_progress = False
|
||||
self._pixmap_refresh_pending = False
|
||||
self._setup_ui()
|
||||
self.update_task(task)
|
||||
|
||||
@property
|
||||
def card_id(self) -> object:
|
||||
return self._card_id
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
self.setObjectName("ticket_task_card")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
# Content-shell: горизонтальное разделение текстовой зоны и аватара.
|
||||
content = SContainer(
|
||||
width_percent=100,
|
||||
height_percent=100,
|
||||
orientation="h",
|
||||
margin=8,
|
||||
spacing=0,
|
||||
style="TICKET_TASK_CARD_CONTENT",
|
||||
parent=self,
|
||||
)
|
||||
|
||||
# Text-column: левая вертикальная зона с единственным текстовым полем заголовка.
|
||||
text_column = VContainer(
|
||||
width_percent=70,
|
||||
spacing=0,
|
||||
parent=content,
|
||||
)
|
||||
|
||||
# Единственное текстовое поле карточки: краткий заголовок неисправности.
|
||||
self._fault_title_label = Label(
|
||||
"",
|
||||
alignment="left",
|
||||
height_percent=100,
|
||||
parent=text_column,
|
||||
)
|
||||
|
||||
# Avatar-column: правая зона для фото или плейсхолдера специалиста.
|
||||
avatar_column = VContainer(
|
||||
width_percent=26,
|
||||
spacing=0,
|
||||
parent=content,
|
||||
)
|
||||
self._avatar_label = Label(
|
||||
"",
|
||||
alignment="center",
|
||||
width_percent=100,
|
||||
height_percent=100,
|
||||
style="TICKET_TASK_CARD_AVATAR_IMAGE",
|
||||
parent=avatar_column,
|
||||
)
|
||||
|
||||
def update_task(self, task: TicketTaskSnapshot) -> None:
|
||||
self._card_id = task.task_id
|
||||
self._current_task = task
|
||||
|
||||
if self._fault_title_label is not None:
|
||||
self._fault_title_label.set_text(build_task_fault_title(task))
|
||||
self._fault_title_label.set_tooltip(task.location or "")
|
||||
|
||||
self._apply_state_styles(task)
|
||||
self._update_avatar(task)
|
||||
self._sync_card_height()
|
||||
self._schedule_pixmap_refresh()
|
||||
|
||||
def _apply_state_styles(self, task: TicketTaskSnapshot) -> None:
|
||||
style_suffix = "DARK" if task.state_code in self._DARK_TEXT_STATES else "LIGHT"
|
||||
self.style(self._ROOT_STYLE_BY_STATE.get(task.state_code, "TICKET_TASK_CARD_ROOT_TODO"))
|
||||
if self._fault_title_label is not None:
|
||||
self._fault_title_label.style(f"TICKET_TASK_CARD_TITLE_{style_suffix}")
|
||||
|
||||
def _update_avatar(self, task: TicketTaskSnapshot) -> None:
|
||||
if self._avatar_label is None:
|
||||
return
|
||||
avatar_path = build_specialist_photo_path(task.assigned_specialist, task.specialist_photo)
|
||||
self._avatar_label.set_tooltip(task.assigned_specialist or "Специалист не назначен")
|
||||
pixmap = load_avatar_pixmap(avatar_path, self._avatar_label.width(), self._avatar_label.height(), padding=4)
|
||||
if pixmap is not None:
|
||||
self._avatar_label.set_text("")
|
||||
self._avatar_label.set_pixmap(pixmap)
|
||||
return
|
||||
self._avatar_label.set_text("")
|
||||
placeholder_size = self._label_square_size(self._avatar_label)
|
||||
if placeholder_size > 0:
|
||||
self._avatar_label.set_pixmap(build_placeholder_avatar_pixmap(placeholder_size))
|
||||
|
||||
@staticmethod
|
||||
def _label_square_size(label: Label, padding: int = 0) -> int:
|
||||
return compute_square_size(label.width(), label.height(), padding)
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._sync_card_height()
|
||||
if self._current_task is not None:
|
||||
self._update_avatar(self._current_task)
|
||||
self._schedule_pixmap_refresh()
|
||||
|
||||
def _schedule_pixmap_refresh(self) -> None:
|
||||
"""Запланировать отложенный рендер pixmap-контента.
|
||||
|
||||
Процентные размеры дочерних Label вычисляются через
|
||||
QTimer.singleShot(0) в PercentSizedWidget, поэтому при первом
|
||||
показе карточки (или при динамической вставке из COM-сигнала)
|
||||
label.width()/height() ещё равны 0 в момент resizeEvent.
|
||||
Отложенный вызов гарантирует, что к моменту рендера layout
|
||||
дочерних виджетов уже завершён.
|
||||
"""
|
||||
if self._pixmap_refresh_pending:
|
||||
return
|
||||
self._pixmap_refresh_pending = True
|
||||
QTimer.singleShot(0, self._deferred_pixmap_refresh)
|
||||
|
||||
def _deferred_pixmap_refresh(self) -> None:
|
||||
self._pixmap_refresh_pending = False
|
||||
if self._current_task is not None:
|
||||
self._update_avatar(self._current_task)
|
||||
|
||||
def _sync_card_height(self) -> None:
|
||||
if self._height_sync_in_progress or self.width() <= 0:
|
||||
return
|
||||
target_height = max(1, round(self.width() / 2.745))
|
||||
if self.height() == target_height:
|
||||
return
|
||||
self._height_sync_in_progress = True
|
||||
self.setFixedHeight(target_height)
|
||||
self._height_sync_in_progress = False
|
||||
self.card_height_changed.emit()
|
||||
|
||||
def _on_parent_rebuild_finished(self) -> None:
|
||||
# Фаза 2 каскада percent-sized: родительская колонка завершила
|
||||
# перестроение, ширина карточки уже стабильная. Только теперь
|
||||
# пересчитываем собственную высоту по соотношению width/2.745
|
||||
# и обновляем pixmap-контент.
|
||||
super()._on_parent_rebuild_finished()
|
||||
self._sync_card_height()
|
||||
if self._current_task is not None:
|
||||
self._schedule_pixmap_refresh()
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.card_clicked.emit(self._card_id)
|
||||
super().mousePressEvent(event)
|
||||
Reference in New Issue
Block a user