- person 字段是自由文本,一帧可为多人组合且带括号特征描述
('张三 (红色T恤), 汤圆, 人物3'),COUNT(DISTINCT person) 把每种
组合字符串当一个人物,全局误报 20 人。新增 resolve_persons:
去括号描述 -> 按中英逗号/顿号/斜杠拆分 -> family_members
抽象标签映射真名,去重后全局实际 6 人
- 导航从侧边栏 radio 改为顶部 segmented_control:窄屏下
Streamlit 侧边栏自动收起,页面入口藏进汉堡菜单导致
"成员命名/问答界面不见了";放主区域顶部任何屏宽可见
946 lines
38 KiB
Python
946 lines
38 KiB
Python
"""
|
||
FAM-UI - 家庭多模态智能监控系统前端 v2
|
||
|
||
深色监控面板主题 + 关键帧时间轴:
|
||
- 事件时间轴页: 左侧事件列表,右侧时间轴(时间点 + 关键帧 + 摘要)
|
||
- AI 对话 / 对话历史 / 成员命名 / 统计图表
|
||
"""
|
||
import os
|
||
import re
|
||
import sys
|
||
import json
|
||
import base64
|
||
import html as _html
|
||
import requests
|
||
import streamlit as st
|
||
import pymysql
|
||
import pymysql.cursors
|
||
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():
|
||
"""获取数据库连接"""
|
||
return pymysql.connect(
|
||
host=_db_cfg.get('host', '127.0.0.1'),
|
||
port=_db_cfg.get('port', 3306),
|
||
user=_db_cfg.get('user', 'root'),
|
||
password=_db_cfg.get('password', ''),
|
||
database=_db_cfg.get('database', 'sentinel_home_ai'),
|
||
charset='utf8mb4',
|
||
cursorclass=pymysql.cursors.DictCursor
|
||
)
|
||
|
||
|
||
def serialize_datetime(obj):
|
||
"""序列化 datetime"""
|
||
if hasattr(obj, 'isoformat'):
|
||
return obj.isoformat()
|
||
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 resolve_persons(person_str, alias_map):
|
||
"""解析一帧 person 字段为真实人物集合
|
||
|
||
person 是自由文本,可能为多人组合且带括号特征描述,如
|
||
'张三 (红色T恤, 白色裤子), 汤圆 (蓝色上衣), 人物3'。
|
||
统计真实人数需: 去括号描述 -> 按分隔符拆分 -> 抽象标签映射真名。
|
||
"""
|
||
s = re.sub(r'[((][^(())]*[))]', '', person_str or '')
|
||
out = set()
|
||
for n in re.split(r'[,,、/]+', s):
|
||
n = n.strip()
|
||
if n and n != '无人':
|
||
out.add(alias_map.get(n, n))
|
||
return out
|
||
|
||
|
||
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="家庭智能监控",
|
||
page_icon="🏠",
|
||
layout="wide",
|
||
initial_sidebar_state="expanded"
|
||
)
|
||
|
||
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.segmented_control(
|
||
"功能页面",
|
||
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 成员命名", "📈 统计图表"],
|
||
default="🕒 事件时间轴",
|
||
label_visibility="collapsed"
|
||
) or "🕒 事件时间轴"
|
||
|
||
# 侧边栏底部:任务队列状态
|
||
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 == "🕒 事件时间轴":
|
||
page_header('🕒', '事件时间轴', '关键时间点 · 视频帧 · 信息摘要')
|
||
|
||
with st.container():
|
||
col_date, col_sp = st.columns([1, 3])
|
||
with col_date:
|
||
date_filter = st.date_input("日期", value=None)
|
||
|
||
date_str = date_filter.isoformat() if date_filter else None
|
||
|
||
# 统计卡
|
||
try:
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"SELECT abstract_label, real_name FROM family_members "
|
||
"WHERE real_name IS NOT NULL AND is_active = TRUE")
|
||
alias_map = {r['abstract_label']: r['real_name'] for r in cursor.fetchall()}
|
||
|
||
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
|
||
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,))
|
||
s = cursor.fetchone() or {}
|
||
cursor.execute(
|
||
"""SELECT ed.person FROM event_details ed
|
||
JOIN monitor_events me ON me.event_id = ed.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
|
||
FROM monitor_events me
|
||
LEFT JOIN event_details ed ON ed.event_id = me.event_id
|
||
""")
|
||
s = cursor.fetchone() or {}
|
||
cursor.execute(
|
||
"SELECT ed.person FROM event_details ed "
|
||
"JOIN monitor_events me ON me.event_id = ed.event_id")
|
||
persons = set()
|
||
for r in cursor.fetchall():
|
||
persons |= resolve_persons(r['person'], alias_map)
|
||
s['ps'] = len(persons)
|
||
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()
|
||
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,
|
||
(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))
|
||
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,
|
||
(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))
|
||
events = cursor.fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
if not events:
|
||
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('%m-%d %H:%M') if ts else '--:--'
|
||
date_label = ts.strftime('%Y-%m-%d') if ts else ''
|
||
summary = (ev.get('global_summary') or '').strip()
|
||
if not summary:
|
||
summary = '暂无摘要'
|
||
summary_short = summary if len(summary) <= 30 else summary[:30] + '…'
|
||
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()
|
||
date_html = (f'<span style="color:#7dd3fc;font-weight:600;">{date_label}</span> · '
|
||
if date_label else '')
|
||
st.markdown(
|
||
f'<div style="font-size:11px;color:#64748b;margin:-4px 2px 12px 2px;'
|
||
f'line-height:1.5;">{date_html}{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 []))
|
||
|
||
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)
|
||
|
||
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""",
|
||
(selected_id,))
|
||
details = cursor.fetchall()
|
||
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 对话":
|
||
page_header('💬', 'AI 对话', '基于监控数据的智能问答')
|
||
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT DISTINCT real_name FROM family_members WHERE real_name IS NOT NULL AND is_active = TRUE")
|
||
named = [row['real_name'] for row in cursor.fetchall()]
|
||
finally:
|
||
conn.close()
|
||
|
||
col1, col2 = st.columns(2)
|
||
with col1:
|
||
queried_person = st.text_input("查询人物", value=named[0] if named else "")
|
||
with col2:
|
||
queried_date = st.date_input("查询日期", value=date.today())
|
||
|
||
if named:
|
||
quick_person = st.selectbox("快捷选择成员", [""] + named)
|
||
if quick_person:
|
||
queried_person = quick_person
|
||
|
||
quick_questions = [
|
||
f"{queried_person}今天干嘛了?",
|
||
f"{queried_person}有没有发生什么需要注意的事情?",
|
||
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"):
|
||
if not user_question.strip():
|
||
st.warning("请输入问题")
|
||
elif not queried_person.strip():
|
||
st.warning("请输入查询人物")
|
||
else:
|
||
with st.spinner("AI 正在思考..."):
|
||
try:
|
||
resp = requests.post(
|
||
f"{_core_url}/api/chat/ask",
|
||
json={
|
||
"question": user_question,
|
||
"queried_person": queried_person,
|
||
"queried_date": queried_date.isoformat()
|
||
},
|
||
timeout=120
|
||
)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
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'])
|
||
if data.get('context_summary'):
|
||
st.caption(f"上下文: {data['context_summary']}")
|
||
else:
|
||
st.error(f"请求失败: {resp.status_code} {resp.text}")
|
||
except requests.ConnectionError:
|
||
st.error(f"无法连接 FAM-Core ({_core_url})")
|
||
except Exception as e:
|
||
st.error(f"异常: {e}")
|
||
|
||
|
||
# ============================================================
|
||
# 对话历史页
|
||
# ============================================================
|
||
elif page == "📝 对话历史":
|
||
page_header('📝', '对话历史', '历史问答记录')
|
||
|
||
if 'chat_page' not in st.session_state:
|
||
st.session_state.chat_page = 0
|
||
|
||
page_size = 20
|
||
offset = st.session_state.chat_page * page_size
|
||
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"""SELECT chat_id, user_question, ai_answer, context_summary,
|
||
queried_date, queried_person, created_at
|
||
FROM chat_history
|
||
ORDER BY created_at DESC
|
||
LIMIT %s OFFSET %s""",
|
||
(page_size, offset)
|
||
)
|
||
history = cursor.fetchall()
|
||
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 == "👤 成员命名":
|
||
page_header('👤', '家庭成员命名', '为识别到的人物起名')
|
||
|
||
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
|
||
FROM family_members fm
|
||
WHERE fm.real_name IS NULL AND fm.is_active = TRUE
|
||
ORDER BY fm.first_seen_at ASC"""
|
||
)
|
||
unnamed = cursor.fetchall()
|
||
|
||
if not unnamed:
|
||
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, 1.6, 1])
|
||
with col1:
|
||
first_seen = serialize_datetime(m['first_seen_at'])
|
||
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']}命名",
|
||
label_visibility="collapsed")
|
||
with col3:
|
||
if st.button("命名", key=f"btn_{m['abstract_label']}",
|
||
type="primary", use_container_width=True):
|
||
if real_name.strip():
|
||
try:
|
||
resp = requests.post(
|
||
f"{_core_url}/api/member/name",
|
||
json={
|
||
"abstract_label": m['abstract_label'],
|
||
"real_name": real_name.strip(),
|
||
"named_by": "UI管理员"
|
||
},
|
||
timeout=10
|
||
)
|
||
if resp.status_code == 200:
|
||
result = resp.json()
|
||
st.success(
|
||
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}")
|
||
except Exception as e:
|
||
st.error(f"异常: {e}")
|
||
else:
|
||
st.warning("请输入名字")
|
||
st.markdown('<div style="height:8px"></div>', unsafe_allow_html=True)
|
||
|
||
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
|
||
WHERE real_name IS NOT NULL AND is_active = TRUE
|
||
ORDER BY named_at DESC"""
|
||
)
|
||
named = cursor.fetchall()
|
||
|
||
if not named:
|
||
st.markdown(
|
||
'<div class="fam-empty">暂无已命名成员</div>', unsafe_allow_html=True)
|
||
else:
|
||
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()
|
||
|
||
|
||
# ============================================================
|
||
# 统计图表页
|
||
# ============================================================
|
||
elif page == "📈 统计图表":
|
||
page_header('📈', '统计图表', '模型来源 / 关注事件 / 任务状态')
|
||
|
||
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)
|
||
try:
|
||
cursor.execute("""
|
||
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
|
||
FROM monitor_events,
|
||
JSON_TABLE(compute_provider, '$[*]'
|
||
COLUMNS(item VARCHAR(50) PATH '$')
|
||
) AS jt
|
||
GROUP BY provider ORDER BY count DESC
|
||
""")
|
||
stats = cursor.fetchall()
|
||
except Exception:
|
||
cursor.execute("SELECT compute_provider, COUNT(*) AS count FROM monitor_events GROUP BY compute_provider")
|
||
stats = cursor.fetchall()
|
||
|
||
if stats:
|
||
st.bar_chart({r['provider']: r['count'] for r in stats})
|
||
else:
|
||
st.markdown('<div class="fam-empty">暂无统计数据</div>',
|
||
unsafe_allow_html=True)
|
||
|
||
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
|
||
WHERE is_attention_event = TRUE
|
||
GROUP BY DATE(frame_timestamp), person
|
||
ORDER BY date DESC
|
||
""")
|
||
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.markdown('<div class="fam-empty">暂无关注事件</div>',
|
||
unsafe_allow_html=True)
|
||
|
||
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
|
||
GROUP BY status
|
||
""")
|
||
task_stats = cursor.fetchall()
|
||
if task_stats:
|
||
st.bar_chart({r['status']: r['count'] for r in task_stats})
|
||
else:
|
||
st.markdown('<div class="fam-empty">暂无任务数据</div>',
|
||
unsafe_allow_html=True)
|
||
|
||
finally:
|
||
conn.close()
|