refactor(fam-ui): 重构第三阶段 - Streamlit 换成 Vue3+Vite+Tailwind 完全重写

范围变更:原计划是给 Streamlit 界面做视觉美化,用户中途要求换新框架完全重新实现。
最终方案:Vue 3 + Vite + Tailwind CSS v4,本地 npm run build 出静态文件,NAS 不装
Node.js,由 fam-core 的 Flask 直接提供(send_from_directory),原来独立跑在 :8501
的 Streamlit 进程整个退休,前端和 API 合并到 fam-core 的 :8000 一个进程。

fam-core 新增只读 API(ui_api.py):全部包装 db_layer.py 里已有的查询函数,没有
新写查询逻辑(除了下面两处真实缺口)。新增 static_app.py 做 SPA 静态文件服务
(assets 直出 + 非 API 路径回退 index.html 供前端路由接管),app.py 注册顺序上
必须排在其他 /api/* 蓝图之后。

顺带补的两个功能缺口(旧 Streamlit 版本自己绕开 db_layer 写了裸 SQL 才有的功能,
db_layer 本身不支持):
- get_chat_history 补 offset 参数(分页)
- 新增 get_attention_events(统计图表页"关注事件"表格)

fam-ui 完全重写为 Vue 3 项目:7 个页面 1:1 迁移功能(事件时间轴/AI对话/对话历史/
人物管理/统计图表/模型统计/服务状态),深色主题设计系统(Inter+JetBrains Mono
字体、蓝紫渐变强调色、语义色 token)。用本地 npm run dev 代理到真实 NAS 数据做
了完整联调,过程中发现并修了两个真 bug:
- URLSearchParams 把 undefined 转成字符串 "undefined" 传给后端,导致日期筛选失效
- MariaDB SUM() 返回 Decimal,Flask 默认序列化成字符串,前端字符串拼接出乱码数字
  (ui_api.py::_ser 统一转 int/float 修复)

移动端 H5 补了响应式:原来的固定宽度侧边栏 + flex-wrap 导航在手机宽度下会把每个
按钮挤到文字逐字换行;改成 lg 以上桌面侧边栏、lg 以下移动端顶栏 + 横向可滑动导航
胶囊;人物管理页命名表单(输入框+命名按钮+合并下拉)在窄屏下改为纵向堆叠。

已部署 NAS 并验证:curl 确认 index.html/assets/深层路由/API 路由全部 200;
Browser 工具在桌面宽度和手机宽度下把 7 个页面点了一遍(含真实提问一次 AI 对话、
人物头像/事件缩略图加载、命名合并表单),旧 Streamlit 进程已停止。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-22 06:48:25 +08:00
parent e56d362d73
commit 7579782daf
36 changed files with 3176 additions and 1306 deletions

View File

@@ -20,15 +20,20 @@ from .oracle_sync import get_sync
from .chat_handler.chat_handler import chat_bp
from .member_manager.member_manager import member_bp
from .img_proxy import img_bp
from .ui_api import ui_bp
from .static_app import static_bp
logger = setup_logger('fam-core.app')
app = Flask(__name__)
# 注册蓝图
# 注册蓝图/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由
# Vue Router history 模式回退 index.html排在前面会吞掉 API 请求。
app.register_blueprint(chat_bp)
app.register_blueprint(member_bp)
app.register_blueprint(img_bp)
app.register_blueprint(ui_bp)
app.register_blueprint(static_bp)
# 健康检查
@app.route('/health', methods=['GET'])

View File

@@ -121,8 +121,9 @@ def chat_history():
date = request.args.get('date')
person = request.args.get('person')
limit = int(request.args.get('limit', 20))
offset = int(request.args.get('offset', 0))
history = db_layer.get_chat_history(limit=limit, date_filter=date, person_filter=person)
history = db_layer.get_chat_history(limit=limit, offset=offset, date_filter=date, person_filter=person)
for h in history:
for k, v in h.items():
if hasattr(v, 'isoformat'):

View File

@@ -417,6 +417,23 @@ def set_sync_cursor(value: str):
# 统计
# ============================================================
def get_attention_events(limit: int = 200) -> List[Dict]:
"""需关注事件列表(供统计图表页展示日期 + 涉及人物),按录制日期倒序。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"""SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
e.person_list_json
FROM sync_events e JOIN sync_videos v ON e.video_id=v.id
WHERE e.is_attention_event = 1
ORDER BY ev_date DESC LIMIT %s""",
(limit,))
return cur.fetchall()
finally:
conn.close()
def get_sync_stats(date_str: str = None) -> Dict:
"""概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。
@@ -488,8 +505,8 @@ def insert_chat_history(user_question: str, ai_answer: str,
conn.close()
def get_chat_history(limit=20, date_filter=None, person_filter=None) -> List[Dict]:
"""获取对话历史"""
def get_chat_history(limit=20, offset=0, date_filter=None, person_filter=None) -> List[Dict]:
"""获取对话历史(分页)"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
@@ -502,9 +519,9 @@ def get_chat_history(limit=20, date_filter=None, person_filter=None) -> List[Dic
conditions.append("queried_person = %s")
params.append(person_filter)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
params.append(limit)
params.extend([limit, offset])
cur.execute(
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s",
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s OFFSET %s",
params
)
return cur.fetchall()

View File

@@ -0,0 +1,32 @@
"""
Static-App - 提供 Vue 前端构建产物(新架构 v3fam-ui 不再单独起 Streamlit 进程)
fam-ui/dist/ 是本地 `npm run build` 出的静态文件,部署时整个目录拷到 NAS。
本模块只做两件事: 命中真实静态文件(如 /assets/xxx.js直接下发其余任何路径
Vue Router history 模式的前端路由)一律回退到 index.html由浏览器端路由接管。
必须最后注册app.py 里排在 chat_bp/member_bp/img_bp/ui_bp 之后),否则这里的
通配路由会先于 /api/* 匹配,把 API 请求也吞成 index.html。
"""
import os
from flask import Blueprint, send_from_directory, abort
DIST_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'..', 'fam-ui', 'dist'
)
DIST_DIR = os.path.normpath(DIST_DIR)
static_bp = Blueprint('static_app', __name__)
@static_bp.route('/', defaults={'path': ''})
@static_bp.route('/<path:path>')
def spa(path):
if not os.path.isdir(DIST_DIR):
abort(404, "前端构建产物不存在,请先 npm run build 并部署 fam-ui/dist")
full = os.path.join(DIST_DIR, path)
if path and os.path.isfile(full):
return send_from_directory(DIST_DIR, path)
return send_from_directory(DIST_DIR, 'index.html')

View File

@@ -0,0 +1,167 @@
"""
UI-API - Vue 前端只读数据接口(新架构 v3Vue SPA 取代 Streamlit
全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑。人物按 canonical_name
聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管渲染,不重复业务规则)。
/api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity浏览器不直连
Oracle避免 token 暴露),写法照抄 img_proxy.py 的模式:复用 oracle_sync.get_sync()
已解析好的 base_url/token不再单独存一份配置。
"""
import re
from datetime import datetime
from decimal import Decimal
from flask import Blueprint, request, jsonify
from .logger import setup_logger
from . import db_layer
from .oracle_sync import get_sync
logger = setup_logger('fam-core.ui_api')
ui_bp = Blueprint('ui_api', __name__)
_BRACKET_RE = re.compile(r'[(][^()]*[)]')
def _ser(v):
"""递归序列化供 jsonify 用: datetime -> isoformatDecimal -> int/float
MariaDB 的 SUM()/AVG() 聚合返回 DecimalFlask 默认编码器会把它偷偷转成
字符串而不是数字,前端拿到 "284" 这种字符串做加法会变成字符串拼接出乱码)。
"""
if isinstance(v, dict):
return {k: _ser(x) for k, x in v.items()}
if isinstance(v, list):
return [_ser(x) for x in v]
if isinstance(v, Decimal):
return int(v) if v == v.to_integral_value() else float(v)
if hasattr(v, 'isoformat'):
return v.isoformat()
return v
@ui_bp.route('/api/ui/videos', methods=['GET'])
def videos():
"""视频会话列表(事件时间轴左侧),支持 date + page 分页。"""
date_filter = request.args.get('date') or None
page = max(0, request.args.get('page', 0, type=int))
page_size = 15
rows = db_layer.get_sync_videos(limit=page_size, offset=page * page_size,
date_filter=date_filter)
return jsonify({"videos": _ser(rows), "page": page, "page_size": page_size}), 200
@ui_bp.route('/api/ui/videos/<int:video_id>', methods=['GET'])
def video_detail(video_id):
"""单个视频会话详情 + 时间线事件列表(事件时间轴右侧)。"""
video = db_layer.get_sync_video(video_id)
if not video:
return jsonify({"error": "视频不存在"}), 404
events = db_layer.get_sync_events_for_video(video_id)
return jsonify({"video": _ser(video), "events": _ser(events)}), 200
@ui_bp.route('/api/ui/stats', methods=['GET'])
def stats():
"""统计卡:视频/事件/人物/关注数(可选按日期过滤)。"""
date_filter = request.args.get('date') or None
return jsonify(_ser(db_layer.get_sync_stats(date_filter))), 200
def _clean_person(s: str) -> str:
"""剥离全角/半角括号备注(如 '人物A别名人物B' -> '人物A')。"""
return _BRACKET_RE.sub('', str(s or '')).strip()
@ui_bp.route('/api/ui/people', methods=['GET'])
def people():
"""人物列表:按 canonical_name 聚合(未命名按 label 自身聚合)。
对应旧 Streamlit 版本 app.py 里的分组逻辑,原样搬到服务端。
"""
rows = db_layer.get_sync_people()
groups = {}
for p in rows:
key = p.get('canonical_name') or p['label']
g = groups.setdefault(key, {
'display': key, 'is_named': bool(p.get('canonical_name')),
'labels': [], 'appearances': 0, 'first_seen': None,
'features_json': None, 'display_uid': None,
})
g['labels'].append(p['label'])
g['appearances'] += (p.get('appearances') or 0)
fs = p.get('first_seen')
if fs and (g['first_seen'] is None or str(fs) < str(g['first_seen'])):
g['first_seen'] = fs
if not g['features_json'] and p.get('features_json'):
g['features_json'] = p['features_json']
if not g['display_uid'] and p.get('display_uid'):
g['display_uid'] = p['display_uid']
out = sorted(groups.values(), key=lambda g: -g['appearances'])
all_labels = [p['label'] for p in rows]
return jsonify({"groups": _ser(out), "all_labels": all_labels}), 200
@ui_bp.route('/api/ui/attention-events', methods=['GET'])
def attention_events():
"""需关注事件列表(统计图表页),已按人物去重规则清洗好 people 字段。"""
rows = db_layer.get_attention_events()
out = []
for r in rows:
persons = r.get('person_list_json')
if persons:
import json
try:
items = json.loads(persons) if isinstance(persons, str) else persons
except (ValueError, TypeError):
items = []
names = sorted({_clean_person(p) for p in (items or []) if _clean_person(p) and _clean_person(p) != '无人'})
else:
names = []
out.append({"date": r.get('ev_date'), "persons": names or ['无人']})
return jsonify({"events": _ser(out)}), 200
@ui_bp.route('/api/ui/named-members', methods=['GET'])
def named_members():
"""已命名成员真名列表AI 对话页快捷选择)。"""
return jsonify({"members": db_layer.get_sync_named_members()}), 200
@ui_bp.route('/api/ui/model-stats', methods=['GET'])
def model_stats():
"""云端模型调用统计:按模型聚合 + 最近调用明细。"""
agg = db_layer.get_sync_model_calls_stats()
calls = db_layer.get_sync_model_calls(limit=100)
return jsonify({"aggregate": _ser(agg), "recent_calls": _ser(calls)}), 200
@ui_bp.route('/api/ui/service-status', methods=['GET'])
def service_status():
"""服务状态页NAS 同步状态 + Oracle 实时活动代理token 不下发浏览器)。"""
sync = get_sync()
nas_status = sync.status()
oracle_data = None
oracle_error = None
if sync.base_url and sync.token:
import requests
try:
r = requests.get(f"{sync.base_url}/api/oracle/activity",
params={'token': sync.token}, timeout=15)
if r.status_code == 200:
oracle_data = r.json()
else:
oracle_error = f"Oracle activity HTTP {r.status_code}"
except Exception as e:
oracle_error = f"连接 Oracle 失败: {e}"
else:
oracle_error = "未配置 oracle_sync.base_url/token"
return jsonify({
"nas_sync": nas_status,
"oracle": oracle_data,
"oracle_error": oracle_error,
}), 200