@@ -1,28 +1,32 @@
"""
FAM-UI - 家庭多模态智能监控系统前端
FAM-UI - 家庭多模态智能监控系统前端 v2
Streamlit 直读 MariaDB, 展示 :
- 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页
深色监控面板主题 + 关键帧时间轴 :
- 事件时间轴页: 左侧事件列表,右侧时间轴(时间点 + 关键帧 + 摘要)
- AI 对话 / 对话历史 / 成员命名 / 统计图表
"""
import os
import sys
import json
import base64
import html as _html
import requests
import streamlit as st
import pymysql
import pymysql . cursors
import pandas as pd
import json
from datetime import datetime , date
# 添加共享模块路径
sys . path . insert ( 0 , os . path . join ( os . path . dirname ( __file__ ) , ' .. ' ) )
from config_loader import load_config
# 加载配置
_cfg = load_config ( )
_core_url = _cfg . get ( ' core_url ' , ' http://127.0.0.1:8000 ' )
_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 ( ) :
@@ -45,8 +49,30 @@ def serialize_datetime(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 (
page_title = " 家庭智能监控 " ,
@@ -55,136 +81,536 @@ st.set_page_config(
initial_sidebar_state = " expanded "
)
# 侧边栏导航
st . sidebar . title ( " 🏠 家庭智能监控 " )
GLOBAL_CSS = """
<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 ( " 功能页面 " , [
" 📊 事件列表 " ,
" 🕒 事件时间轴 " ,
" 💬 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 == " 📊 事件列表 " :
st . title ( " 📊 监控事件列表 " )
if page == " 🕒 事件时间轴 " :
page_header ( ' 🕒 ' , ' 事件时间轴 ' , ' 关键时间点 · 视频帧 · 信息摘要 ' )
# 日期筛选
col1 , col2 = st . columns ( [ 1 , 3 ] )
with col1 :
date_filter = st . date_input ( " 日期筛选 " , value = None )
with st . container ( ) :
col_date , col_sp = st . columns ( [ 1 , 3 ] )
with col_date :
date_filter = st . date_input ( " 日期 " , value = None )
# 分页
page_size = 20
date_str = date_filter . isoformat ( ) if date_filter else None
# 统计卡
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 :
st . session_state . event_page = 0
offset = st . session_state . event_page * page_size
conn = get_db_conn ( )
try :
cursor = conn . cursor ( )
# 查询条件
date_str = date_filter . isoformat ( ) if date_filter else None
if date_str :
cursor . execute (
""" 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
FROM monitor_events me
WHERE DATE(me.event_start_time) = %s
ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s """ ,
( date_str , page_size , offset )
)
( date_str , page_size , offset ) )
else :
cursor . execute (
""" 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
FROM monitor_events me
ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s """ ,
( page_size , offset )
)
( page_size , offset ) )
events = cursor . fetchall ( )
finally :
conn . close ( )
if not events :
st . info ( " 暂无事件数据 " )
st . markdown (
' <div class= " fam-empty " ><span class= " fe-ico " >🗓</span> '
' 该日期暂无监控事件</div> ' , unsafe_allow_html = True )
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 :
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 ' , ' [] ' )
if isinstance ( providers , str ) :
try :
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 ( ) :
col1 , col2 , col3 = st . columns ( [ 2 , 1 , 1 ] )
with col1 :
start_time = serialize_datetime ( ev [ ' event_start_time ' ] )
end_time = serialize_datetime ( ev [ ' event_end_time ' ] )
st . markdown ( f " ** { ev [ ' camera_name ' ] or ' 未知摄像头 ' } ** | { start_time } ~ { end_time } " )
st . markdown ( f " _ { ev [ ' global_summary ' ] } _ " )
with col2 :
st . markdown ( f " 明细: { ev [ ' detail_count ' ] } 条 " )
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 ' ]
st . markdown (
f ' <div class= " ev-head " > '
f ' <div class= " ev-title " > '
f ' <span> { esc ( ev . get ( " camera_name " ) or " 未知摄像头 " ) } </span> '
f ' <span class= " bdg bdg-cam " >事件 # { ev [ " event_id " ] } </span> '
f ' { model_badges } </div> '
f ' <div class= " ev-range " >⏱ { esc ( range_str ) } </div> '
f ' <div class= " ev-summary " > { esc ( ev . get ( " global_summary " ) or " 暂无全局摘要 " ) } </div> '
f ' </div> ' , unsafe_allow_html = True )
# 展开明细
if st . session_state . get ( ' selected_event_id ' ) == ev [ ' event_id ' ] :
conn = get_db_conn ( )
try :
cursor = conn . cursor ( )
cursor . execute (
""" SELECT frame_index, frame_timestamp, camera_name, person,
action, clothing, is_attention_event, source_providers
FROM event_details
WHERE event_id = %s
ORDER BY frame_index ASC """ ,
( ev [ ' event_id ' ] , )
)
( selected_id , ) )
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 :
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 对话页
# ============================================================
elif page == " 💬 AI 对话 " :
st . title ( " 💬 AI 对话 " )
page_header ( ' 💬 ' , ' AI 对话 ' , ' 基于监控数据的智能问答 ' )
# 快捷人物按钮
conn = get_db_conn ( )
try :
cursor = conn . cursor ( )
@@ -199,7 +625,6 @@ elif page == "💬 AI 对话":
with col2 :
queried_date = st . date_input ( " 查询日期 " , value = date . today ( ) )
# 快捷提问
if named :
quick_person = st . selectbox ( " 快捷选择成员 " , [ " " ] + named )
if quick_person :
@@ -211,7 +636,6 @@ elif page == "💬 AI 对话":
f " 今天 { queried_person } 的活动时间线是什么? " ,
]
selected_quick = st . selectbox ( " 快捷提问 " , [ " 自定义 " ] + quick_questions )
user_question = st . text_area ( " 你的问题 " , value = selected_quick if selected_quick != " 自定义 " else " " )
if st . button ( " 提问 " , type = " primary " ) :
@@ -233,9 +657,15 @@ elif page == "💬 AI 对话":
)
if resp . status_code == 200 :
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 . caption ( f " 上下文: { data . get ( ' context_summary ' , ' ' ) } " )
if data . get ( ' context_summary ' ) :
st . caption ( f " 上下文: { data [ ' context_summary ' ] } " )
else :
st . error ( f " 请求失败: { resp . status_code } { resp . text } " )
except requests . ConnectionError :
@@ -248,7 +678,7 @@ elif page == "💬 AI 对话":
# 对话历史页
# ============================================================
elif page == " 📝 对话历史 " :
st . title ( " 📝 对话历史 " )
page_header ( ' 📝 ' , ' 对话历史 ' , ' 历史问答记录 ' )
if ' chat_page ' not in st . session_state :
st . session_state . chat_page = 0
@@ -268,52 +698,55 @@ elif page == "📝 对话历史":
( page_size , offset )
)
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 :
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 == " 👤 成员命名 " :
st . title ( " 👤 家庭成员命名 " )
page_header ( ' 👤 ' , ' 家庭成员命名 ' , ' 为识别到的人物起名 ' )
# 未命名成员
st . subheader ( " 未命名人物 " )
conn = get_db_conn ( )
try :
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 (
""" 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
@@ -324,22 +757,31 @@ elif page == "👤 成员命名":
unnamed = cursor . fetchall ( )
if not unnamed :
st . info ( " 所有人物已命名,或暂未发现新人物 " )
st . markdown (
' <div class= " fam-empty " ><span class= " fe-ico " >✅</span> '
' 所有人物已命名,或暂未发现新人物</div> ' , unsafe_allow_html = True )
else :
for m in unnamed :
col1 , col2 , col3 = st . columns ( [ 2 , 2 , 1 ] )
col1 , col2 , col3 = st . columns ( [ 2 , 1.6 , 1 ] )
with col1 :
st . markdown ( f " ** { m [ ' abstract_label ' ] } ** " )
st . text ( m [ ' feature_description ' ] or ' 无特征描述 ' )
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 :
real_name = st . text_input (
" 输入名字 " , key = f " name_ { m [ ' abstract_label ' ] } " ,
placeholder = f " 为 { m [ ' abstract_label ' ] } 命名 "
)
placeholder = f " 为 { m [ ' abstract_label ' ] } 命名 " ,
label_visibility = " collapsed " )
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 ( ) :
try :
resp = requests . post (
@@ -354,9 +796,8 @@ elif page == "👤 成员命名":
if resp . status_code == 200 :
result = resp . json ( )
st . success (
f " 命名成功! { m [ ' abstract_label ' ] } -> { real_name } , "
f " 更新明细 { result . get ( ' updated_event_details_count ' , 0 ) } 条 "
)
f " 命名成功! { m [ ' abstract_label ' ] } → { real_name } , "
f " 更新明细 { result . get ( ' updated_event_details_count ' , 0 ) } 条 " )
st . rerun ( )
else :
st . error ( f " 命名失败: { resp . status_code } { resp . text } " )
@@ -364,10 +805,10 @@ elif page == "👤 成员命名":
st . error ( f " 异常: { e } " )
else :
st . warning ( " 请输入名字 " )
st . divider ( )
st . markdown ( ' <div style= " height:8px " ></div> ' , unsafe_allow_html = True )
# 已命名成员
st . subheader ( " 已命名成员 " )
st . markdown ( ' <div style= " font-size:15px;font-weight:700;color:#f1f5f9; '
' margin:22px 0 14px 0; " >已命名成员</div> ' , unsafe_allow_html = True )
cursor . execute (
""" SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
FROM family_members
@@ -377,17 +818,25 @@ elif page == "👤 成员命名":
named = cursor . fetchall ( )
if not named :
st . info ( " 暂无已命名成员 " )
st . markdown (
' <div class= " fam-empty " >暂无已命名成员</div> ' , unsafe_allow_html = True )
else :
df = pd . DataFrame ( [ {
' 抽象标识 ' : m [ ' abstract_label ' ] ,
' 真实名字 ' : m [ ' real_name ' ] ,
' 特征描述 ' : m [ ' feature_description ' ] ,
' 首次出现 ' : serialize_datetime ( m [ ' first_seen_at ' ] ) ,
' 命名时间 ' : serialize_datetime ( m [ ' named_at ' ] ) ,
' 命名人 ' : m [ ' named_by ' ]
} for m in named ] )
st . dataframe ( df , use_container_width = True , hide_index = True )
cards = [ ]
for m in named :
cards . append (
f ' <div class= " member-card " > '
f ' <div style= " font-size:15px;font-weight:700;color:#f1f5f9; " > '
f ' { esc ( m [ " real_name " ] ) } '
f ' <span style= " font-size:11px;color:#64748b;font-weight:400; " > '
f ' { esc ( m [ " abstract_label " ] ) } </span></div> '
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 :
conn . close ( )
@@ -397,14 +846,14 @@ elif page == "👤 成员命名":
# 统计图表页
# ============================================================
elif page == " 📈 统计图表 " :
st . title ( " 📈 统计图表 " )
page_header ( ' 📈 ' , ' 统计图表 ' , ' 模型来源 / 关注事件 / 任务状态 ' )
conn = get_db_conn ( )
try :
cursor = conn . cursor ( )
# compute_provider 分布
st . subheader ( " 模型来源分布 " )
st . markdown ( ' <div style= " font-size:15px;font-weight:700;color:#f1f5f9; '
' margin:6px 0 14px 0; " >模型来源分布</div> ' , unsafe_allow_html = True )
try :
cursor . execute ( """
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, ' $ ' )) AS provider, COUNT(*) AS count
@@ -420,14 +869,13 @@ elif page == "📈 统计图表":
stats = cursor . fetchall ( )
if stats :
df_stats = pd . DataFrame ( stats )
st . bar_chart ( df_stats . set_index ( ' provider ' ) [ ' count ' ] )
st . dataframe ( df_stats , use_container_width = True , hide_index = True )
st . bar_chart ( { r [ ' provider ' ] : r [ ' count ' ] for r in stats } )
else :
st . info ( " 暂无统计数据 " )
st . markdown ( ' <div class= " fam-empty " >暂无统计数据</div> ' ,
unsafe_allow_html = True )
# 关注事件统计
st . subheader ( " 关注事件统计 " )
st . markdown ( ' <div style= " font-size:15px;font-weight:700;color:#f1f5f9; '
' margin:22px 0 14px 0; " >关注事件统计</div> ' , unsafe_allow_html = True )
cursor . execute ( """
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
FROM event_details
@@ -437,13 +885,15 @@ elif page == "📈 统计图表":
""" )
attention = cursor . fetchall ( )
if attention :
import pandas as pd
df_att = pd . DataFrame ( attention )
st . dataframe ( df_att , use_container_width = True , hide_index = True )
else :
st . info ( " 暂无关注事件 " )
st . markdown ( ' <div class= " fam-empty " >暂无关注事件</div> ' ,
unsafe_allow_html = True )
# 任务统计
st . subheader ( " 任务状态统计 " )
st . markdown ( ' <div style= " font-size:15px;font-weight:700;color:#f1f5f9; '
' margin:22px 0 14px 0; " >任务状态统计</div> ' , unsafe_allow_html = True )
cursor . execute ( """
SELECT status, COUNT(*) AS count
FROM process_tasks
@@ -451,11 +901,10 @@ elif page == "📈 统计图表":
""" )
task_stats = cursor . fetchall ( )
if task_stats :
df_tasks = pd . DataFrame ( task_stats )
st . bar_chart ( df_tasks . set_index ( ' status ' ) [ ' count ' ] )
st . dataframe ( df_tasks , use_container_width = True , hide_index = True )
st . bar_chart ( { r [ ' status ' ] : r [ ' count ' ] for r in task_stats } )
else :
st . info ( " 暂无任务数据 " )
st . markdown ( ' <div class= " fam-empty " >暂无任务数据</div> ' ,
unsafe_allow_html = True )
finally :
conn . close ( )