DataFlowing / collector /ui /task_create_dialog.py
stouchinglhh
collector: 更新采集工程至 v1.3.17(时间轴修正 + 采集端自建任务)
ddb63ef
Raw History Blame Contribute Delete
17.5 kB
"""本机任务对话框 —— 任务页「+ 新建任务」/「✏ 改名」调用。
设计约定:
* 只负责输入与实时校验,落盘由调用方经 ``core.task_record`` 完成
(``create_local_task`` / ``rename_local_task``),错误码统一由本模块
的 ``task_error_text`` 本地化。
* 名称规则与 ``core.task_record.validate_task_name`` 完全一致:只允许
字母/数字/汉字/下划线/连字符,去首尾空白后非空、≤ 64 字符、不重名。
这样任务名 == 录制目录名 == 上传会话/项目名,不会出现三者不一致。
* 本机任务仅创建者可见(身份在任务页创建时传入),对话框里如实提示。
* 改名不迁移录制目录:任务已有本地目录时禁止改名(避免旧数据失去归属、
上传队列路径脱节),改名对话框会把原因写清楚。
"""
from __future__ import annotations
import os
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIntValidator
from PyQt5.QtWidgets import (
QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit,
QPushButton, QVBoxLayout,
)
from config import settings
from config.i18n import tr
from core import task_record
from core.helpers import list_task_episodes, task_dir_of
_LINE_EDIT_STYLE = (
f"QLineEdit {{ background:{settings.COLOR_BG_WIDGET}; "
f"color:{settings.COLOR_TEXT_PRIMARY}; "
f"border:1px solid {settings.COLOR_BORDER}; "
f"border-radius:4px; padding:6px 10px; font-size:13px; }}"
f"QLineEdit:focus {{ border-color:{settings.COLOR_BTN_START}; }}"
f"QLineEdit:disabled {{ color:{settings.COLOR_BTN_DISABLED_TEXT}; }}"
)
_PLAIN_STYLE = (
f"QPlainTextEdit {{ background:{settings.COLOR_BG_WIDGET}; "
f"color:{settings.COLOR_TEXT_PRIMARY}; "
f"border:1px solid {settings.COLOR_BORDER}; "
f"border-radius:4px; padding:4px 8px; font-size:12px; }}"
f"QPlainTextEdit:focus {{ border-color:{settings.COLOR_BTN_START}; }}"
)
_LABEL_STYLE = (
f"color:{settings.COLOR_TEXT_SECONDARY}; font-size:12px; "
f"background:transparent; border:none;"
)
_HINT_STYLE = (
f"color:{settings.COLOR_TEXT_HINT}; font-size:11px; "
f"background:transparent; border:none;"
)
_ERROR_STYLE = (
f"color:{settings.COLOR_ABNORMAL}; font-size:12px; "
f"background:transparent; border:none;"
)
# 错误码 → 界面文案(源语言中文,config.i18n 提供英文)
_ERROR_TEXT = {
"name_empty": "任务名称不能为空",
"name_too_long": "任务名称最多 {} 个字符",
"name_illegal": "任务名称只能包含字母、数字、汉字、下划线和连字符",
"name_reserved": "任务名称不可用(系统保留名)",
"name_duplicate": "已存在同名任务(可能属于其他账号或服务端)",
"name_dir_exists": "该名称的录制目录已存在",
"has_data": "该任务在本机已有录制目录({} 段);改名不迁移数据,请先上传并删除本地数据",
"not_local": "只能修改本机任务",
"task_not_found": "任务不存在",
"total_invalid": "目标段数需在 1~{} 之间",
"description_too_long": "任务描述最多 {} 个字符",
}
def task_error_text(code: str, *args) -> str:
"""错误码 → 当前语言文案;未知错误码回退为「操作失败:code」。"""
text = _ERROR_TEXT.get(code)
if text is None:
return tr("操作失败:{}", code)
return tr(text, *args)
def _identity_display(identity: str | None) -> str:
return tr("游客") if not identity or identity == "guest" else str(identity)
def _label(text: str) -> QLabel:
lb = QLabel(text)
lb.setStyleSheet(_LABEL_STYLE)
return lb
def _cancel_button(text: str) -> QPushButton:
btn = QPushButton(text)
btn.setMinimumHeight(34)
btn.setCursor(Qt.PointingHandCursor)
btn.setStyleSheet(
f"QPushButton {{ background:{settings.COLOR_BTN_DEFAULT_BG}; "
f"color:{settings.COLOR_TEXT_PRIMARY}; "
f"border:1px solid {settings.COLOR_BORDER}; border-radius:5px; "
f"padding:6px 20px; font-size:13px; }}"
f"QPushButton:hover {{ background:{settings.COLOR_BTN_HOVER}; }}"
)
return btn
def _ok_button(text: str) -> QPushButton:
btn = QPushButton(text)
btn.setMinimumHeight(34)
btn.setCursor(Qt.PointingHandCursor)
btn.setStyleSheet(
f"QPushButton {{ background:{settings.COLOR_BTN_START}; "
f"color:white; font-weight:bold; font-size:13px; "
f"border:none; border-radius:5px; padding:6px 24px; }}"
f"QPushButton:hover {{ background:#388E3C; }}"
f"QPushButton:disabled {{ background:{settings.COLOR_BTN_DISABLED_BG}; "
f"color:{settings.COLOR_BTN_DISABLED_TEXT}; }}"
)
return btn
class TaskCreateDialog(QDialog):
"""新建本机任务:任务名称 + 目标段数 + 描述。exec_() 后经访问器取值。"""
def __init__(self, parent=None, identity: str | None = "guest"):
super().__init__(parent)
self._identity = identity or "guest"
self._ok_btn: QPushButton | None = None
self.setWindowTitle(tr("新建采集任务"))
self.setWindowFlag(Qt.WindowContextHelpButtonHint, False)
self.setModal(True)
self.setMinimumWidth(520)
layout = QVBoxLayout(self)
layout.setContentsMargins(28, 22, 28, 18)
layout.setSpacing(10)
title = QLabel(tr("新建采集任务"))
title.setStyleSheet(
f"font-size:18px; font-weight:bold; "
f"color:{settings.COLOR_TEXT_PRIMARY}; background:transparent;")
layout.addWidget(title)
subtitle = QLabel(tr(
"任务只保存在本机,仅当前身份({})可见;上传时按任务名匹配或"
"自动创建服务器项目。", _identity_display(self._identity)))
subtitle.setWordWrap(True)
subtitle.setStyleSheet(_LABEL_STYLE)
layout.addWidget(subtitle)
form = QGridLayout()
form.setVerticalSpacing(8)
form.setHorizontalSpacing(10)
self._name_edit = QLineEdit()
self._name_edit.setPlaceholderText(tr("例如 grasp_cylinder_01"))
self._name_edit.setMaxLength(task_record.MAX_TASK_NAME_LEN)
self._name_edit.setStyleSheet(_LINE_EDIT_STYLE)
self._name_edit.textChanged.connect(self._refresh_state)
self._dir_label = QLabel("")
self._dir_label.setWordWrap(True)
self._dir_label.setStyleSheet(_HINT_STYLE)
self._total_edit = QLineEdit("1")
self._total_edit.setValidator(
QIntValidator(1, task_record.MAX_TOTAL_REQUIRED, self))
self._total_edit.setFixedWidth(110)
self._total_edit.setStyleSheet(_LINE_EDIT_STYLE)
self._total_edit.textChanged.connect(self._refresh_state)
self._total_edit.returnPressed.connect(self.accept)
total_row = QHBoxLayout()
total_row.setContentsMargins(0, 0, 0, 0)
total_row.setSpacing(8)
total_row.addWidget(self._total_edit)
unit = QLabel(tr("段"))
unit.setStyleSheet(_LABEL_STYLE)
total_row.addWidget(unit)
total_row.addStretch(1)
self._desc_edit = QPlainTextEdit()
self._desc_edit.setPlaceholderText(tr("可选:任务说明,录制时悬停任务名显示"))
self._desc_edit.setFixedHeight(64)
self._desc_edit.setStyleSheet(_PLAIN_STYLE)
self._desc_edit.textChanged.connect(self._refresh_state)
form.addWidget(_label(tr("任务名称:")), 0, 0)
form.addWidget(self._name_edit, 0, 1)
form.addWidget(self._dir_label, 1, 1)
form.addWidget(_label(tr("目标段数:")), 2, 0)
form.addLayout(total_row, 2, 1)
form.addWidget(_label(tr("任务描述:")), 3, 0, Qt.AlignTop)
form.addWidget(self._desc_edit, 3, 1)
layout.addLayout(form)
self._error_label = QLabel("")
self._error_label.setWordWrap(True)
self._error_label.setStyleSheet(_ERROR_STYLE)
self._error_label.setVisible(False)
layout.addWidget(self._error_label)
buttons = QHBoxLayout()
buttons.addStretch(1)
self._cancel_btn = _cancel_button(tr("取消"))
self._cancel_btn.clicked.connect(self.reject)
buttons.addWidget(self._cancel_btn)
self._ok_btn = _ok_button(tr("创建任务"))
self._ok_btn.clicked.connect(self.accept)
buttons.addWidget(self._ok_btn)
layout.addLayout(buttons)
self._name_edit.returnPressed.connect(self.accept)
self._refresh_state()
# ── 访问器 ────────────────────────────────────────
def name(self) -> str:
return self._name_edit.text().strip()
def total_required(self) -> int:
text = self._total_edit.text().strip()
return int(text) if text.isdigit() else 0
def description(self) -> str:
return self._desc_edit.toPlainText().strip()
# ── 校验 ──────────────────────────────────────────
def _validate(self) -> str:
"""返回错误码;空串 = 通过。"""
ok, code = task_record.validate_task_name(self.name())
if not ok:
return code
if not 1 <= self.total_required() <= task_record.MAX_TOTAL_REQUIRED:
return "total_invalid"
if len(self.description()) > task_record.MAX_TASK_DESCRIPTION_LEN:
return "description_too_long"
return ""
def _refresh_state(self):
name = self.name()
code = self._validate()
# 名字里含非法字符/超长时不要显示「清洗后」的目录预览(会误导用户
# 以为能建成);重名仍显示路径,用户能看到撞的是哪个目录。
preview_ok = bool(name) and code not in (
"name_too_long", "name_illegal", "name_reserved")
self._dir_label.setText(
tr("本机目录: {}", task_dir_of(settings.RECORDING_DIR, name))
if preview_ok else "")
if code:
self._error_label.setText(self._error_text(code))
self._error_label.setVisible(True)
if self._ok_btn is not None:
self._ok_btn.setEnabled(False)
return
# 名称合法但目录已存在(无任务记录的孤儿目录):允许创建,但要说明
warning = ""
if name:
task_dir = task_dir_of(settings.RECORDING_DIR, name)
if os.path.isdir(task_dir):
try:
n_eps = len(list_task_episodes(task_dir))
except OSError:
n_eps = 0
warning = tr(
"该名称的录制目录已存在({} 段)。新任务进度从 0 开始,"
"录制会追加到同一目录。", n_eps)
self._error_label.setText(warning)
self._error_label.setStyleSheet(
_HINT_STYLE if warning else _ERROR_STYLE)
self._error_label.setVisible(bool(warning))
if self._ok_btn is not None:
self._ok_btn.setEnabled(True)
@staticmethod
def _error_text(code: str) -> str:
if code == "total_invalid":
return task_error_text(code, task_record.MAX_TOTAL_REQUIRED)
if code == "name_too_long":
return task_error_text(code, task_record.MAX_TASK_NAME_LEN)
if code == "description_too_long":
return task_error_text(code, task_record.MAX_TASK_DESCRIPTION_LEN)
return task_error_text(code)
def accept(self):
code = self._validate()
if code:
self._error_label.setText(self._error_text(code))
self._error_label.setStyleSheet(_ERROR_STYLE)
self._error_label.setVisible(True)
return
super().accept()
class TaskRenameDialog(QDialog):
"""本机任务改名(不迁移录制目录;已有本地目录时禁止)。"""
def __init__(self, parent=None, task: dict | None = None):
super().__init__(parent)
self._task = dict(task or {})
self._task_id = str(self._task.get("id") or self._task.get("task_id") or "")
self._old_name = str(self._task.get("name") or "")
self._ok_btn: QPushButton | None = None
self.setWindowTitle(tr("任务改名"))
self.setWindowFlag(Qt.WindowContextHelpButtonHint, False)
self.setModal(True)
self.setMinimumWidth(500)
layout = QVBoxLayout(self)
layout.setContentsMargins(28, 22, 28, 18)
layout.setSpacing(10)
title = QLabel(tr("任务改名"))
title.setStyleSheet(
f"font-size:18px; font-weight:bold; "
f"color:{settings.COLOR_TEXT_PRIMARY}; background:transparent;")
layout.addWidget(title)
current = QLabel(tr("当前名称:{}", self._old_name))
current.setWordWrap(True)
current.setStyleSheet(_LABEL_STYLE)
layout.addWidget(current)
form = QGridLayout()
form.setVerticalSpacing(8)
form.setHorizontalSpacing(10)
self._name_edit = QLineEdit(self._old_name)
self._name_edit.setMaxLength(task_record.MAX_TASK_NAME_LEN)
self._name_edit.setStyleSheet(_LINE_EDIT_STYLE)
self._name_edit.selectAll()
self._name_edit.textChanged.connect(self._refresh_state)
self._name_edit.returnPressed.connect(self.accept)
self._dir_label = QLabel("")
self._dir_label.setWordWrap(True)
self._dir_label.setStyleSheet(_HINT_STYLE)
form.addWidget(_label(tr("新名称:")), 0, 0)
form.addWidget(self._name_edit, 0, 1)
form.addWidget(self._dir_label, 1, 1)
layout.addLayout(form)
# 已有录制目录 → 禁止改名(改名不迁移数据)
self._has_data = False
old_dir = task_dir_of(settings.RECORDING_DIR, self._old_name)
if self._old_name and os.path.isdir(old_dir):
self._has_data = True
try:
n_eps = len(list_task_episodes(old_dir))
except OSError:
n_eps = 0
self._data_note = task_error_text("has_data", n_eps)
else:
self._data_note = ""
self._error_label = QLabel("")
self._error_label.setWordWrap(True)
self._error_label.setStyleSheet(_ERROR_STYLE)
layout.addWidget(self._error_label)
buttons = QHBoxLayout()
buttons.addStretch(1)
cancel = _cancel_button(tr("取消"))
cancel.clicked.connect(self.reject)
buttons.addWidget(cancel)
self._ok_btn = _ok_button(tr("确定改名"))
self._ok_btn.clicked.connect(self.accept)
buttons.addWidget(self._ok_btn)
layout.addLayout(buttons)
self._refresh_state()
def new_name(self) -> str:
return self._name_edit.text().strip()
def _refresh_state(self):
name = self.new_name()
code = ""
if self._has_data:
code = "has_data"
elif name == self._old_name:
code = "name_unchanged"
else:
ok, err = task_record.validate_task_name(
name, exclude_id=self._task_id or None)
if not ok:
code = err
elif os.path.isdir(task_dir_of(settings.RECORDING_DIR, name)):
code = "name_dir_exists"
preview_ok = (name and name != self._old_name
and code not in ("has_data", "name_too_long",
"name_illegal", "name_reserved"))
self._dir_label.setText(
tr("本机目录: {}", task_dir_of(settings.RECORDING_DIR, name))
if preview_ok else "")
if code == "has_data":
text = self._data_note
elif code == "name_unchanged":
text = tr("新名称与当前名称相同")
elif code:
text = self._error_text(code)
else:
text = ""
self._error_label.setText(text)
self._error_label.setStyleSheet(
_HINT_STYLE if code == "name_unchanged" else _ERROR_STYLE)
self._error_label.setVisible(bool(text))
if self._ok_btn is not None:
self._ok_btn.setEnabled(not code or code == "name_unchanged")
@staticmethod
def _error_text(code: str) -> str:
if code == "name_too_long":
return task_error_text(code, task_record.MAX_TASK_NAME_LEN)
return task_error_text(code)
def accept(self):
if self._has_data:
return
if self.new_name() == self._old_name:
super().accept()
return
ok, code = task_record.validate_task_name(
self.new_name(), exclude_id=self._task_id or None)
if not ok:
self._error_label.setText(self._error_text(code))
self._error_label.setStyleSheet(_ERROR_STYLE)
self._error_label.setVisible(True)
return
if os.path.isdir(task_dir_of(settings.RECORDING_DIR, self.new_name())):
self._error_label.setText(task_error_text("name_dir_exists"))
self._error_label.setStyleSheet(_ERROR_STYLE)
self._error_label.setVisible(True)
return
super().accept()