更新
This commit is contained in:
+378
-4
@@ -28,6 +28,7 @@ from PySide6.QtGui import (
|
||||
QColor,
|
||||
QDesktopServices,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QIcon,
|
||||
QPainter,
|
||||
QPen,
|
||||
@@ -853,6 +854,7 @@ class SessionDetailDialog(QDialog):
|
||||
content.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
content.setMinimumWidth(260)
|
||||
bubble_layout.addWidget(content)
|
||||
self._add_snapshot(bubble_layout, message)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -868,6 +870,57 @@ class SessionDetailDialog(QDialog):
|
||||
row.addStretch(1)
|
||||
parent.addLayout(row)
|
||||
|
||||
# 气泡最宽 690,留出左右内边距后图片可用的宽度
|
||||
_SNAPSHOT_MAX_WIDTH = 640
|
||||
_SNAPSHOT_MAX_HEIGHT = 420
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_path(message: dict) -> str:
|
||||
"""把档案里的文件名还原成 media 目录下的绝对路径。
|
||||
|
||||
存文件名而不是绝对路径,是为了档案换台机器打开也还能对上。
|
||||
"""
|
||||
name = str(message.get("image") or "").strip()
|
||||
if not name or os.path.basename(name) != name:
|
||||
return ""
|
||||
path = os.path.join(str(SCRIPT_DIR), "media", name)
|
||||
return path if os.path.exists(path) else ""
|
||||
|
||||
def _add_snapshot(self, bubble_layout: QVBoxLayout, message: dict) -> None:
|
||||
"""客户发来图片/表情时,把当时的聊天画面贴在这条消息下面。
|
||||
|
||||
企微的图片没有可复制的文本,档案里只能写「(客户发来图片)」这样的占位;
|
||||
光看这行字事后完全不知道客户发了什么。留存的是那一刻的整块聊天画面,
|
||||
不是抠出来的单张图,所以标注清楚免得看的人误会。
|
||||
"""
|
||||
path = self._snapshot_path(message)
|
||||
if not path:
|
||||
return
|
||||
pixmap = QPixmap(path)
|
||||
if pixmap.isNull():
|
||||
return
|
||||
if (
|
||||
pixmap.width() > self._SNAPSHOT_MAX_WIDTH
|
||||
or pixmap.height() > self._SNAPSHOT_MAX_HEIGHT
|
||||
):
|
||||
pixmap = pixmap.scaled(
|
||||
self._SNAPSHOT_MAX_WIDTH,
|
||||
self._SNAPSHOT_MAX_HEIGHT,
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
caption = _label("客户发来消息时的聊天画面", "MessageTime")
|
||||
bubble_layout.addWidget(caption)
|
||||
view = QLabel()
|
||||
view.setPixmap(pixmap)
|
||||
view.setStyleSheet("border:1px solid #DDE7E1;border-radius:8px;")
|
||||
view.setCursor(Qt.PointingHandCursor)
|
||||
view.setToolTip("点击用系统看图工具打开原图")
|
||||
view.mouseReleaseEvent = lambda _event, target=path: QDesktopServices.openUrl(
|
||||
QUrl.fromLocalFile(target)
|
||||
)
|
||||
bubble_layout.addWidget(view)
|
||||
|
||||
def _copy_all(self) -> None:
|
||||
lines = [f"客户会话记录\n会话 ID:{self.session_id}\n"]
|
||||
role_names = {"user": "客户", "assistant": "客服", "system": "系统记录"}
|
||||
@@ -877,7 +930,11 @@ class SessionDetailDialog(QDialog):
|
||||
name = role_names.get(role, role)
|
||||
stamp = self._message_time(message.get("ts"))
|
||||
heading = f"[{stamp}] {name}" if stamp else name
|
||||
lines.append(f"{heading}\n{message.get('content') or ''}")
|
||||
body = str(message.get("content") or "")
|
||||
snapshot = self._snapshot_path(message)
|
||||
if snapshot:
|
||||
body = f"{body}\n[聊天画面] {snapshot}"
|
||||
lines.append(f"{heading}\n{body}")
|
||||
elif self.last_lines:
|
||||
lines.append("系统记录\n" + "\n".join(self.last_lines))
|
||||
QApplication.clipboard().setText("\n\n".join(lines))
|
||||
@@ -1358,6 +1415,263 @@ class PersonaPage(QScrollArea):
|
||||
self.mcp_rounds.setValue(int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)))
|
||||
|
||||
|
||||
class QueuePage(QScrollArea):
|
||||
"""回复队列:现在还欠谁一条回复,以及每个任务都经历了什么。
|
||||
|
||||
队列本身只存"未完成"的任务,做完就删,什么痕迹都不留。出问题时——某个客户
|
||||
没收到回复、同一句话回了三遍——事后完全看不出它经历过什么。执行记录补的
|
||||
就是这条时间线。
|
||||
"""
|
||||
|
||||
logMessage = Signal(str, str)
|
||||
|
||||
EVENT_COLORS = {
|
||||
"入队": COLORS["accent_dark"],
|
||||
"开始处理": COLORS["ink"],
|
||||
"调用模型": COLORS["accent_dark"],
|
||||
"生成回复": COLORS["ink"],
|
||||
"已发送": COLORS["success"],
|
||||
"完成": COLORS["success"],
|
||||
"对账": COLORS["muted"],
|
||||
"页面诊断": COLORS["muted"],
|
||||
"发送待核对": COLORS["warning"],
|
||||
"跳过": COLORS["warning"],
|
||||
"失败": COLORS["danger"],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("PageScroll")
|
||||
self.setWidgetResizable(True)
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
root = QWidget()
|
||||
root.setObjectName("PageRoot")
|
||||
self.setWidget(root)
|
||||
layout = QVBoxLayout(root)
|
||||
layout.setContentsMargins(28, 25, 28, 28)
|
||||
layout.setSpacing(18)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.addLayout(
|
||||
_page_header("06", "回复队列", "排队等回复的会话,以及每个任务的执行流水。"),
|
||||
1,
|
||||
)
|
||||
refresh = _button("刷新")
|
||||
refresh.clicked.connect(self.refresh_data)
|
||||
header.addWidget(refresh, 0, Qt.AlignBottom)
|
||||
layout.addLayout(header)
|
||||
|
||||
summary = QGridLayout()
|
||||
summary.setSpacing(12)
|
||||
self.waiting_metric = MetricCard("排队中", "0", "等待回复的会话")
|
||||
self.sent_metric = MetricCard("今日已回", "0", "成功发出的回复")
|
||||
self.failed_metric = MetricCard("今日失败", "0", "发送未成功,会自动重试")
|
||||
for column, card in enumerate(
|
||||
(self.waiting_metric, self.sent_metric, self.failed_metric)
|
||||
):
|
||||
summary.addWidget(card, 0, column)
|
||||
summary.setColumnStretch(column, 1)
|
||||
layout.addLayout(summary)
|
||||
|
||||
queue_card, queue_layout = _card(
|
||||
"当前队列",
|
||||
"按进入顺序排队,一次只服务一个会话,聊天内容互不串扰。",
|
||||
)
|
||||
self._running = False
|
||||
self.queue_notice = _label("", "CardSubtitle")
|
||||
self.queue_notice.setStyleSheet(
|
||||
f"color:{COLORS['warning']};background:#FFF7E8;"
|
||||
"border-radius:6px;padding:7px 10px;"
|
||||
)
|
||||
self.queue_notice.hide()
|
||||
queue_layout.addWidget(self.queue_notice)
|
||||
self.queue_table = self._table(["排队", "客户", "状态", "等待时长", "会话 ID"])
|
||||
queue_layout.addWidget(self.queue_table)
|
||||
layout.addWidget(queue_card)
|
||||
|
||||
history_card, history_layout = _card(
|
||||
"执行记录",
|
||||
"每个任务从入队到完成的每一步,最新的排在最上面。",
|
||||
)
|
||||
self.history_table = self._table(["时间", "客户", "动作", "内容"])
|
||||
history_layout.addWidget(self.history_table)
|
||||
history_actions = QHBoxLayout()
|
||||
history_actions.addStretch(1)
|
||||
clear = _button("清空记录", "danger")
|
||||
clear.clicked.connect(self._clear_history)
|
||||
history_actions.addWidget(clear)
|
||||
history_layout.addLayout(history_actions)
|
||||
layout.addWidget(history_card)
|
||||
layout.addStretch(1)
|
||||
self.refresh_data()
|
||||
|
||||
@staticmethod
|
||||
def _table(headers: list[str]) -> QTableWidget:
|
||||
table = QTableWidget(0, len(headers))
|
||||
table.setHorizontalHeaderLabels(headers)
|
||||
table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
table.setAlternatingRowColors(True)
|
||||
table.verticalHeader().hide()
|
||||
table.verticalHeader().setDefaultSectionSize(38)
|
||||
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
|
||||
table.horizontalHeader().setStretchLastSection(True)
|
||||
table.setMinimumHeight(220)
|
||||
return table
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
"""监听是否在跑。停着的时候队列里的等待时长会一直涨,不说明白会被当成卡死。"""
|
||||
self._running = bool(running)
|
||||
self._refresh_notice()
|
||||
|
||||
def _refresh_notice(self) -> None:
|
||||
waiting = self.queue_table.rowCount() if self.queue_table.item(0, 1) else 0
|
||||
if not self._running and waiting:
|
||||
self.queue_notice.setText(
|
||||
"监听已停止,这些任务原地保留;重新开始监听后会接着处理,等待时长仍在累计。"
|
||||
)
|
||||
self.queue_notice.show()
|
||||
else:
|
||||
self.queue_notice.hide()
|
||||
|
||||
@staticmethod
|
||||
def _show_placeholder(table: QTableWidget, text: str) -> None:
|
||||
"""空表就是一大片白,看着像坏了。用一行字说明它本来就该是空的。"""
|
||||
table.setRowCount(1)
|
||||
item = QTableWidgetItem(text)
|
||||
item.setForeground(QColor(COLORS["muted"]))
|
||||
table.setItem(0, 0, item)
|
||||
table.setSpan(0, 0, 1, table.columnCount())
|
||||
|
||||
@staticmethod
|
||||
def _as_time(value) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float("inf")
|
||||
|
||||
@staticmethod
|
||||
def _waited_text(since: float) -> str:
|
||||
try:
|
||||
seconds = max(0, int(time.time() - float(since)))
|
||||
except (TypeError, ValueError):
|
||||
return "--"
|
||||
if seconds < 60:
|
||||
return f"{seconds} 秒"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60} 分 {seconds % 60} 秒"
|
||||
return f"{seconds // 3600} 小时 {(seconds % 3600) // 60} 分"
|
||||
|
||||
@staticmethod
|
||||
def _state_text(state: dict) -> str:
|
||||
send_state = str(state.get("send_state") or "")
|
||||
if send_state == "sending":
|
||||
return "正在发送"
|
||||
if send_state == "uncertain":
|
||||
return "发送待核对"
|
||||
if send_state == "sent_uncommitted":
|
||||
return "已发送,待归档"
|
||||
if state.get("batch_ready"):
|
||||
return "正在生成回复"
|
||||
if state.get("confirmed_unread"):
|
||||
return "等待处理"
|
||||
return "排队中"
|
||||
|
||||
def refresh_data(self) -> None:
|
||||
self._refresh_queue()
|
||||
self._refresh_history()
|
||||
self._refresh_notice()
|
||||
|
||||
def _refresh_queue(self) -> None:
|
||||
try:
|
||||
with open(SCRIPT_DIR / "pending_replies.json", encoding="utf-8") as handle:
|
||||
raw = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
raw = {}
|
||||
except Exception as exc:
|
||||
raw = {}
|
||||
self.logMessage.emit(f"读取回复队列失败:{exc}", "err")
|
||||
pending = raw.get("pending", raw) if isinstance(raw, dict) else {}
|
||||
rows = sorted(
|
||||
(
|
||||
(key, value)
|
||||
for key, value in pending.items()
|
||||
if isinstance(value, dict)
|
||||
),
|
||||
# 先来的排前面,界面上的顺序就是实际服务顺序。时间戳脏了也不能
|
||||
# 让整页读不出来——排到最后就是了
|
||||
key=lambda item: self._as_time(item[1].get("created_at")),
|
||||
)
|
||||
self.waiting_metric.value.setText(str(len(rows)))
|
||||
self.queue_table.clearSpans()
|
||||
self.queue_table.setRowCount(len(rows))
|
||||
if not rows:
|
||||
self._show_placeholder(self.queue_table, "没有排队的会话,所有消息都已回复。")
|
||||
return
|
||||
for index, (key, state) in enumerate(rows):
|
||||
values = (
|
||||
str(index + 1),
|
||||
state.get("display_name") or "(未识别昵称)",
|
||||
self._state_text(state),
|
||||
self._waited_text(state.get("created_at")),
|
||||
str(key)[:32],
|
||||
)
|
||||
for column, value in enumerate(values):
|
||||
self.queue_table.setItem(index, column, QTableWidgetItem(str(value)))
|
||||
|
||||
def _refresh_history(self) -> None:
|
||||
try:
|
||||
from queue_log import QueueLog
|
||||
|
||||
events = QueueLog().recent(200)
|
||||
except Exception as exc:
|
||||
events = []
|
||||
self.logMessage.emit(f"读取队列执行记录失败:{exc}", "err")
|
||||
self.history_table.clearSpans()
|
||||
self.history_table.setRowCount(len(events))
|
||||
if not events:
|
||||
self._show_placeholder(self.history_table, "还没有队列执行记录。")
|
||||
self.sent_metric.value.setText("0")
|
||||
self.failed_metric.value.setText("0")
|
||||
return
|
||||
midnight = time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1))
|
||||
sent = failed = 0
|
||||
for row, event in enumerate(events):
|
||||
try:
|
||||
stamp = float(event.get("ts") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
stamp = 0.0
|
||||
action = str(event.get("event") or "")
|
||||
if stamp >= midnight:
|
||||
if action == "已发送":
|
||||
sent += 1
|
||||
elif action == "失败":
|
||||
failed += 1
|
||||
values = (
|
||||
time.strftime("%m-%d %H:%M:%S", time.localtime(stamp)) if stamp else "--",
|
||||
event.get("name") or "(未识别昵称)",
|
||||
action,
|
||||
str(event.get("detail") or "").replace("\n", " "),
|
||||
)
|
||||
for column, value in enumerate(values):
|
||||
item = QTableWidgetItem(str(value))
|
||||
if column == 2 and action in self.EVENT_COLORS:
|
||||
item.setForeground(QColor(self.EVENT_COLORS[action]))
|
||||
self.history_table.setItem(row, column, item)
|
||||
self.sent_metric.value.setText(str(sent))
|
||||
self.failed_metric.value.setText(str(failed))
|
||||
|
||||
def _clear_history(self) -> None:
|
||||
try:
|
||||
from queue_log import QueueLog
|
||||
|
||||
QueueLog().clear()
|
||||
except Exception as exc:
|
||||
self.logMessage.emit(f"清空队列执行记录失败:{exc}", "err")
|
||||
return
|
||||
self._refresh_history()
|
||||
|
||||
|
||||
class LogPage(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -1366,7 +1680,7 @@ class LogPage(QWidget):
|
||||
layout.setContentsMargins(28, 25, 28, 28)
|
||||
layout.setSpacing(18)
|
||||
header = QHBoxLayout()
|
||||
header.addLayout(_page_header("06", "运行日志", "实时查看连接、识别、AI 与业务沉淀事件。"), 1)
|
||||
header.addLayout(_page_header("07", "运行日志", "实时查看连接、识别、AI 与业务沉淀事件。"), 1)
|
||||
clear = _button("清空日志")
|
||||
clear.clicked.connect(self.clear)
|
||||
header.addWidget(clear, 0, Qt.AlignBottom)
|
||||
@@ -1534,6 +1848,16 @@ class CapsuleWindow(QWidget):
|
||||
row.addWidget(self.status_group, 0, Qt.AlignVCenter)
|
||||
row.addStretch(14)
|
||||
|
||||
# 计时器和进度文字叠成一列:光看时长看不出机器人此刻在忙什么,
|
||||
# 缩成胶囊之后更是全靠这一行判断它是在等、在读、还是卡住了
|
||||
clock = QWidget()
|
||||
clock.setObjectName("CapsuleClock")
|
||||
clock.setFixedSize(150, 46)
|
||||
clock_column = QVBoxLayout(clock)
|
||||
clock_column.setContentsMargins(0, 0, 0, 0)
|
||||
clock_column.setSpacing(2)
|
||||
clock_column.addStretch(1)
|
||||
|
||||
self.timer = QLabel("00:00:00")
|
||||
self.timer.setObjectName("CapsuleTimer")
|
||||
self.timer.setAccessibleName("监听运行时长")
|
||||
@@ -1544,7 +1868,21 @@ class CapsuleWindow(QWidget):
|
||||
"border-radius:8px;font-family:'Cascadia Mono','Consolas';"
|
||||
"font-size:10px;font-weight:600;color:#1A1C1C;}"
|
||||
)
|
||||
row.addWidget(self.timer, 0, Qt.AlignVCenter)
|
||||
clock_column.addWidget(self.timer, 0, Qt.AlignHCenter)
|
||||
|
||||
self._progress_text = ""
|
||||
self.progress = QLabel("")
|
||||
self.progress.setObjectName("CapsuleProgress")
|
||||
self.progress.setAccessibleName("当前进度")
|
||||
self.progress.setAlignment(Qt.AlignCenter)
|
||||
self.progress.setFixedSize(150, 13)
|
||||
self.progress.setStyleSheet(
|
||||
"QLabel#CapsuleProgress{color:#66736D;font-size:10px;}"
|
||||
)
|
||||
clock_column.addWidget(self.progress, 0, Qt.AlignHCenter)
|
||||
clock_column.addStretch(1)
|
||||
|
||||
row.addWidget(clock, 0, Qt.AlignVCenter)
|
||||
row.addStretch(39)
|
||||
|
||||
self.divider = QFrame()
|
||||
@@ -1629,6 +1967,20 @@ class CapsuleWindow(QWidget):
|
||||
f"QLabel{{color:{title_color};font-size:13px;font-weight:600;}}"
|
||||
)
|
||||
self.hint.setText(hints.get(state, text))
|
||||
if state in ("stopped", "error", "verification"):
|
||||
# 停下来之后还挂着"正在回复 XXX"会让人以为它还在干活
|
||||
self.set_progress("")
|
||||
|
||||
def set_progress(self, text: str) -> None:
|
||||
"""胶囊里那行实时进度。客户昵称可能很长,放不下就省略中间。"""
|
||||
self._progress_text = str(text or "")
|
||||
metrics = QFontMetrics(self.progress.font())
|
||||
self.progress.setText(
|
||||
metrics.elidedText(
|
||||
self._progress_text, Qt.ElideMiddle, self.progress.width()
|
||||
)
|
||||
)
|
||||
self.progress.setToolTip(self._progress_text)
|
||||
|
||||
|
||||
class Sidebar(QFrame):
|
||||
@@ -1636,7 +1988,9 @@ class Sidebar(QFrame):
|
||||
startRequested = Signal()
|
||||
stopRequested = Signal()
|
||||
|
||||
PAGE_NAMES = ("AI 客服", "自动回复", "通用设置", "业务数据", "AI 人格", "运行日志")
|
||||
PAGE_NAMES = (
|
||||
"AI 客服", "自动回复", "通用设置", "业务数据", "AI 人格", "回复队列", "运行日志",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -1745,6 +2099,7 @@ class MainWindow(QMainWindow):
|
||||
self.settings_page = SettingsPage(self.runtime_settings)
|
||||
self.business_page = BusinessPage()
|
||||
self.persona_page = PersonaPage()
|
||||
self.queue_page = QueuePage()
|
||||
self.log_page = LogPage()
|
||||
for page in (
|
||||
self.portal_page,
|
||||
@@ -1752,6 +2107,7 @@ class MainWindow(QMainWindow):
|
||||
self.settings_page,
|
||||
self.business_page,
|
||||
self.persona_page,
|
||||
self.queue_page,
|
||||
self.log_page,
|
||||
):
|
||||
self.stack.addWidget(page)
|
||||
@@ -1771,6 +2127,13 @@ class MainWindow(QMainWindow):
|
||||
self.queue_timer.timeout.connect(self._process_queue)
|
||||
self.queue_timer.start()
|
||||
|
||||
# 队列页面只在自己露脸时刷新:读的是磁盘上的两个 JSON,
|
||||
# 后台一直轮询纯属白白占着硬盘和 CPU
|
||||
self.queue_page_timer = QTimer(self)
|
||||
self.queue_page_timer.setInterval(2000)
|
||||
self.queue_page_timer.timeout.connect(self._refresh_queue_page)
|
||||
self.queue_page_timer.start()
|
||||
|
||||
try:
|
||||
import backend_client
|
||||
|
||||
@@ -1803,6 +2166,7 @@ class MainWindow(QMainWindow):
|
||||
self.dashboard_page.pageRequested.connect(self.show_page)
|
||||
self.settings_page.saved.connect(self.save_runtime_settings)
|
||||
self.business_page.logMessage.connect(self.append_log)
|
||||
self.queue_page.logMessage.connect(self.append_log)
|
||||
self.persona_page.saved.connect(self._persona_saved)
|
||||
self.capsule.expandRequested.connect(self.expand_console)
|
||||
self.capsule.stopRequested.connect(self.stop_monitoring)
|
||||
@@ -1867,9 +2231,16 @@ class MainWindow(QMainWindow):
|
||||
self.stack.setCurrentIndexAnimated(index)
|
||||
if index == 3:
|
||||
self.business_page.refresh_data()
|
||||
if index == 5:
|
||||
self.queue_page.refresh_data()
|
||||
if index == 0:
|
||||
QTimer.singleShot(80, lambda: self.portal_page.view.setFocus(Qt.OtherFocusReason))
|
||||
|
||||
def _refresh_queue_page(self) -> None:
|
||||
"""队列页开着的时候,让它跟着机器人一起动。"""
|
||||
if self.stack.currentWidget() is self.queue_page and self.isVisible():
|
||||
self.queue_page.refresh_data()
|
||||
|
||||
def start_monitoring(self) -> None:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self.append_log("监听线程已经在运行", "warn")
|
||||
@@ -1918,6 +2289,7 @@ class MainWindow(QMainWindow):
|
||||
self.sidebar.set_status(state, text, hint)
|
||||
self.dashboard_page.set_status(state, text, hint)
|
||||
self.capsule.set_status(state, text)
|
||||
self.queue_page.set_running(state not in ("stopped", "error", "verification"))
|
||||
|
||||
def changeEvent(self, event) -> None:
|
||||
if (
|
||||
@@ -2081,6 +2453,8 @@ class MainWindow(QMainWindow):
|
||||
self.show_page(5)
|
||||
elif data == "stopped":
|
||||
self._finish_thread("stopped")
|
||||
elif kind == "progress":
|
||||
self.capsule.set_progress(str(data or ""))
|
||||
elif kind == "stats":
|
||||
self.dashboard_page.replied.value.setText(str(data.get("replied", 0)))
|
||||
self.dashboard_page.false_pos.value.setText(str(data.get("false_pos", 0)))
|
||||
|
||||
Reference in New Issue
Block a user