feat: UI 时间轴重构 + 关键帧图片全链路持久化

界面重做(深色监控面板主题):
- 事件时间轴页: 左侧事件列表卡片 + 右侧关键帧时间轴
  (时间点 + 视频帧 + 人物/动作/衣着摘要 + 关注标记)
- 全局 CSS: 深色主题、卡片化按钮、统计卡、对话气泡、成员卡片
- 侧边栏任务队列状态徽章; 历史帧无图时优雅降级占位

关键帧持久化链路:
- Edge orchestrator: process_push_task 成功后把 keyframes
  base64 注入 frame_details[i].frame_image (位置对齐视觉输入帧)
- NAS event_receiver: 落库前 base64 解码写盘到
  fam-ui/static/frames/event_{id}/frame_{idx}.jpg
- payload 增量 ~300KB/事件 (6帧 jpeg q80), 队列/拉取均无压力

配置: fam-core/fam-ui 新增 storage.frame_image_dir
This commit is contained in:
ericwyuan
2026-08-20 19:50:36 +08:00
parent 963b6d7540
commit 0d5173f442
6 changed files with 695 additions and 180 deletions

View File

@@ -47,3 +47,7 @@ chat_handler:
# 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底) # 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask" qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
timeout: 120 timeout: 120
storage:
# 关键帧落盘目录event_receiver 写入fam-ui 读取展示时间轴)
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"

View File

@@ -36,3 +36,7 @@ chat_handler:
ollama_url: "http://100.x.x.20:11434/api/generate" ollama_url: "http://100.x.x.20:11434/api/generate"
model_name: "llava-phi3" model_name: "llava-phi3"
timeout: 120 timeout: 120
storage:
# 关键帧落盘目录event_receiver 写入fam-ui 读取展示时间轴)
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"

View File

@@ -3,21 +3,31 @@ Event-Receiver - Flask 蓝图,接收 Edge 回调,写库
处理逻辑: 处理逻辑:
1. 成功回调: 插入 monitor_events 1 条 + 遍历 frame_details 逐条插入 event_details 1. 成功回调: 插入 monitor_events 1 条 + 遍历 frame_details 逐条插入 event_details
2. 对未命名的 abstract_label 自动 upsert 到 family_members 2. frame_details 携带的关键帧 base64 落盘到 fam-ui 静态目录(供时间轴展示)
3. 更新 process_tasks 状态为 SUCCESS 3. 对未命名的 abstract_label 自动 upsert 到 family_members
4. 失败回调: 更新任务状态为 FAILED记录 failure_stage 4. 更新 process_tasks 状态为 SUCCESS
5. 失败回调: 更新任务状态为 FAILED记录 failure_stage
""" """
import os
import re import re
import json import json
import base64
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from ..logger import setup_logger from ..logger import setup_logger
from ..config_loader import load_config
from .. import db_layer from .. import db_layer
logger = setup_logger('fam-core.event_receiver') logger = setup_logger('fam-core.event_receiver')
event_bp = Blueprint('event_receiver', __name__) event_bp = Blueprint('event_receiver', __name__)
# 关键帧落盘目录fam-ui 读取展示fam-core 与 fam-ui 同机部署)
_cfg = load_config()
FRAME_IMAGE_DIR = _cfg.get('storage', {}).get(
'frame_image_dir',
'/volume1/web/sentinel-home-ai/fam-ui/static/frames')
# 匹配 "人物A" / "人物B" 等 abstract_label # 匹配 "人物A" / "人物B" 等 abstract_label
_ABSTRACT_LABEL_PATTERN = re.compile(r'^人物[A-Z]$') _ABSTRACT_LABEL_PATTERN = re.compile(r'^人物[A-Z]$')
@@ -27,6 +37,28 @@ def _is_abstract_label(person: str) -> bool:
return bool(_ABSTRACT_LABEL_PATTERN.match(person)) return bool(_ABSTRACT_LABEL_PATTERN.match(person))
def _save_frame_images(event_id: int, frame_details: list) -> int:
"""把 frame_details 中的 base64 关键帧落盘,返回成功张数"""
saved = 0
for frame in frame_details:
img_b64 = frame.pop('frame_image', None)
if not img_b64:
continue
idx = frame.get('frame_index', 0)
try:
out_dir = os.path.join(FRAME_IMAGE_DIR, f'event_{event_id}')
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, f'frame_{idx}.jpg')
with open(out_path, 'wb') as f:
f.write(base64.b64decode(img_b64))
saved += 1
except Exception as e:
logger.warning(f"[event_id={event_id}] 关键帧落盘失败 frame_{idx}: {e}")
if saved:
logger.info(f"[event_id={event_id}] 关键帧落盘 {saved} 张 -> {FRAME_IMAGE_DIR}")
return saved
def _upsert_abstract_members(frame_details: list): def _upsert_abstract_members(frame_details: list):
"""对未命名的 abstract_label 自动 upsert 到 family_members""" """对未命名的 abstract_label 自动 upsert 到 family_members"""
seen = {} seen = {}
@@ -63,8 +95,14 @@ def apply_success_event(task_id, data: dict) -> int:
compute_provider=data.get('compute_provider', []) compute_provider=data.get('compute_provider', [])
) )
# 2. 遍历 frame_details 逐条插入 # 2. 关键帧图片落盘先落盘再入库pop 掉 base64 后 insert避免大字段进 DB
frame_details = data.get('frame_details', []) frame_details = data.get('frame_details', [])
try:
_save_frame_images(event_id, frame_details)
except Exception as e:
logger.warning(f"[event_id={event_id}] 关键帧落盘异常(不影响入库): {e}")
# 3. 遍历 frame_details 逐条插入
for frame in frame_details: for frame in frame_details:
db_layer.insert_event_detail( db_layer.insert_event_detail(
event_id=event_id, event_id=event_id,

View File

@@ -171,6 +171,18 @@ class AIOrchestrator:
adapter.get_circuit_breaker().record_failure() adapter.get_circuit_breaker().record_failure()
return model_outputs return model_outputs
@staticmethod
def _attach_frame_images(frame_details: List[dict], frame_paths: List[str]) -> None:
"""把关键帧图片 base64 附加到 frame_details按位置对齐视觉分析输入帧"""
for i, fd in enumerate(frame_details):
if i >= len(frame_paths):
break
try:
with open(frame_paths[i], 'rb') as f:
fd['frame_image'] = base64.b64encode(f.read()).decode('ascii')
except OSError as e:
logger.warning(f"关键帧图片读取失败: {frame_paths[i]}: {e}")
def format_cloud_result(self, provider: str, raw_result: dict, def format_cloud_result(self, provider: str, raw_result: dict,
known_members_context: str = '', known_members_context: str = '',
task_id: int = 0) -> dict: task_id: int = 0) -> dict:
@@ -481,6 +493,10 @@ class AIOrchestrator:
fusion_result = self.format_cloud_result( fusion_result = self.format_cloud_result(
provider, model_outputs[provider], known_members, task_id) provider, model_outputs[provider], known_members, task_id)
# 5. 附加关键帧图片NAS 落盘后供 UI 时间轴展示)
frame_details = fusion_result.get('frame_details', [])
self._attach_frame_images(frame_details, compressed_frames)
total_ms = int((time.time() - start_time) * 1000) total_ms = int((time.time() - start_time) * 1000)
log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms) log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms)
@@ -492,7 +508,7 @@ class AIOrchestrator:
"camera_name": task_data.get('camera_name', ''), "camera_name": task_data.get('camera_name', ''),
"global_summary": fusion_result.get('global_summary', ''), "global_summary": fusion_result.get('global_summary', ''),
"entities_json": fusion_result.get('entities_json', []), "entities_json": fusion_result.get('entities_json', []),
"frame_details": fusion_result.get('frame_details', []), "frame_details": frame_details,
"compute_provider": fusion_result.get('compute_provider', []), "compute_provider": fusion_result.get('compute_provider', []),
"error_message": None "error_message": None
} }

View File

@@ -10,3 +10,7 @@ database:
password: "iLoveJava5!" password: "iLoveJava5!"
database: "sentinel_home_ai" database: "sentinel_home_ai"
unix_socket: "/run/mysqld/mysqld10.sock" unix_socket: "/run/mysqld/mysqld10.sock"
storage:
# 关键帧目录fam-core event_receiver 落盘UI 读取展示时间轴)
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"

View File

@@ -1,28 +1,32 @@
""" """
FAM-UI - 家庭多模态智能监控系统前端 FAM-UI - 家庭多模态智能监控系统前端 v2
Streamlit 直读 MariaDB展示: 深色监控面板主题 + 关键帧时间轴:
- 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页 - 事件时间轴页: 左侧事件列表,右侧时间轴(时间点 + 关键帧 + 摘要)
- AI 对话 / 对话历史 / 成员命名 / 统计图表
""" """
import os import os
import sys import sys
import json
import base64
import html as _html
import requests import requests
import streamlit as st import streamlit as st
import pymysql import pymysql
import pymysql.cursors import pymysql.cursors
import pandas as pd
import json
from datetime import datetime, date from datetime import datetime, date
# 添加共享模块路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from config_loader import load_config from config_loader import load_config
# 加载配置
_cfg = load_config() _cfg = load_config()
_core_url = _cfg.get('core_url', 'http://127.0.0.1:8000') _core_url = _cfg.get('core_url', 'http://127.0.0.1:8000')
_db_cfg = _cfg.get('database', {}) _db_cfg = _cfg.get('database', {})
FRAME_DIR = _cfg.get('storage', {}).get(
'frame_image_dir', '/volume1/web/sentinel-home-ai/fam-ui/static/frames')
esc = _html.escape
def get_db_conn(): def get_db_conn():
@@ -45,8 +49,30 @@ def serialize_datetime(obj):
return str(obj) return str(obj)
def parse_ts(ts):
"""'2026-08-14 22:31:15' / datetime -> datetime"""
if isinstance(ts, datetime):
return ts
try:
return datetime.strptime(str(ts)[:19], '%Y-%m-%d %H:%M:%S')
except ValueError:
return None
def load_frame_b64(event_id, frame_index):
"""读取落盘关键帧为 base64不存在返回 None"""
path = os.path.join(FRAME_DIR, f'event_{event_id}', f'frame_{frame_index}.jpg')
try:
if os.path.isfile(path):
with open(path, 'rb') as f:
return base64.b64encode(f.read()).decode('ascii')
except OSError:
pass
return None
# ============================================================ # ============================================================
# 页面配置 # 页面配置 & 全局样式
# ============================================================ # ============================================================
st.set_page_config( st.set_page_config(
page_title="家庭智能监控", page_title="家庭智能监控",
@@ -55,136 +81,536 @@ st.set_page_config(
initial_sidebar_state="expanded" initial_sidebar_state="expanded"
) )
# 侧边栏导航 GLOBAL_CSS = """
st.sidebar.title("🏠 家庭智能监控") <style>
:root {
--bg: #0b0e14;
--panel: #131826;
--panel-2: #161b28;
--border: #232a3b;
--border-hi: #33405c;
--text: #e2e8f0;
--text-dim: #8b93a7;
--text-mute: #64748b;
--accent: #5b8cff;
--accent-soft: rgba(91,140,255,.15);
--danger: #ff6b6b;
--ok: #34d399;
}
.stApp { background: var(--bg); color: var(--text); }
#MainMenu, footer, header { visibility: hidden; }
h1, h2, h3 { color: #f1f5f9 !important; font-weight: 700 !important; }
a { color: var(--accent) !important; }
section[data-testid="stSidebar"] {
background: #0e1220 !important;
border-right: 1px solid var(--border);
}
[data-testid="stSidebar"] * { color: var(--text-dim) !important; }
[data-testid="stSidebar"] .stRadio label { font-size: 14px; }
/* Streamlit 组件暗色适配 */
.stTabs [data-baseweb="tab"] { color: var(--text-dim); }
.stTabs [aria-selected="true"] { color: #fff !important; }
div[data-testid="stDataFrame"] { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.stProgress > div > div { background: var(--accent); }
hr { border-color: var(--border) !important; }
/* 输入组件 */
.stTextInput > div > div > input, .stTextArea textarea {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
color: var(--text) !important;
border-radius: 10px !important;
}
.stTextInput > div > div > input:focus, .stTextArea textarea:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent-soft) !important;
}
div[data-testid="stDateInput"] > div > div > input {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
color: var(--text) !important;
border-radius: 10px !important;
}
/* 按钮卡片化 */
div[data-testid="stButton"] > button {
background: var(--panel);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px 14px;
font-size: 13px;
transition: all .18s ease;
}
div[data-testid="stButton"] > button:hover {
border-color: var(--accent);
color: #fff;
background: #182034;
box-shadow: 0 4px 14px rgba(91,140,255,.12);
}
div[data-testid="stButton"] > button[kind="primary"],
.stButton > button[kind="primary"] {
background: linear-gradient(135deg, #3d6bff, #5b8cff);
border: none;
color: #fff;
font-weight: 600;
box-shadow: 0 4px 14px rgba(91,140,255,.25);
}
/* 页面标题 */
.fam-pagehead {
display: flex; align-items: center; gap: 12px;
margin: 4px 0 20px 0;
}
.fam-pagehead .fp-ico {
width: 42px; height: 42px; border-radius: 12px;
background: linear-gradient(135deg, rgba(91,140,255,.25), rgba(124,91,255,.18));
border: 1px solid rgba(91,140,255,.35);
display: flex; align-items: center; justify-content: center;
font-size: 21px;
}
.fam-pagehead .fp-title { font-size: 21px; font-weight: 700; color: #f1f5f9; }
.fam-pagehead .fp-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
/* 统计卡 */
.stat-row { display: flex; gap: 14px; margin: 0 0 22px 0; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 130px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 18px;
}
.stat-card .s-val {
font-size: 26px; font-weight: 700; color: #f1f5f9;
font-variant-numeric: tabular-nums; line-height: 1.2;
}
.stat-card .s-label { font-size: 12px; color: var(--text-mute); margin-top: 3px; }
.stat-card.warn .s-val { color: #ff9b9b; }
.stat-card.ok .s-val { color: #6ee7b7; }
/* 事件头卡片 */
.ev-head {
background: linear-gradient(135deg, rgba(91,140,255,.12), rgba(124,91,255,.07));
border: 1px solid #26304a;
border-radius: 16px;
padding: 18px 22px;
margin-bottom: 26px;
}
.ev-head .ev-title { font-size: 18px; font-weight: 700; color: #f1f5f9; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ev-head .ev-range {
font-size: 13px; color: var(--text-dim); margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.ev-head .ev-summary { margin-top: 10px; font-size: 14px; color: #cbd5e1; line-height: 1.65; }
/* 徽章 */
.bdg {
display: inline-block; font-size: 11px; padding: 3px 10px;
border-radius: 999px; font-weight: 600; line-height: 1.4;
}
.bdg-person { background: var(--accent-soft); color: #8fb0ff; border: 1px solid rgba(91,140,255,.32); }
.bdg-att { background: rgba(255,107,107,.14); color: #ff9b9b; border: 1px solid rgba(255,107,107,.35); }
.bdg-cam { background: rgba(52,211,153,.12); color: #6ee7b7; border: 1px solid rgba(52,211,153,.3); }
.bdg-model { background: rgba(148,163,184,.12); color: #a8b3c7; border: 1px solid rgba(148,163,184,.28); }
/* ===== 关键帧时间轴 ===== */
.fam-timeline { margin-top: 4px; }
.tl-item {
display: grid;
grid-template-columns: 86px 34px 1fr;
position: relative;
padding-bottom: 26px;
}
.tl-item:last-child { padding-bottom: 4px; }
.tl-time { padding: 18px 8px 0 0; text-align: right; }
.tl-time .hm {
display: block; font-size: 16px; font-weight: 700; color: #cbd5e1;
font-variant-numeric: tabular-nums; line-height: 1.15;
}
.tl-time .ss { font-size: 11px; color: var(--text-mute); font-variant-numeric: tabular-nums; }
.tl-rail { position: relative; }
.tl-rail::before {
content: ''; position: absolute; left: 50%; top: 0; bottom: -26px;
width: 2px; margin-left: -1px; background: #1e2740;
}
.tl-item:last-child .tl-rail::before { display: none; }
.tl-dot {
position: absolute; left: 50%; top: 21px;
width: 11px; height: 11px; margin-left: -5.5px;
border-radius: 50%; background: var(--accent);
box-shadow: 0 0 0 4px rgba(91,140,255,.16);
z-index: 1;
}
.tl-item.attention .tl-dot { background: var(--danger); box-shadow: 0 0 0 4px rgba(255,107,107,.16); }
.tl-card {
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
display: flex; gap: 14px;
transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease;
}
.tl-card:hover { border-color: var(--border-hi); transform: translateX(3px); box-shadow: 0 6px 20px rgba(0,0,0,.35); }
.tl-item.attention .tl-card { border-color: rgba(255,107,107,.38); }
.tl-img {
width: 224px; height: 126px; border-radius: 10px;
object-fit: cover; background: #0d1019; flex-shrink: 0;
border: 1px solid #1c2233;
}
.tl-noimg {
width: 224px; height: 126px; border-radius: 10px; flex-shrink: 0;
background:
linear-gradient(135deg, #12161f 25%, #0f1320 25%, #0f1320 50%, #12161f 50%, #12161f 75%, #0f1320 75%);
background-size: 14px 14px;
border: 1px dashed #26304a;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #475069; font-size: 12px; gap: 6px;
}
.tl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tl-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 9px; }
.tl-action { font-size: 14px; color: var(--text); line-height: 1.6; flex: 1; }
.tl-action .no-action { color: var(--text-mute); }
.tl-meta { margin-top: 9px; font-size: 12px; color: var(--text-mute); }
/* 空态 */
.fam-empty {
border: 1px dashed var(--border); border-radius: 14px;
padding: 40px 20px; text-align: center; color: var(--text-mute);
font-size: 14px; background: rgba(19,24,38,.5);
}
.fam-empty .fe-ico { font-size: 30px; display: block; margin-bottom: 10px; opacity: .55; }
/* 对话气泡 */
.chat-q, .chat-a {
border-radius: 14px; padding: 14px 18px; margin-bottom: 14px;
font-size: 14px; line-height: 1.7;
}
.chat-q { background: var(--accent-soft); border: 1px solid rgba(91,140,255,.28); }
.chat-a { background: var(--panel); border: 1px solid var(--border); }
.chat-who { font-size: 11px; color: var(--text-mute); margin-bottom: 6px; }
/* 成员卡 */
.member-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.member-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
padding: 16px 18px;
}
@media (max-width: 960px) {
.tl-card { flex-direction: column; }
.tl-img, .tl-noimg { width: 100%; height: 180px; }
.stat-card { min-width: 45%; }
}
</style>
"""
def page_header(icon: str, title: str, sub: str = ''):
st.markdown(
f'<div class="fam-pagehead">'
f'<div class="fp-ico">{icon}</div>'
f'<div><div class="fp-title">{esc(title)}</div>'
f'{f"<div class=fp-sub>{esc(sub)}</div>" if sub else ""}'
f'</div></div>',
unsafe_allow_html=True)
def render_timeline(details: list, event_id: int):
"""渲染关键帧时间轴:左时间点 + 中轴 + 右(帧图 + 摘要)"""
items = []
for d in details:
ts = parse_ts(d['frame_timestamp'])
hm = ts.strftime('%H:%M') if ts else '--:--'
ss = ts.strftime(':%S') if ts else ''
person = d.get('person') or '无人'
attention = bool(d.get('is_attention_event'))
action = d.get('action') or ''
clothing = d.get('clothing') or ''
providers = d.get('source_providers')
if isinstance(providers, str):
try:
providers = json.loads(providers)
except ValueError:
providers = [providers] if providers else []
providers = providers or []
model = providers[0] if providers else ''
img_b64 = load_frame_b64(event_id, d.get('frame_index', 0))
if img_b64:
img_html = (f'<img class="tl-img" alt="关键帧" '
f'src="data:image/jpeg;base64,{img_b64}">')
else:
img_html = ('<div class="tl-noimg"><span>📷</span>暂无帧图</div>')
badges = [f'<span class="bdg bdg-person">{esc(person)}</span>']
if attention:
badges.append('<span class="bdg bdg-att">⚠ 需关注</span>')
badge_html = ''.join(badges)
action_html = esc(action) if action else '<span class="no-action">未识别到明显活动</span>'
meta_parts = []
if clothing:
meta_parts.append(f'衣着: {esc(clothing)}')
if model:
meta_parts.append(f'模型: {esc(model)}')
meta_html = f'<div class="tl-meta">{" · ".join(meta_parts)}</div>' if meta_parts else ''
items.append(
f'<div class="tl-item {"attention" if attention else ""}">'
f'<div class="tl-time"><span class="hm">{hm}</span><span class="ss">{ss}</span></div>'
f'<div class="tl-rail"><span class="tl-dot"></span></div>'
f'<div class="tl-card">{img_html}'
f'<div class="tl-info"><div class="tl-badges">{badge_html}</div>'
f'<div class="tl-action">{action_html}</div>{meta_html}</div>'
f'</div></div>'
)
st.markdown(
f'<div class="fam-timeline">{"".join(items)}</div>',
unsafe_allow_html=True)
# ============================================================
# 侧边栏
# ============================================================
st.markdown(GLOBAL_CSS, unsafe_allow_html=True)
st.sidebar.markdown(
'<div style="padding:6px 2px 14px 2px;">'
'<div style="font-size:17px;font-weight:700;color:#f1f5f9;">🏠 家庭智能监控</div>'
'<div style="font-size:11px;color:#64748b;margin-top:3px;">SENTINEL HOME AI</div>'
'</div>', unsafe_allow_html=True)
page = st.sidebar.radio("功能页面", [ page = st.sidebar.radio("功能页面", [
"📊 事件列表", "🕒 事件时间轴",
"💬 AI 对话", "💬 AI 对话",
"📝 对话历史", "📝 对话历史",
"👤 成员命名", "👤 成员命名",
"📈 统计图表" "📈 统计图表"
]) ], label_visibility="collapsed")
# 侧边栏底部:任务队列状态
try:
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute("SELECT status, COUNT(*) AS c FROM process_tasks GROUP BY status")
rows = {r['status']: r['c'] for r in cursor.fetchall()}
finally:
conn.close()
pending = rows.get('PENDING', 0)
processing = rows.get('PROCESSING', 0)
failed = rows.get('FAILED', 0)
st.sidebar.markdown('---')
st.sidebar.markdown(
f'<div style="font-size:11px;color:#64748b;line-height:2.1;">'
f'任务队列<br>'
f'待处理 <b style="color:#cbd5e1">{pending}</b> · '
f'处理中 <b style="color:#8fb0ff">{processing}</b> · '
f'失败 <b style="color:#ff9b9b">{failed}</b>'
f'</div>', unsafe_allow_html=True)
except Exception:
pass
# ============================================================ # ============================================================
# 事件列表 # 事件时间轴
# ============================================================ # ============================================================
if page == "📊 事件列表": if page == "🕒 事件时间轴":
st.title("📊 监控事件列表") page_header('🕒', '事件时间轴', '关键时间点 · 视频帧 · 信息摘要')
# 日期筛选 with st.container():
col1, col2 = st.columns([1, 3]) col_date, col_sp = st.columns([1, 3])
with col1: with col_date:
date_filter = st.date_input("日期筛选", value=None) date_filter = st.date_input("日期", value=None)
# 分页 date_str = date_filter.isoformat() if date_filter else None
page_size = 20
# 统计卡
try:
conn = get_db_conn()
try:
cursor = conn.cursor()
if date_str:
cursor.execute("""
SELECT COUNT(DISTINCT me.event_id) AS ev,
COUNT(ed.detail_id) AS fr,
COALESCE(SUM(ed.is_attention_event), 0) AS att,
COUNT(DISTINCT CASE WHEN ed.person <> '无人' THEN ed.person END) AS ps
FROM monitor_events me
LEFT JOIN event_details ed ON ed.event_id = me.event_id
WHERE DATE(me.event_start_time) = %s
""", (date_str,))
else:
cursor.execute("""
SELECT COUNT(DISTINCT me.event_id) AS ev,
COUNT(ed.detail_id) AS fr,
COALESCE(SUM(ed.is_attention_event), 0) AS att,
COUNT(DISTINCT CASE WHEN ed.person <> '无人' THEN ed.person END) AS ps
FROM monitor_events me
LEFT JOIN event_details ed ON ed.event_id = me.event_id
""")
s = cursor.fetchone() or {}
finally:
conn.close()
st.markdown(
f'<div class="stat-row">'
f'<div class="stat-card"><div class="s-val">{s.get("ev", 0)}</div><div class="s-label">监控事件</div></div>'
f'<div class="stat-card"><div class="s-val">{s.get("fr", 0)}</div><div class="s-label">关键帧</div></div>'
f'<div class="stat-card ok"><div class="s-val">{s.get("ps", 0)}</div><div class="s-label">出现人物</div></div>'
f'<div class="stat-card warn"><div class="s-val">{s.get("att", 0)}</div><div class="s-label">需关注</div></div>'
f'</div>', unsafe_allow_html=True)
except Exception:
pass
# 事件列表
page_size = 15
if 'event_page' not in st.session_state: if 'event_page' not in st.session_state:
st.session_state.event_page = 0 st.session_state.event_page = 0
offset = st.session_state.event_page * page_size offset = st.session_state.event_page * page_size
conn = get_db_conn() conn = get_db_conn()
try: try:
cursor = conn.cursor() cursor = conn.cursor()
# 查询条件
date_str = date_filter.isoformat() if date_filter else None
if date_str: if date_str:
cursor.execute( cursor.execute(
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time, """SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
me.camera_name, me.global_summary, me.compute_provider, me.created_at, me.camera_name, me.global_summary, me.compute_provider,
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count (SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
FROM monitor_events me FROM monitor_events me
WHERE DATE(me.event_start_time) = %s WHERE DATE(me.event_start_time) = %s
ORDER BY me.event_start_time DESC ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s""", LIMIT %s OFFSET %s""",
(date_str, page_size, offset) (date_str, page_size, offset))
)
else: else:
cursor.execute( cursor.execute(
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time, """SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
me.camera_name, me.global_summary, me.compute_provider, me.created_at, me.camera_name, me.global_summary, me.compute_provider,
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count (SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
FROM monitor_events me FROM monitor_events me
ORDER BY me.event_start_time DESC ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s""", LIMIT %s OFFSET %s""",
(page_size, offset) (page_size, offset))
)
events = cursor.fetchall() events = cursor.fetchall()
finally:
conn.close()
if not events: if not events:
st.info("暂无事件数据") st.markdown(
'<div class="fam-empty"><span class="fe-ico">🗓</span>'
'该日期暂无监控事件</div>', unsafe_allow_html=True)
else: else:
# 默认选中最新事件
if 'selected_event_id' not in st.session_state:
st.session_state.selected_event_id = events[0]['event_id']
# 选中的事件不在当前列表时重置
valid_ids = {e['event_id'] for e in events}
if st.session_state.selected_event_id not in valid_ids:
st.session_state.selected_event_id = events[0]['event_id']
selected_id = st.session_state.selected_event_id
col_list, col_detail = st.columns([1, 2.35], gap='large')
with col_list:
st.markdown(
f'<div style="font-size:13px;font-weight:600;color:#8b93a7;'
f'margin-bottom:10px;">事件列表 · {len(events)} 条</div>',
unsafe_allow_html=True)
for ev in events: for ev in events:
ts = parse_ts(ev['event_start_time'])
time_label = ts.strftime('%H:%M') if ts else '--:--'
summary = (ev.get('global_summary') or '').strip()
if not summary:
summary = '暂无摘要'
summary_short = summary if len(summary) <= 26 else summary[:26] + ''
is_selected = ev['event_id'] == selected_id
if st.button(
f"{'' if is_selected else ''}{time_label} · {ev.get('camera_name') or '未知'} · {ev['detail_count']}",
key=f"evbtn_{ev['event_id']}",
type="primary" if is_selected else "secondary",
use_container_width=True
):
st.session_state.selected_event_id = ev['event_id']
st.rerun()
st.markdown(
f'<div style="font-size:11px;color:#64748b;margin:-4px 2px 12px 2px;'
f'line-height:1.5;">{esc(summary_short)}</div>',
unsafe_allow_html=True)
# 分页
nav1, nav2, nav3 = st.columns(3)
with nav1:
if st.button("← 上一页", use_container_width=True,
disabled=st.session_state.event_page == 0):
st.session_state.event_page -= 1
st.rerun()
with nav3:
if st.button("下一页 →", use_container_width=True,
disabled=len(events) < page_size):
st.session_state.event_page += 1
st.rerun()
with col_detail:
ev = next(e for e in events if e['event_id'] == selected_id)
start = parse_ts(ev['event_start_time'])
end = parse_ts(ev['event_end_time'])
range_str = ''
if start and end:
span_min = (end - start).total_seconds() / 60
range_str = (f"{start.strftime('%Y-%m-%d %H:%M:%S')}"
f"{end.strftime('%H:%M:%S')}"
f"(约 {int(span_min)} 分钟)")
providers = ev.get('compute_provider', '[]') providers = ev.get('compute_provider', '[]')
if isinstance(providers, str): if isinstance(providers, str):
try:
providers = json.loads(providers) providers = json.loads(providers)
except ValueError:
providers = []
model_badges = ''.join(
f'<span class="bdg bdg-model">{esc(p)}</span>' for p in (providers or []))
with st.container(): st.markdown(
col1, col2, col3 = st.columns([2, 1, 1]) f'<div class="ev-head">'
with col1: f'<div class="ev-title">'
start_time = serialize_datetime(ev['event_start_time']) f'<span>{esc(ev.get("camera_name") or "未知摄像头")}</span>'
end_time = serialize_datetime(ev['event_end_time']) f'<span class="bdg bdg-cam">事件 #{ev["event_id"]}</span>'
st.markdown(f"**{ev['camera_name'] or '未知摄像头'}** | {start_time} ~ {end_time}") f'{model_badges}</div>'
st.markdown(f"_{ev['global_summary']}_") f'<div class="ev-range">⏱ {esc(range_str)}</div>'
with col2: f'<div class="ev-summary">{esc(ev.get("global_summary") or "暂无全局摘要")}</div>'
st.markdown(f"明细: {ev['detail_count']}") f'</div>', unsafe_allow_html=True)
st.markdown(f"模型: {', '.join(providers)}")
with col3:
if st.button("查看明细", key=f"detail_{ev['event_id']}"):
st.session_state.selected_event_id = ev['event_id']
# 展开明细 conn = get_db_conn()
if st.session_state.get('selected_event_id') == ev['event_id']: try:
cursor = conn.cursor()
cursor.execute( cursor.execute(
"""SELECT frame_index, frame_timestamp, camera_name, person, """SELECT frame_index, frame_timestamp, camera_name, person,
action, clothing, is_attention_event, source_providers action, clothing, is_attention_event, source_providers
FROM event_details FROM event_details
WHERE event_id = %s WHERE event_id = %s
ORDER BY frame_index ASC""", ORDER BY frame_index ASC""",
(ev['event_id'],) (selected_id,))
)
details = cursor.fetchall() details = cursor.fetchall()
df = pd.DataFrame([{
'帧号': d['frame_index'],
'时间': serialize_datetime(d['frame_timestamp']),
'位置': d['camera_name'],
'人物': d['person'],
'动作': d['action'],
'衣着': d['clothing'],
'关注': '⚠️' if d['is_attention_event'] else '',
'模型来源': json.dumps(d['source_providers']) if isinstance(d['source_providers'], str) else json.dumps(d['source_providers'])
} for d in details])
st.dataframe(df, use_container_width=True, hide_index=True)
st.divider()
# 分页控制
col1, col2, col3 = st.columns([1, 1, 1])
with col1:
if st.button("上一页") and st.session_state.event_page > 0:
st.session_state.event_page -= 1
st.rerun()
with col2:
st.markdown(f"{st.session_state.event_page + 1}")
with col3:
if st.button("下一页") and len(events) == page_size:
st.session_state.event_page += 1
st.rerun()
finally: finally:
conn.close() conn.close()
if details:
render_timeline(details, selected_id)
else:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">🎞</span>'
'该事件暂无关键帧明细</div>', unsafe_allow_html=True)
# ============================================================ # ============================================================
# AI 对话页 # AI 对话页
# ============================================================ # ============================================================
elif page == "💬 AI 对话": elif page == "💬 AI 对话":
st.title("💬 AI 对话") page_header('💬', 'AI 对话', '基于监控数据的智能问答')
# 快捷人物按钮
conn = get_db_conn() conn = get_db_conn()
try: try:
cursor = conn.cursor() cursor = conn.cursor()
@@ -199,7 +625,6 @@ elif page == "💬 AI 对话":
with col2: with col2:
queried_date = st.date_input("查询日期", value=date.today()) queried_date = st.date_input("查询日期", value=date.today())
# 快捷提问
if named: if named:
quick_person = st.selectbox("快捷选择成员", [""] + named) quick_person = st.selectbox("快捷选择成员", [""] + named)
if quick_person: if quick_person:
@@ -211,7 +636,6 @@ elif page == "💬 AI 对话":
f"今天{queried_person}的活动时间线是什么?", f"今天{queried_person}的活动时间线是什么?",
] ]
selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions) selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions)
user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "") user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "")
if st.button("提问", type="primary"): if st.button("提问", type="primary"):
@@ -233,9 +657,15 @@ elif page == "💬 AI 对话":
) )
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
st.success("回答:") st.markdown(
f'<div class="chat-q"><div class="chat-who">❓ 提问</div>'
f'{esc(user_question)}</div>', unsafe_allow_html=True)
st.markdown(
f'<div class="chat-a"><div class="chat-who">🤖 回答</div>'
f'</div>', unsafe_allow_html=True)
st.markdown(data['answer']) st.markdown(data['answer'])
st.caption(f"上下文: {data.get('context_summary', '')}") if data.get('context_summary'):
st.caption(f"上下文: {data['context_summary']}")
else: else:
st.error(f"请求失败: {resp.status_code} {resp.text}") st.error(f"请求失败: {resp.status_code} {resp.text}")
except requests.ConnectionError: except requests.ConnectionError:
@@ -248,7 +678,7 @@ elif page == "💬 AI 对话":
# 对话历史页 # 对话历史页
# ============================================================ # ============================================================
elif page == "📝 对话历史": elif page == "📝 对话历史":
st.title("📝 对话历史") page_header('📝', '对话历史', '历史问答记录')
if 'chat_page' not in st.session_state: if 'chat_page' not in st.session_state:
st.session_state.chat_page = 0 st.session_state.chat_page = 0
@@ -268,52 +698,55 @@ elif page == "📝 对话历史":
(page_size, offset) (page_size, offset)
) )
history = cursor.fetchall() history = cursor.fetchall()
if not history:
st.info("暂无对话记录")
else:
for h in history:
with st.container():
col1, col2 = st.columns([1, 4])
with col1:
created = serialize_datetime(h['created_at'])
st.markdown(f"**{h['queried_person']}**")
st.caption(created)
if h['queried_date']:
st.caption(f"日期: {serialize_datetime(h['queried_date'])}")
with col2:
st.markdown(f"**Q:** {h['user_question']}")
st.markdown(f"**A:** {h['ai_answer']}")
st.divider()
# 分页
col1, col2, col3 = st.columns([1, 1, 1])
with col1:
if st.button("上一页") and st.session_state.chat_page > 0:
st.session_state.chat_page -= 1
st.rerun()
with col2:
st.markdown(f"{st.session_state.chat_page + 1}")
with col3:
if st.button("下一页") and len(history) == page_size:
st.session_state.chat_page += 1
st.rerun()
finally: finally:
conn.close() conn.close()
if not history:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">💬</span>'
'暂无对话记录</div>', unsafe_allow_html=True)
else:
for h in history:
created = serialize_datetime(h['created_at'])
st.markdown(
f'<div class="chat-q"><div class="chat-who">'
f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}</div>'
f'{esc(h["user_question"])}</div>',
unsafe_allow_html=True)
st.markdown(
f'<div class="chat-a"><div class="chat-who">🤖 回答</div></div>',
unsafe_allow_html=True)
st.markdown(h['ai_answer'] or '')
st.markdown('<div style="height:14px"></div>', unsafe_allow_html=True)
nav1, nav2, nav3 = st.columns([1, 1, 1])
with nav1:
if st.button("← 上一页", disabled=st.session_state.chat_page == 0):
st.session_state.chat_page -= 1
st.rerun()
with nav2:
st.markdown(
f'<div style="text-align:center;color:#64748b;font-size:13px;'
f'padding-top:8px;">第 {st.session_state.chat_page + 1} 页</div>',
unsafe_allow_html=True)
with nav3:
if st.button("下一页 →", disabled=len(history) < page_size):
st.session_state.chat_page += 1
st.rerun()
# ============================================================ # ============================================================
# 成员命名页 # 成员命名页
# ============================================================ # ============================================================
elif page == "👤 成员命名": elif page == "👤 成员命名":
st.title("👤 家庭成员命名") page_header('👤', '家庭成员命名', '为识别到的人物起名')
# 未命名成员
st.subheader("未命名人物")
conn = get_db_conn() conn = get_db_conn()
try: try:
cursor = conn.cursor() cursor = conn.cursor()
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:6px 0 14px 0;">未命名人物</div>', unsafe_allow_html=True)
cursor.execute( cursor.execute(
"""SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at, """SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at,
(SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count (SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count
@@ -324,22 +757,31 @@ elif page == "👤 成员命名":
unnamed = cursor.fetchall() unnamed = cursor.fetchall()
if not unnamed: if not unnamed:
st.info("所有人物已命名,或暂未发现新人物") st.markdown(
'<div class="fam-empty"><span class="fe-ico">✅</span>'
'所有人物已命名,或暂未发现新人物</div>', unsafe_allow_html=True)
else: else:
for m in unnamed: for m in unnamed:
col1, col2, col3 = st.columns([2, 2, 1]) col1, col2, col3 = st.columns([2, 1.6, 1])
with col1: with col1:
st.markdown(f"**{m['abstract_label']}**")
st.text(m['feature_description'] or '无特征描述')
first_seen = serialize_datetime(m['first_seen_at']) first_seen = serialize_datetime(m['first_seen_at'])
st.caption(f"首次出现: {first_seen} | 事件数: {m['event_count']}") st.markdown(
f'<div class="member-card">'
f'<div style="font-size:15px;font-weight:700;color:#8fb0ff;">'
f'{esc(m["abstract_label"])}</div>'
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;'
f'line-height:1.6;">{esc(m["feature_description"] or "无特征描述")}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
f'首次出现 {esc(first_seen)} · 事件数 {m["event_count"]}</div>'
f'</div>', unsafe_allow_html=True)
with col2: with col2:
real_name = st.text_input( real_name = st.text_input(
"输入名字", key=f"name_{m['abstract_label']}", "输入名字", key=f"name_{m['abstract_label']}",
placeholder=f"{m['abstract_label']}命名" placeholder=f"{m['abstract_label']}命名",
) label_visibility="collapsed")
with col3: with col3:
if st.button("命名", key=f"btn_{m['abstract_label']}"): if st.button("命名", key=f"btn_{m['abstract_label']}",
type="primary", use_container_width=True):
if real_name.strip(): if real_name.strip():
try: try:
resp = requests.post( resp = requests.post(
@@ -354,9 +796,8 @@ elif page == "👤 成员命名":
if resp.status_code == 200: if resp.status_code == 200:
result = resp.json() result = resp.json()
st.success( st.success(
f"命名成功!{m['abstract_label']} -> {real_name}" f"命名成功!{m['abstract_label']} {real_name}"
f"更新明细 {result.get('updated_event_details_count', 0)}" f"更新明细 {result.get('updated_event_details_count', 0)}")
)
st.rerun() st.rerun()
else: else:
st.error(f"命名失败: {resp.status_code} {resp.text}") st.error(f"命名失败: {resp.status_code} {resp.text}")
@@ -364,10 +805,10 @@ elif page == "👤 成员命名":
st.error(f"异常: {e}") st.error(f"异常: {e}")
else: else:
st.warning("请输入名字") st.warning("请输入名字")
st.divider() st.markdown('<div style="height:8px"></div>', unsafe_allow_html=True)
# 已命名成员 st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
st.subheader("已命名成员") 'margin:22px 0 14px 0;">已命名成员</div>', unsafe_allow_html=True)
cursor.execute( cursor.execute(
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by """SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
FROM family_members FROM family_members
@@ -377,17 +818,25 @@ elif page == "👤 成员命名":
named = cursor.fetchall() named = cursor.fetchall()
if not named: if not named:
st.info("暂无已命名成员") st.markdown(
'<div class="fam-empty">暂无已命名成员</div>', unsafe_allow_html=True)
else: else:
df = pd.DataFrame([{ cards = []
'抽象标识': m['abstract_label'], for m in named:
'真实名字': m['real_name'], cards.append(
'特征描述': m['feature_description'], f'<div class="member-card">'
'首次出现': serialize_datetime(m['first_seen_at']), f'<div style="font-size:15px;font-weight:700;color:#f1f5f9;">'
'命名时间': serialize_datetime(m['named_at']), f'{esc(m["real_name"])} '
'命名人': m['named_by'] f'<span style="font-size:11px;color:#64748b;font-weight:400;">'
} for m in named]) f'{esc(m["abstract_label"])}</span></div>'
st.dataframe(df, use_container_width=True, hide_index=True) f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;line-height:1.6;">'
f'{esc(m["feature_description"] or "")}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
f'首次出现 {esc(serialize_datetime(m["first_seen_at"]))} · '
f'命名于 {esc(serialize_datetime(m["named_at"]))}</div>'
f'</div>')
st.markdown(f'<div class="member-grid">{"".join(cards)}</div>',
unsafe_allow_html=True)
finally: finally:
conn.close() conn.close()
@@ -397,14 +846,14 @@ elif page == "👤 成员命名":
# 统计图表页 # 统计图表页
# ============================================================ # ============================================================
elif page == "📈 统计图表": elif page == "📈 统计图表":
st.title("📈 统计图表") page_header('📈', '统计图表', '模型来源 / 关注事件 / 任务状态')
conn = get_db_conn() conn = get_db_conn()
try: try:
cursor = conn.cursor() cursor = conn.cursor()
# compute_provider 分布 st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
st.subheader("模型来源分布") 'margin:6px 0 14px 0;">模型来源分布</div>', unsafe_allow_html=True)
try: try:
cursor.execute(""" cursor.execute("""
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
@@ -420,14 +869,13 @@ elif page == "📈 统计图表":
stats = cursor.fetchall() stats = cursor.fetchall()
if stats: if stats:
df_stats = pd.DataFrame(stats) st.bar_chart({r['provider']: r['count'] for r in stats})
st.bar_chart(df_stats.set_index('provider')['count'])
st.dataframe(df_stats, use_container_width=True, hide_index=True)
else: else:
st.info("暂无统计数据") st.markdown('<div class="fam-empty">暂无统计数据</div>',
unsafe_allow_html=True)
# 关注事件统计 st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
st.subheader("关注事件统计") 'margin:22px 0 14px 0;">关注事件统计</div>', unsafe_allow_html=True)
cursor.execute(""" cursor.execute("""
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
FROM event_details FROM event_details
@@ -437,13 +885,15 @@ elif page == "📈 统计图表":
""") """)
attention = cursor.fetchall() attention = cursor.fetchall()
if attention: if attention:
import pandas as pd
df_att = pd.DataFrame(attention) df_att = pd.DataFrame(attention)
st.dataframe(df_att, use_container_width=True, hide_index=True) st.dataframe(df_att, use_container_width=True, hide_index=True)
else: else:
st.info("暂无关注事件") st.markdown('<div class="fam-empty">暂无关注事件</div>',
unsafe_allow_html=True)
# 任务统计 st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
st.subheader("任务状态统计") 'margin:22px 0 14px 0;">任务状态统计</div>', unsafe_allow_html=True)
cursor.execute(""" cursor.execute("""
SELECT status, COUNT(*) AS count SELECT status, COUNT(*) AS count
FROM process_tasks FROM process_tasks
@@ -451,11 +901,10 @@ elif page == "📈 统计图表":
""") """)
task_stats = cursor.fetchall() task_stats = cursor.fetchall()
if task_stats: if task_stats:
df_tasks = pd.DataFrame(task_stats) st.bar_chart({r['status']: r['count'] for r in task_stats})
st.bar_chart(df_tasks.set_index('status')['count'])
st.dataframe(df_tasks, use_container_width=True, hide_index=True)
else: else:
st.info("暂无任务数据") st.markdown('<div class="fam-empty">暂无任务数据</div>',
unsafe_allow_html=True)
finally: finally:
conn.close() conn.close()