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:
@@ -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'])
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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()
|
||||
|
||||
32
fam-core/src/fam_core/static_app.py
Normal file
32
fam-core/src/fam_core/static_app.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Static-App - 提供 Vue 前端构建产物(新架构 v3:fam-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')
|
||||
167
fam-core/src/fam_core/ui_api.py
Normal file
167
fam-core/src/fam_core/ui_api.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
UI-API - Vue 前端只读数据接口(新架构 v3:Vue 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 -> isoformat;Decimal -> int/float
|
||||
(MariaDB 的 SUM()/AVG() 聚合返回 Decimal,Flask 默认编码器会把它偷偷转成
|
||||
字符串而不是数字,前端拿到 "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
|
||||
Reference in New Issue
Block a user