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:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "fam-ui-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "fam-ui", "run", "dev", "--", "--host"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -20,15 +20,20 @@ from .oracle_sync import get_sync
|
|||||||
from .chat_handler.chat_handler import chat_bp
|
from .chat_handler.chat_handler import chat_bp
|
||||||
from .member_manager.member_manager import member_bp
|
from .member_manager.member_manager import member_bp
|
||||||
from .img_proxy import img_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')
|
logger = setup_logger('fam-core.app')
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# 注册蓝图
|
# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由
|
||||||
|
# (Vue Router history 模式回退 index.html),排在前面会吞掉 API 请求。
|
||||||
app.register_blueprint(chat_bp)
|
app.register_blueprint(chat_bp)
|
||||||
app.register_blueprint(member_bp)
|
app.register_blueprint(member_bp)
|
||||||
app.register_blueprint(img_bp)
|
app.register_blueprint(img_bp)
|
||||||
|
app.register_blueprint(ui_bp)
|
||||||
|
app.register_blueprint(static_bp)
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
@app.route('/health', methods=['GET'])
|
@app.route('/health', methods=['GET'])
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ def chat_history():
|
|||||||
date = request.args.get('date')
|
date = request.args.get('date')
|
||||||
person = request.args.get('person')
|
person = request.args.get('person')
|
||||||
limit = int(request.args.get('limit', 20))
|
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 h in history:
|
||||||
for k, v in h.items():
|
for k, v in h.items():
|
||||||
if hasattr(v, 'isoformat'):
|
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:
|
def get_sync_stats(date_str: str = None) -> Dict:
|
||||||
"""概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。
|
"""概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。
|
||||||
|
|
||||||
@@ -488,8 +505,8 @@ def insert_chat_history(user_question: str, ai_answer: str,
|
|||||||
conn.close()
|
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()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
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")
|
conditions.append("queried_person = %s")
|
||||||
params.append(person_filter)
|
params.append(person_filter)
|
||||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
params.append(limit)
|
params.extend([limit, offset])
|
||||||
cur.execute(
|
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
|
params
|
||||||
)
|
)
|
||||||
return cur.fetchall()
|
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
|
||||||
24
fam-ui/.gitignore
vendored
Normal file
24
fam-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
5
fam-ui/README.md
Normal file
5
fam-ui/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# FAM-UI 配置文件 (NAS 端) - 新架构 v2(2026-08-21)
|
|
||||||
# NAS 仅作管理后台,前端读本地 MariaDB 同步镜像;视频缩略图经 Oracle 带 token 接口获取。
|
|
||||||
|
|
||||||
core_url: "http://127.0.0.1:8000"
|
|
||||||
|
|
||||||
# 甲骨文 FAM-Edge 地址(视频缩略图接口,与同步同 token)
|
|
||||||
oracle_url: "http://129.146.203.203:5000"
|
|
||||||
oracle_token: "${ORACLE_SYNC_TOKEN}"
|
|
||||||
|
|
||||||
database:
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: 3306
|
|
||||||
user: "root"
|
|
||||||
password: "iLoveJava5!"
|
|
||||||
database: "sentinel_home_ai"
|
|
||||||
unix_socket: "/run/mysqld/mysqld10.sock"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# FAM-UI 配置文件 (NAS 端)
|
|
||||||
# 复制此文件为 config.yaml 并修改实际值
|
|
||||||
|
|
||||||
core_url: "http://127.0.0.1:8000" # FAM-Core 地址
|
|
||||||
|
|
||||||
database:
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: 3306
|
|
||||||
user: "root"
|
|
||||||
password: ""
|
|
||||||
database: "sentinel_home_ai"
|
|
||||||
13
fam-ui/index.html
Normal file
13
fam-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22%3E%3Ctext y=%22.9em%22 font-size=%2290%22%3E%F0%9F%8F%A0%3C/text%3E%3C/svg%3E" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>家庭智能监控</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1671
fam-ui/package-lock.json
generated
Normal file
1671
fam-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
fam-ui/package.json
Normal file
21
fam-ui/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "fam-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"vite": "^8.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
streamlit>=1.30.0
|
|
||||||
PyMySQL>=1.1.0
|
|
||||||
pandas>=2.1.0
|
|
||||||
requests>=2.31.0
|
|
||||||
PyYAML>=6.0
|
|
||||||
65
fam-ui/src/App.vue
Normal file
65
fam-ui/src/App.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { api, fmtDateTime } from './api.js'
|
||||||
|
import { navItems } from './router.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const sync = ref(null)
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const data = await api.status()
|
||||||
|
sync.value = data.sync
|
||||||
|
} catch {
|
||||||
|
sync.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
onMounted(() => {
|
||||||
|
refreshStatus()
|
||||||
|
timer = setInterval(refreshStatus, 30000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen flex-col lg:flex-row">
|
||||||
|
<!-- 桌面端左侧栏:品牌 + 同步状态。lg 以下隐藏,改用下面的移动端顶栏 -->
|
||||||
|
<aside class="hidden w-64 shrink-0 border-r border-border bg-[#090c12] px-4 py-6 lg:block">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="text-[17px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
|
||||||
|
<div class="mt-1 text-[11px] text-text-mute">SENTINEL HOME AI · 管理后台</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="sync" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
|
||||||
|
<b class="text-[#f7f9fc]">同步状态</b><br />
|
||||||
|
状态 <span :class="sync.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ sync.running ? '同步中' : '未运行' }}</span><br />
|
||||||
|
最近 <b class="text-[#ccd5e1]">{{ sync.last_sync_at ? fmtDateTime(sync.last_sync_at) : '—' }}</b><br />
|
||||||
|
本次增量 {{ sync.last_count ? `视频+${sync.last_count[0]} / 事件+${sync.last_count[1]} / 人物+${sync.last_count[2]}` : '—' }}<br />
|
||||||
|
游标 {{ sync.cursor ? fmtDateTime(sync.cursor) : '(全量)' }}
|
||||||
|
<div v-if="sync.last_error" class="font-semibold text-danger">⚠ {{ sync.last_error }}</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 移动端顶栏:品牌 + 简要同步指示灯,lg 以上隐藏(用左侧栏代替) -->
|
||||||
|
<header class="flex items-center justify-between border-b border-border bg-[#090c12] px-4 py-3 lg:hidden">
|
||||||
|
<div class="text-[15px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
|
||||||
|
<span v-if="sync" class="flex items-center gap-1.5 text-xs font-medium" :class="sync.running ? 'text-ok' : 'text-danger'">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ sync.running ? '同步中' : '未运行' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1 px-4 py-4 sm:px-8 sm:py-6">
|
||||||
|
<!-- 导航:宽屏时换行排列的胶囊组;窄屏时横向可滑动,避免每个按钮被挤到文字逐字换行 -->
|
||||||
|
<nav class="mb-6 flex gap-1.5 overflow-x-auto rounded-2xl border border-border bg-panel-2 p-1.5 lg:mb-7 lg:flex-wrap lg:overflow-visible">
|
||||||
|
<router-link v-for="item in navItems" :key="item.name" :to="item.path"
|
||||||
|
class="shrink-0 whitespace-nowrap rounded-xl px-3.5 py-2 text-sm font-medium text-text-dim transition-colors hover:text-text"
|
||||||
|
:class="route.name === item.name ? 'bg-gradient-to-br from-accent to-accent-2 text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)]' : ''">
|
||||||
|
{{ item.meta.icon }} {{ item.meta.label }}
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
112
fam-ui/src/api.js
Normal file
112
fam-ui/src/api.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
let data = null
|
||||||
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 响应(如 404 空 body),保持 data=null
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 过滤掉 null/undefined/空字符串再拼 query string,避免 URLSearchParams 把
|
||||||
|
* undefined 字面量转成字符串 "undefined" 传给后端。 */
|
||||||
|
function qs(params) {
|
||||||
|
const clean = Object.fromEntries(
|
||||||
|
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ''))
|
||||||
|
const s = new URLSearchParams(clean).toString()
|
||||||
|
return s ? '?' + s : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (path) => request(path),
|
||||||
|
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
videos: (params = {}) => request(`/api/ui/videos${qs(params)}`),
|
||||||
|
videoDetail: (id) => request(`/api/ui/videos/${id}`),
|
||||||
|
stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`),
|
||||||
|
people: () => request('/api/ui/people'),
|
||||||
|
namedMembers: () => request('/api/ui/named-members'),
|
||||||
|
modelStats: () => request('/api/ui/model-stats'),
|
||||||
|
attentionEvents: () => request('/api/ui/attention-events'),
|
||||||
|
serviceStatus: () => request('/api/ui/service-status'),
|
||||||
|
|
||||||
|
chatAsk: (question, queried_person, queried_date) =>
|
||||||
|
request('/api/chat/ask', { method: 'POST', body: JSON.stringify({ question, queried_person, queried_date }) }),
|
||||||
|
chatHistory: (params = {}) => request(`/api/chat/history${qs(params)}`),
|
||||||
|
|
||||||
|
nameMember: (label, canonical_name) =>
|
||||||
|
request('/api/member/name', { method: 'POST', body: JSON.stringify({ label, canonical_name, named_by: 'UI管理员' }) }),
|
||||||
|
mergeMember: (source, target) =>
|
||||||
|
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
|
||||||
|
|
||||||
|
status: () => request('/api/status'),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剥离全角/半角括号备注(如 '人物A(别名:人物B)' -> '人物A'),与后端归一化一致 */
|
||||||
|
export function cleanPerson(s) {
|
||||||
|
return String(s || '').replace(/[((][^()()]*[))]/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** person_list_json(字符串或数组)-> 去重、去括号备注、去"无人"的名字数组 */
|
||||||
|
export function parsePersons(personListJson) {
|
||||||
|
if (!personListJson) return []
|
||||||
|
let items = personListJson
|
||||||
|
if (typeof items === 'string') {
|
||||||
|
try { items = JSON.parse(items) } catch { items = [items] }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(items)) items = [items]
|
||||||
|
const out = new Set()
|
||||||
|
for (const it of items) {
|
||||||
|
const s = cleanPerson(it)
|
||||||
|
if (s && s !== '无人') out.add(s)
|
||||||
|
}
|
||||||
|
return [...out].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'YYYY-MM-DD HH:MM:SS' / 相对时间 'HH:MM:SS' -> Date 对象,失败返回 null */
|
||||||
|
export function parseTs(ts) {
|
||||||
|
if (!ts) return null
|
||||||
|
const s = String(ts)
|
||||||
|
let m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/)
|
||||||
|
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6])
|
||||||
|
m = s.match(/^(\d{1,2}):(\d{2}):(\d{2})/)
|
||||||
|
if (m) {
|
||||||
|
const d = new Date(0)
|
||||||
|
d.setHours(+m[1], +m[2], +m[3])
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
export function fmtTime(ts) {
|
||||||
|
const d = parseTs(ts)
|
||||||
|
return d ? `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` : (String(ts || '').slice(0, 8) || '--:--')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDateTime(ts) {
|
||||||
|
const d = parseTs(ts)
|
||||||
|
if (!d) return '—'
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtMonthDayTime(ts) {
|
||||||
|
const d = parseTs(ts)
|
||||||
|
return d ? `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` : '--:--'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDateOnly(ts) {
|
||||||
|
const d = parseTs(ts)
|
||||||
|
return d ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : ''
|
||||||
|
}
|
||||||
1239
fam-ui/src/app.py
1239
fam-ui/src/app.py
File diff suppressed because it is too large
Load Diff
21
fam-ui/src/components/Badge.vue
Normal file
21
fam-ui/src/components/Badge.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
tone: { type: String, default: 'accent' }, // accent | ok | warn | danger | info | violet | neutral
|
||||||
|
})
|
||||||
|
|
||||||
|
const toneClass = {
|
||||||
|
accent: 'bg-accent/15 text-accent-bright border-accent/30',
|
||||||
|
ok: 'bg-ok/12 text-ok border-ok/30',
|
||||||
|
warn: 'bg-warn/12 text-warn border-warn/30',
|
||||||
|
danger: 'bg-danger/14 text-danger border-danger/35',
|
||||||
|
info: 'bg-info/12 text-info border-info/30',
|
||||||
|
violet: 'bg-violet/12 text-violet border-violet/30',
|
||||||
|
neutral: 'bg-slate-400/12 text-slate-300 border-slate-400/28',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] font-semibold leading-relaxed" :class="toneClass[tone]">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
13
fam-ui/src/components/EmptyState.vue
Normal file
13
fam-ui/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
icon: { type: String, default: '🗒' },
|
||||||
|
text: { type: String, required: true },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-2xl border border-dashed border-border bg-[rgba(19,24,38,.4)] px-5 py-11 text-center text-sm text-text-mute">
|
||||||
|
<span class="mb-3 block text-3xl opacity-50">{{ icon }}</span>
|
||||||
|
{{ text }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
66
fam-ui/src/components/EventItem.vue
Normal file
66
fam-ui/src/components/EventItem.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { fmtTime, parsePersons } from '../api.js'
|
||||||
|
import Badge from './Badge.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
event: { type: Object, required: true },
|
||||||
|
videoId: { type: [Number, String], required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeLabel = computed(() => fmtTime(props.event.ts))
|
||||||
|
const persons = computed(() => parsePersons(props.event.person_list_json))
|
||||||
|
const isAttention = computed(() => !!props.event.is_attention_event)
|
||||||
|
const description = computed(() => props.event.description || '(无描述)')
|
||||||
|
|
||||||
|
const thumbUrl = computed(() => {
|
||||||
|
if (!props.videoId || !props.event.ts) return null
|
||||||
|
return `/api/proxy/frame?video_id=${props.videoId}&ts=${encodeURIComponent(props.event.ts)}&w=440`
|
||||||
|
})
|
||||||
|
|
||||||
|
const appearances = computed(() => {
|
||||||
|
const raw = props.event.person_appearances_json
|
||||||
|
if (!raw) return []
|
||||||
|
let list = raw
|
||||||
|
if (typeof list === 'string') {
|
||||||
|
try { list = JSON.parse(list) } catch { return [] }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return list
|
||||||
|
.filter(p => p && p.uid)
|
||||||
|
.map(p => {
|
||||||
|
const feats = p.features || {}
|
||||||
|
const bits = ['gender', 'clothing', 'face']
|
||||||
|
.map(k => (feats[k] || '').trim())
|
||||||
|
.filter(v => v && v.toLowerCase() !== 'unknown')
|
||||||
|
return { uid: p.uid, featStr: bits.length ? bits.join(' · ') : '无特征', action: (p.action || '').trim() }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[78px_1fr] gap-3.5 border-b border-border py-3 last:border-none">
|
||||||
|
<div class="pt-0.5 font-mono text-sm font-semibold text-[#ccd3e0] tabular">
|
||||||
|
{{ timeLabel }}
|
||||||
|
<span v-if="event.camera_name" class="mt-1 block font-sans text-[11px] font-medium text-text-mute">{{ event.camera_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border p-3.5 transition-colors" :class="isAttention ? 'border-danger/35' : 'border-border'"
|
||||||
|
style="background: linear-gradient(165deg, var(--color-panel-2), var(--color-panel));">
|
||||||
|
<div v-if="thumbUrl" class="mb-2.5 leading-none">
|
||||||
|
<img loading="lazy" alt="事件帧" :src="thumbUrl" class="block w-full rounded-lg border border-border" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 flex flex-wrap gap-2">
|
||||||
|
<Badge v-if="isAttention" tone="danger">⚠ 需关注</Badge>
|
||||||
|
<Badge v-for="p in persons" :key="p" tone="accent">{{ p }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-if="appearances.length" class="mb-2">
|
||||||
|
<div v-for="a in appearances" :key="a.uid" class="my-1.5 rounded-lg border border-border bg-panel-3 px-2.5 py-1.5 text-xs">
|
||||||
|
<b class="text-accent-bright">{{ a.uid }}</b>
|
||||||
|
<span class="ml-2 text-text-mute">{{ a.featStr }}</span>
|
||||||
|
<span v-if="a.action" class="mt-1 block text-text-dim">{{ a.action }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm leading-relaxed text-text">{{ description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
19
fam-ui/src/components/PageHeader.vue
Normal file
19
fam-ui/src/components/PageHeader.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
sub: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-6 flex items-center gap-3.5">
|
||||||
|
<div class="flex h-11 w-11 items-center justify-center rounded-xl border border-accent/30 bg-gradient-to-br from-accent/30 to-accent-2/20 text-xl shadow-[var(--shadow-card)]">
|
||||||
|
{{ icon }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[22px] font-bold tracking-tight text-[#f7f9fc]">{{ title }}</div>
|
||||||
|
<div v-if="sub" class="mt-0.5 text-[12.5px] text-text-mute">{{ sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
129
fam-ui/src/components/PersonCard.vue
Normal file
129
fam-ui/src/components/PersonCard.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { api, fmtMonthDayTime } from '../api.js'
|
||||||
|
import Badge from './Badge.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
group: { type: Object, required: true },
|
||||||
|
allLabels: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['named', 'merged'])
|
||||||
|
|
||||||
|
const isNamed = computed(() => props.group.is_named)
|
||||||
|
const firstSeenStr = computed(() => {
|
||||||
|
const s = fmtMonthDayTime(props.group.first_seen)
|
||||||
|
return s === '--:--' ? '--' : s
|
||||||
|
})
|
||||||
|
const labelStr = computed(() => props.group.labels.join(' · '))
|
||||||
|
const avatarUrl = computed(() => `/api/proxy/avatar?label=${encodeURIComponent(props.group.display)}&w=150`)
|
||||||
|
|
||||||
|
const FEATURE_ROWS = [
|
||||||
|
['性别', 'gender'], ['年龄段', 'age_band'], ['身材', 'build'], ['发型', 'hair'],
|
||||||
|
['衣着', 'clothing'], ['面部', 'face'], ['辨识点', 'distinguishing'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const features = computed(() => {
|
||||||
|
let feats = {}
|
||||||
|
if (props.group.features_json) {
|
||||||
|
try { feats = JSON.parse(props.group.features_json) } catch { feats = {} }
|
||||||
|
}
|
||||||
|
return FEATURE_ROWS
|
||||||
|
.map(([label, key]) => ({ label, value: (feats[key] || '').trim() }))
|
||||||
|
.filter(f => f.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergeTargets = computed(() => props.allLabels.filter(l => !props.group.labels.includes(l)))
|
||||||
|
|
||||||
|
// 命名/合并表单状态
|
||||||
|
const newNameByLabel = ref({})
|
||||||
|
const mergeTargetByLabel = ref({})
|
||||||
|
const mergeTargetSelf = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
async function doName(label) {
|
||||||
|
const name = (newNameByLabel.value[label] || '').trim()
|
||||||
|
if (!name) { errorMsg.value = '请输入名字'; return }
|
||||||
|
busy.value = true; errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
await api.nameMember(label, name)
|
||||||
|
emit('named')
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doMergeFromLabel(sourceLabel) {
|
||||||
|
const target = mergeTargetByLabel.value[sourceLabel]
|
||||||
|
if (!target) return
|
||||||
|
await doMerge(sourceLabel, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doMergeSelf() {
|
||||||
|
if (!mergeTargetSelf.value) return
|
||||||
|
await doMerge(props.group.labels[0], mergeTargetSelf.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doMerge(sourceLabel, target) {
|
||||||
|
busy.value = true; errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
await api.mergeMember(sourceLabel, target)
|
||||||
|
emit('merged')
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-3 rounded-2xl border border-border bg-panel-2 p-5 shadow-[var(--shadow-card)]">
|
||||||
|
<div class="mb-2.5 leading-none">
|
||||||
|
<img loading="lazy" alt="人物头像" :src="avatarUrl"
|
||||||
|
class="h-[150px] w-[150px] rounded-2xl border border-border object-cover shadow-[var(--shadow-card)]" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-[17px] font-bold text-[#f7f9fc]">
|
||||||
|
{{ group.display }}
|
||||||
|
<Badge :tone="isNamed ? 'info' : 'warn'">{{ isNamed ? '已命名' : '未命名' }}</Badge>
|
||||||
|
<span v-if="group.display_uid && group.display_uid !== group.display" class="text-[11px] font-normal text-text-mute">UID: {{ group.display_uid }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-xs leading-relaxed text-text-dim">标识: {{ labelStr }}</div>
|
||||||
|
<div class="mt-1.5 text-[11px] text-text-mute">出现 <b class="text-[#ccd5e1]">{{ group.appearances }}</b> 次 · 首次 {{ firstSeenStr }}</div>
|
||||||
|
|
||||||
|
<div v-if="features.length" class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span v-for="f in features" :key="f.label" class="rounded-lg border border-border bg-panel-3 px-2.5 py-0.5 text-[11px]">
|
||||||
|
<span class="text-text-mute">{{ f.label }}</span>
|
||||||
|
<b class="ml-1" :class="f.value.toLowerCase() === 'unknown' ? 'font-medium text-text-faint' : 'text-text'">{{ f.value }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-2 text-[11px] text-text-faint">特征待大模型补充(下段视频分析时由 VLM 落库)</div>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="mt-2 text-xs text-danger">{{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<div v-if="!isNamed" class="mt-3 space-y-2.5">
|
||||||
|
<div v-for="lb in group.labels" :key="lb" class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input v-model="newNameByLabel[lb]" :placeholder="`为「${lb}」起名`"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-border bg-panel px-3 py-1.5 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button :disabled="busy" @click="doName(lb)"
|
||||||
|
class="shrink-0 whitespace-nowrap rounded-lg bg-gradient-to-br from-accent to-accent-2 px-4 py-1.5 text-sm font-semibold text-white shadow-[0_4px_16px_-2px_rgba(91,140,255,.4)] disabled:opacity-50">命名</button>
|
||||||
|
<select v-model="mergeTargetByLabel[lb]" @change="doMergeFromLabel(lb)"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-border bg-panel px-2 py-1.5 text-xs text-text-dim sm:w-36 sm:flex-none">
|
||||||
|
<option value="">合并到…</option>
|
||||||
|
<option v-for="t in mergeTargets" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-3">
|
||||||
|
<select v-model="mergeTargetSelf" @change="doMergeSelf"
|
||||||
|
class="w-full rounded-lg border border-border bg-panel px-2 py-1.5 text-xs text-text-dim">
|
||||||
|
<option value="">合并到其他身份…</option>
|
||||||
|
<option v-for="t in mergeTargets" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
fam-ui/src/components/ServiceCard.vue
Normal file
15
fam-ui/src/components/ServiceCard.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
valueColor: { type: String, default: '#ccd5e1' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-w-[190px] flex-1 rounded-xl border border-border bg-panel-2 p-3.5 shadow-[var(--shadow-card)]">
|
||||||
|
<div class="text-xs font-medium text-text-mute">{{ icon }} {{ name }}</div>
|
||||||
|
<div class="mt-1.5 text-[13px] leading-snug" :style="{ color: valueColor }"><slot /></div>
|
||||||
|
<div class="mt-1 text-[11px] text-text-faint"><slot name="sub" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
fam-ui/src/components/StatCard.vue
Normal file
21
fam-ui/src/components/StatCard.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
value: { type: [String, Number], required: true },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
tone: { type: String, default: 'default' }, // default | ok | warn | danger
|
||||||
|
})
|
||||||
|
|
||||||
|
const toneClass = {
|
||||||
|
default: 'text-[#f7f9fc]',
|
||||||
|
ok: 'text-ok',
|
||||||
|
warn: 'text-warn',
|
||||||
|
danger: 'text-danger',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 min-w-[130px] rounded-2xl border border-border bg-gradient-to-b from-panel-2 to-panel px-5 py-4 shadow-[var(--shadow-card)]">
|
||||||
|
<div class="font-mono text-[27px] font-semibold leading-tight tabular" :class="toneClass[tone]">{{ value }}</div>
|
||||||
|
<div class="mt-1 text-xs font-medium text-text-mute">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""
|
|
||||||
配置加载器 (FAM-UI 复用)
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_env_vars(value):
|
|
||||||
if isinstance(value, str):
|
|
||||||
def replace_env(match):
|
|
||||||
return os.environ.get(match.group(1), match.group(0))
|
|
||||||
return re.sub(r'\$\{(\w+)\}', replace_env, value)
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
return {k: _resolve_env_vars(v) for k, v in value.items()}
|
|
||||||
elif isinstance(value, list):
|
|
||||||
return [_resolve_env_vars(item) for item in value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path=None):
|
|
||||||
if config_path is None:
|
|
||||||
config_path = os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
||||||
'config', 'config.yaml'
|
|
||||||
)
|
|
||||||
with open(config_path, 'r', encoding='utf-8') as f:
|
|
||||||
raw = yaml.safe_load(f)
|
|
||||||
return _resolve_env_vars(raw)
|
|
||||||
6
fam-ui/src/main.js
Normal file
6
fam-ui/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router.js'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
19
fam-ui/src/router.js
Normal file
19
fam-ui/src/router.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', redirect: '/timeline' },
|
||||||
|
{ path: '/timeline', name: 'timeline', component: () => import('./views/Timeline.vue'), meta: { icon: '🕒', label: '事件时间轴' } },
|
||||||
|
{ path: '/chat', name: 'chat', component: () => import('./views/Chat.vue'), meta: { icon: '💬', label: 'AI 对话' } },
|
||||||
|
{ path: '/chat-history', name: 'chat-history', component: () => import('./views/ChatHistory.vue'), meta: { icon: '📝', label: '对话历史' } },
|
||||||
|
{ path: '/people', name: 'people', component: () => import('./views/People.vue'), meta: { icon: '👤', label: '人物管理' } },
|
||||||
|
{ path: '/stats', name: 'stats', component: () => import('./views/Stats.vue'), meta: { icon: '📈', label: '统计图表' } },
|
||||||
|
{ path: '/model-stats', name: 'model-stats', component: () => import('./views/ModelStats.vue'), meta: { icon: '🤖', label: '模型统计' } },
|
||||||
|
{ path: '/service-status', name: 'service-status', component: () => import('./views/ServiceStatus.vue'), meta: { icon: '🖥', label: '服务状态' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const navItems = routes.filter(r => r.meta)
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
52
fam-ui/src/style.css
Normal file
52
fam-ui/src/style.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-bg: #0a0d13;
|
||||||
|
--color-panel: #12161f;
|
||||||
|
--color-panel-2: #171c28;
|
||||||
|
--color-panel-3: #0d1119;
|
||||||
|
--color-border: #232a3a;
|
||||||
|
--color-border-hi: #384357;
|
||||||
|
|
||||||
|
--color-text: #e7ecf3;
|
||||||
|
--color-text-dim: #949fb3;
|
||||||
|
--color-text-mute: #67728a;
|
||||||
|
--color-text-faint: #454e60;
|
||||||
|
|
||||||
|
--color-accent: #5b8cff;
|
||||||
|
--color-accent-2: #8b6bff;
|
||||||
|
--color-accent-bright: #9db8ff;
|
||||||
|
|
||||||
|
--color-ok: #3ddc9b;
|
||||||
|
--color-warn: #f5b84e;
|
||||||
|
--color-danger: #ff7373;
|
||||||
|
--color-info: #4fd1e8;
|
||||||
|
--color-violet: #c893ff;
|
||||||
|
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
--shadow-card: 0 1px 2px rgba(0,0,0,.2), 0 8px 24px -12px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 15% -10%, rgb(91 140 255 / .07), transparent),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, rgb(139 107 255 / .05), transparent),
|
||||||
|
var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { background: rgb(91 140 255 / .2); }
|
||||||
|
|
||||||
|
/* 滚动条:深色主题下默认滚动条太亮,统一细一点、低调一点 */
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--color-border-hi); border-radius: 999px; border: 2px solid var(--color-bg); }
|
||||||
|
|
||||||
|
.tabular { font-variant-numeric: tabular-nums; }
|
||||||
103
fam-ui/src/views/Chat.vue
Normal file
103
fam-ui/src/views/Chat.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
|
||||||
|
const named = ref([])
|
||||||
|
const queriedPerson = ref('')
|
||||||
|
const queriedDate = ref(new Date().toISOString().slice(0, 10))
|
||||||
|
const userQuestion = ref('')
|
||||||
|
const selectedQuick = ref('自定义')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const result = ref(null)
|
||||||
|
|
||||||
|
const quickQuestions = computed(() => [
|
||||||
|
`${queriedPerson.value}今天干嘛了?`,
|
||||||
|
`${queriedPerson.value}有没有发生什么需要注意的事情?`,
|
||||||
|
`今天${queriedPerson.value}的活动时间线是什么?`,
|
||||||
|
])
|
||||||
|
|
||||||
|
function applyQuick() {
|
||||||
|
if (selectedQuick.value !== '自定义') userQuestion.value = selectedQuick.value
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.namedMembers()
|
||||||
|
named.value = data.members
|
||||||
|
if (named.value.length) queriedPerson.value = named.value[0]
|
||||||
|
} catch { /* 忽略:下拉留空即可 */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function ask() {
|
||||||
|
errorMsg.value = ''
|
||||||
|
result.value = null
|
||||||
|
if (!userQuestion.value.trim()) { errorMsg.value = '请输入问题'; return }
|
||||||
|
if (!queriedPerson.value.trim()) { errorMsg.value = '请输入查询人物'; return }
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.chatAsk(userQuestion.value, queriedPerson.value, queriedDate.value)
|
||||||
|
result.value = { question: userQuestion.value, answer: data.answer, contextSummary: data.context_summary }
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader icon="💬" title="AI 对话" sub="基于同步事件上下文的智能问答" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-xs font-medium text-text-dim">查询人物</label>
|
||||||
|
<input v-model="queriedPerson" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-xs font-medium text-text-dim">查询日期</label>
|
||||||
|
<input type="date" v-model="queriedDate" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="named.length" class="mt-4">
|
||||||
|
<label class="mb-1.5 block text-xs font-medium text-text-dim">快捷选择成员</label>
|
||||||
|
<select v-model="queriedPerson" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text-dim">
|
||||||
|
<option value="">(不选)</option>
|
||||||
|
<option v-for="n in named" :key="n" :value="n">{{ n }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="mb-1.5 block text-xs font-medium text-text-dim">快捷提问</label>
|
||||||
|
<select v-model="selectedQuick" @change="applyQuick" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text-dim">
|
||||||
|
<option>自定义</option>
|
||||||
|
<option v-for="q in quickQuestions" :key="q" :value="q">{{ q }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="mb-1.5 block text-xs font-medium text-text-dim">你的问题</label>
|
||||||
|
<textarea v-model="userQuestion" rows="3" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button :disabled="loading" @click="ask"
|
||||||
|
class="mt-4 rounded-xl bg-gradient-to-br from-accent to-accent-2 px-5 py-2.5 text-sm font-semibold text-white shadow-[0_4px_16px_-2px_rgba(91,140,255,.4)] disabled:opacity-50">
|
||||||
|
{{ loading ? 'AI 正在思考…' : '提问' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="mt-3 text-sm text-danger">{{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<div v-if="result" class="mt-6 space-y-3.5">
|
||||||
|
<div class="rounded-2xl border border-accent/30 bg-accent/15 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
|
||||||
|
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">❓ 提问</div>
|
||||||
|
{{ result.question }}
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-border bg-panel-2 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
|
||||||
|
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">🤖 回答</div>
|
||||||
|
<div class="whitespace-pre-wrap">{{ result.answer }}</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="result.contextSummary" class="text-xs text-text-faint">上下文: {{ result.contextSummary }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
51
fam-ui/src/views/ChatHistory.vue
Normal file
51
fam-ui/src/views/ChatHistory.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { api, fmtDateTime } from '../api.js'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
|
||||||
|
const page = ref(0)
|
||||||
|
const pageSize = 20
|
||||||
|
const history = ref([])
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const data = await api.chatHistory({ limit: pageSize, offset: page.value * pageSize })
|
||||||
|
history.value = data.history
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
history.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(page, load)
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader icon="📝" title="对话历史" sub="历史问答记录" />
|
||||||
|
|
||||||
|
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||||
|
<EmptyState v-else-if="!history.length" icon="💬" text="暂无对话记录" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="h in history" :key="h.chat_id" class="mb-3.5">
|
||||||
|
<div class="rounded-2xl border border-accent/30 bg-accent/15 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
|
||||||
|
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">👤 {{ h.queried_person || '未知' }} · {{ fmtDateTime(h.created_at) }}</div>
|
||||||
|
{{ h.user_question }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3.5 rounded-2xl border border-border bg-panel-2 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
|
||||||
|
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">🤖 回答</div>
|
||||||
|
<div class="whitespace-pre-wrap">{{ h.ai_answer || '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-3 items-center gap-2">
|
||||||
|
<button :disabled="page === 0" @click="page--" class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40">← 上一页</button>
|
||||||
|
<div class="text-center text-sm text-text-mute">第 {{ page + 1 }} 页</div>
|
||||||
|
<button :disabled="history.length < pageSize" @click="page++" class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40">下一页 →</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
107
fam-ui/src/views/ModelStats.vue
Normal file
107
fam-ui/src/views/ModelStats.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { api, fmtDateTime } from '../api.js'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
|
||||||
|
const aggregate = ref([])
|
||||||
|
const calls = ref([])
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
|
const aggRows = computed(() => aggregate.value.map(r => {
|
||||||
|
const total = (r.ok_cnt || 0) + (r.fail_cnt || 0)
|
||||||
|
const rate = total ? ((r.ok_cnt || 0) / total * 100) : 0
|
||||||
|
return {
|
||||||
|
model: `${r.provider} / ${r.model}`,
|
||||||
|
ok: r.ok_cnt || 0,
|
||||||
|
fail: r.fail_cnt || 0,
|
||||||
|
rate: rate.toFixed(1),
|
||||||
|
avgDur: r.avg_duration,
|
||||||
|
lastCall: r.last_call ? fmtDateTime(r.last_call) : '—',
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const callRows = computed(() => calls.value.map(c => ({
|
||||||
|
time: c.started_at ? fmtDateTime(c.started_at) : '—',
|
||||||
|
model: `${c.provider} / ${c.model}`,
|
||||||
|
duration: (c.duration_sec || 0).toFixed(1),
|
||||||
|
success: !!c.success,
|
||||||
|
error: (c.error || '').slice(0, 60),
|
||||||
|
filename: (c.filename || '').slice(0, 40),
|
||||||
|
})))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.modelStats()
|
||||||
|
aggregate.value = data.aggregate
|
||||||
|
calls.value = data.recent_calls
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader icon="🤖" title="云端模型统计" sub="请求时间 · 耗时 · 成功/失败 · 失败原因" />
|
||||||
|
|
||||||
|
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||||
|
|
||||||
|
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">按模型聚合</div>
|
||||||
|
<EmptyState v-if="!aggRows.length" text="暂无模型调用记录(视频处理中或尚未同步)" />
|
||||||
|
<div v-else class="mb-8 overflow-x-auto rounded-xl border border-border">
|
||||||
|
<table class="w-full whitespace-nowrap text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||||
|
<th class="px-4 py-2 font-medium">模型</th>
|
||||||
|
<th class="px-4 py-2 font-medium">成功</th>
|
||||||
|
<th class="px-4 py-2 font-medium">失败</th>
|
||||||
|
<th class="px-4 py-2 font-medium">成功率%</th>
|
||||||
|
<th class="px-4 py-2 font-medium">平均耗时s</th>
|
||||||
|
<th class="px-4 py-2 font-medium">最后调用</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(r, i) in aggRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||||
|
<td class="px-4 py-2 font-medium text-text">{{ r.model }}</td>
|
||||||
|
<td class="px-4 py-2 text-ok">{{ r.ok }}</td>
|
||||||
|
<td class="px-4 py-2 text-danger">{{ r.fail }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono tabular">{{ r.rate }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono tabular">{{ r.avgDur }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono tabular">{{ r.lastCall }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">最近调用明细</div>
|
||||||
|
<EmptyState v-if="!callRows.length" text="暂无调用明细" />
|
||||||
|
<div v-else class="mb-4 max-h-[520px] overflow-auto rounded-xl border border-border">
|
||||||
|
<table class="w-full whitespace-nowrap text-sm">
|
||||||
|
<thead class="sticky top-0">
|
||||||
|
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||||
|
<th class="px-4 py-2 font-medium">请求时间</th>
|
||||||
|
<th class="px-4 py-2 font-medium">模型</th>
|
||||||
|
<th class="px-4 py-2 font-medium">耗时s</th>
|
||||||
|
<th class="px-4 py-2 font-medium">状态</th>
|
||||||
|
<th class="px-4 py-2 font-medium">失败原因</th>
|
||||||
|
<th class="px-4 py-2 font-medium">视频</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(c, i) in callRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||||
|
<td class="px-4 py-2 font-mono tabular">{{ c.time }}</td>
|
||||||
|
<td class="px-4 py-2">{{ c.model }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono tabular">{{ c.duration }}</td>
|
||||||
|
<td class="px-4 py-2" :class="c.success ? 'text-ok' : 'text-danger'">{{ c.success ? '✅ 成功' : '❌ 失败' }}</td>
|
||||||
|
<td class="px-4 py-2 text-text-faint">{{ c.error }}</td>
|
||||||
|
<td class="px-4 py-2 text-text-faint">{{ c.filename }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-[11px] text-text-mute">
|
||||||
|
统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。
|
||||||
|
失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
46
fam-ui/src/views/People.vue
Normal file
46
fam-ui/src/views/People.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import PersonCard from '../components/PersonCard.vue'
|
||||||
|
|
||||||
|
const groups = ref([])
|
||||||
|
const allLabels = ref([])
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const data = await api.people()
|
||||||
|
groups.value = data.groups
|
||||||
|
allLabels.value = data.all_labels
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
groups.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedCount = computed(() => groups.value.filter(g => g.is_named).length)
|
||||||
|
const unnamedCount = computed(() => groups.value.length - namedCount.value)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader icon="👤" title="人物管理" sub="所有出现的人物 · 命名与合并(回推甲骨文)" />
|
||||||
|
|
||||||
|
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||||
|
<EmptyState v-else-if="!groups.length" icon="👤" text="暂未发现任何人物(甲骨文尚未同步)" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-4 text-xs text-text-dim">
|
||||||
|
共 <b class="text-[#f7f9fc]">{{ groups.length }}</b> 个身份 ·
|
||||||
|
已命名 <b class="text-info">{{ namedCount }}</b> ·
|
||||||
|
未命名 <b class="text-warn">{{ unnamedCount }}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PersonCard v-for="g in groups" :key="g.display" :group="g" :all-labels="allLabels"
|
||||||
|
@named="load" @merged="load" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
115
fam-ui/src/views/ServiceStatus.vue
Normal file
115
fam-ui/src/views/ServiceStatus.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { api, fmtDateTime } from '../api.js'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import StatCard from '../components/StatCard.vue'
|
||||||
|
import ServiceCard from '../components/ServiceCard.vue'
|
||||||
|
import Badge from '../components/Badge.vue'
|
||||||
|
|
||||||
|
const data = ref(null)
|
||||||
|
const oracleError = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
data.value = await api.serviceStatus()
|
||||||
|
oracleError.value = data.value.oracle_error || ''
|
||||||
|
} catch (e) {
|
||||||
|
oracleError.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
const oracle = computed(() => data.value?.oracle || {})
|
||||||
|
const queue = computed(() => oracle.value.queue || {})
|
||||||
|
const dbInfo = computed(() => oracle.value.db || {})
|
||||||
|
const byStatus = computed(() => dbInfo.value.by_status || {})
|
||||||
|
const currentTxt = computed(() => {
|
||||||
|
const c = queue.value.current
|
||||||
|
return c ? `#${c.video_id} ${(c.filename || '').slice(-42)}` : '—'
|
||||||
|
})
|
||||||
|
const qs = computed(() => queue.value.stats || {})
|
||||||
|
|
||||||
|
const rclone = computed(() => oracle.value.rclone)
|
||||||
|
const person = computed(() => oracle.value.person)
|
||||||
|
const nasSync = computed(() => data.value?.nas_sync)
|
||||||
|
const modelCalls = computed(() => oracle.value.model_calls || [])
|
||||||
|
const activities = computed(() => oracle.value.activities || [])
|
||||||
|
|
||||||
|
const SVC_BADGE = {
|
||||||
|
queue: 'info',
|
||||||
|
rclone: 'ok',
|
||||||
|
person: 'violet',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader icon="🖥" title="服务状态" sub="各服务实时动态 · 最近 7 天活动记录" />
|
||||||
|
|
||||||
|
<div class="mb-5 flex items-center gap-3">
|
||||||
|
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">🔄 刷新</button>
|
||||||
|
<span class="text-xs text-text-mute">点击刷新立即更新</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
|
||||||
|
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">各服务当前状态</div>
|
||||||
|
<div class="mb-3 flex flex-wrap gap-3.5">
|
||||||
|
<StatCard :value="queue.running ? '运行中' : '停止'" label="FAM-Edge 队列" />
|
||||||
|
<StatCard :value="queue.queued ?? 0" label="排队中" />
|
||||||
|
<StatCard :value="byStatus.done ?? 0" label="已完成" tone="ok" />
|
||||||
|
<StatCard :value="byStatus.pending ?? 0" label="待处理" tone="warn" />
|
||||||
|
<StatCard :value="byStatus.failed ?? 0" label="失败" tone="danger" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5 text-xs text-text-dim">
|
||||||
|
▶ 正在处理:<b class="text-info">{{ currentTxt }}</b>
|
||||||
|
<span class="ml-3.5">生产者已入队 {{ qs.produced ?? 0 }} · 成功 {{ qs.consumed_ok ?? 0 }} · 失败 {{ qs.consumed_fail ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 flex flex-wrap gap-2.5">
|
||||||
|
<ServiceCard icon="🔄" name="rclone 同步" :value-color="rclone ? '#4ade80' : undefined">
|
||||||
|
{{ rclone ? (rclone.detail || '').slice(0, 70) : '暂无记录' }}
|
||||||
|
<template #sub>{{ rclone ? `最近 ${(rclone.ts || '').slice(0, 19)}` : '—' }}</template>
|
||||||
|
</ServiceCard>
|
||||||
|
<ServiceCard icon="👤" name="人物合并" :value-color="person ? '#c084fc' : undefined">
|
||||||
|
{{ person ? person.action : '暂无记录' }}
|
||||||
|
<template #sub>{{ person ? `${(person.ts || '').slice(0, 19)} · ${(person.detail || '').slice(0, 46)}` : '—' }}</template>
|
||||||
|
</ServiceCard>
|
||||||
|
<ServiceCard icon="📡" name="NAS 同步" :value-color="nasSync ? '#fbbf24' : undefined">
|
||||||
|
{{ nasSync ? `游标 ${(nasSync.cursor || '').slice(0, 19)}` : '不可达' }}
|
||||||
|
<template #sub>
|
||||||
|
{{ nasSync ? `最近 ${(nasSync.last_sync_at || '').slice(0, 19)} · 增量 V${nasSync.last_count?.[0] ?? 0} E${nasSync.last_count?.[1] ?? 0} P${nasSync.last_count?.[2] ?? 0} M${nasSync.last_count?.[3] ?? 0}` : '—' }}
|
||||||
|
</template>
|
||||||
|
</ServiceCard>
|
||||||
|
<ServiceCard icon="🧠" name="云端模型" :value-color="modelCalls.length ? '#4ade80' : undefined">
|
||||||
|
{{ modelCalls.length ? `最近:${modelCalls[0].model} ${modelCalls[0].success ? '✅' : '❌ ' + (modelCalls[0].error || '').slice(0, 30)}` : '暂无调用' }}
|
||||||
|
<template #sub v-if="modelCalls.length">
|
||||||
|
{{ (modelCalls[0].started_at || '').slice(0, 19) }} · 耗时 {{ (modelCalls[0].duration_sec || 0).toFixed(1) }}s ·
|
||||||
|
近5次 成功{{ modelCalls.filter(m => m.success).length }}/{{ modelCalls.length }}
|
||||||
|
</template>
|
||||||
|
</ServiceCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">最近活动</div>
|
||||||
|
<EmptyState v-if="!activities.length" text="暂无活动记录(服务刚启动?)" />
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="(a, i) in activities.slice(0, 50)" :key="i" class="mb-1 grid grid-cols-[110px_1fr] gap-3.5">
|
||||||
|
<div class="pt-1 font-mono text-xs text-[#ccd3e0] tabular">{{ (a.ts || '').slice(0, 19) }}</div>
|
||||||
|
<div class="rounded-lg border border-border bg-panel-2 px-2.5 py-1.5">
|
||||||
|
<Badge :tone="SVC_BADGE[a.service] || 'neutral'">{{ a.service }}</Badge>
|
||||||
|
<span class="mx-1.5 font-semibold text-text">{{ a.action }}</span>
|
||||||
|
<span class="text-xs text-text-dim">{{ a.detail || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-text-mute">共 {{ activities.length }} 条记录(活动日志仅保留最近 7 天)</p>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
85
fam-ui/src/views/Stats.vue
Normal file
85
fam-ui/src/views/Stats.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
|
||||||
|
const modelChart = ref([]) // [{label, value}]
|
||||||
|
const attention = ref([])
|
||||||
|
const syncStatus = ref(null)
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
|
const BAR_COLORS = ['#5b8cff', '#8b6bff', '#3ddc9b', '#f5b84e', '#4fd1e8', '#ff7373']
|
||||||
|
const maxVal = computed(() => Math.max(1, ...modelChart.value.map(m => m.value)))
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const [ms, att, statusData] = await Promise.all([
|
||||||
|
api.modelStats(),
|
||||||
|
api.attentionEvents(),
|
||||||
|
api.status(),
|
||||||
|
])
|
||||||
|
const byProvider = {}
|
||||||
|
for (const row of ms.aggregate) {
|
||||||
|
const key = row.provider || 'unknown'
|
||||||
|
byProvider[key] = (byProvider[key] || 0) + (row.ok_cnt || 0) + (row.fail_cnt || 0)
|
||||||
|
}
|
||||||
|
modelChart.value = Object.entries(byProvider).map(([label, value]) => ({ label, value }))
|
||||||
|
attention.value = att.events
|
||||||
|
syncStatus.value = statusData.sync
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件 / 同步状态" />
|
||||||
|
|
||||||
|
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||||
|
|
||||||
|
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">模型来源分布</div>
|
||||||
|
<EmptyState v-if="!modelChart.length" text="暂无统计数据" />
|
||||||
|
<div v-else class="mb-8 space-y-2.5">
|
||||||
|
<div v-for="(m, i) in modelChart" :key="m.label" class="flex items-center gap-3">
|
||||||
|
<div class="w-20 shrink-0 text-xs text-text-dim">{{ m.label }}</div>
|
||||||
|
<div class="h-6 flex-1 overflow-hidden rounded-md bg-panel-3">
|
||||||
|
<div class="h-full rounded-md transition-all" :style="{ width: `${(m.value / maxVal) * 100}%`, background: BAR_COLORS[i % BAR_COLORS.length] }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 shrink-0 text-right font-mono text-xs tabular text-text-dim">{{ m.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 mt-6 text-[15px] font-bold text-[#f7f9fc]">关注事件统计</div>
|
||||||
|
<EmptyState v-if="!attention.length" text="暂无关注事件" />
|
||||||
|
<div v-else class="mb-8 overflow-hidden rounded-xl border border-border">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||||
|
<th class="px-4 py-2 font-medium">日期</th>
|
||||||
|
<th class="px-4 py-2 font-medium">人物</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(a, i) in attention" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||||
|
<td class="px-4 py-2 font-mono tabular">{{ fmtDateOnly(a.date) || '?' }}</td>
|
||||||
|
<td class="px-4 py-2">{{ a.persons.join(', ') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 mt-6 text-[15px] font-bold text-[#f7f9fc]">同步状态</div>
|
||||||
|
<div v-if="syncStatus" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
|
||||||
|
运行状态 <span :class="syncStatus.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ syncStatus.running ? '同步中' : '未运行' }}</span><br />
|
||||||
|
最近同步 <b class="text-[#ccd5e1]">{{ syncStatus.last_sync_at ? fmtDateTime(syncStatus.last_sync_at) : '—' }}</b><br />
|
||||||
|
本次增量 {{ syncStatus.last_count ? `视频+${syncStatus.last_count[0]} / 事件+${syncStatus.last_count[1]} / 人物+${syncStatus.last_count[2]}` : '—' }}<br />
|
||||||
|
游标 <b class="text-[#ccd5e1]">{{ syncStatus.cursor ? fmtDateTime(syncStatus.cursor) : '(全量)' }}</b><br />
|
||||||
|
周期 {{ syncStatus.interval_sec }}s
|
||||||
|
<div v-if="syncStatus.last_error" class="font-semibold text-danger">⚠ {{ syncStatus.last_error }}</div>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else text="无法获取同步状态" />
|
||||||
|
</template>
|
||||||
141
fam-ui/src/views/Timeline.vue
Normal file
141
fam-ui/src/views/Timeline.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { api, fmtDateOnly, fmtMonthDayTime, fmtDateTime, parseTs } from '../api.js'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import StatCard from '../components/StatCard.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import EventItem from '../components/EventItem.vue'
|
||||||
|
import Badge from '../components/Badge.vue'
|
||||||
|
|
||||||
|
const dateFilter = ref('')
|
||||||
|
const page = ref(0)
|
||||||
|
const videos = ref([])
|
||||||
|
const stats = ref(null)
|
||||||
|
const selectedId = ref(null)
|
||||||
|
const detail = ref(null)
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats.value = await api.stats(dateFilter.value || undefined)
|
||||||
|
} catch {
|
||||||
|
stats.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVideos() {
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const data = await api.videos({ date: dateFilter.value || undefined, page: page.value })
|
||||||
|
videos.value = data.videos
|
||||||
|
if (videos.value.length && !videos.value.some(v => v.id === selectedId.value)) {
|
||||||
|
selectedId.value = videos.value[0].id
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message
|
||||||
|
videos.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(id) {
|
||||||
|
if (!id) { detail.value = null; return }
|
||||||
|
try {
|
||||||
|
detail.value = await api.videoDetail(id)
|
||||||
|
} catch (e) {
|
||||||
|
detail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(dateFilter, () => { page.value = 0; loadStats(); loadVideos() })
|
||||||
|
watch(page, loadVideos)
|
||||||
|
watch(selectedId, (id) => loadDetail(id))
|
||||||
|
|
||||||
|
onMounted(() => { loadStats(); loadVideos() })
|
||||||
|
|
||||||
|
function selectVideo(id) {
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryShort(s) {
|
||||||
|
s = (s || '').trim()
|
||||||
|
if (!s) return '暂无摘要'
|
||||||
|
return s.length <= 28 ? s : s.slice(0, 28) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeStr = computed(() => {
|
||||||
|
if (!detail.value) return ''
|
||||||
|
const v = detail.value.video
|
||||||
|
const start = parseTs(v.event_start_time)
|
||||||
|
const proc = parseTs(v.processed_at)
|
||||||
|
const main = start || proc
|
||||||
|
if (!main) return ''
|
||||||
|
let s = fmtDateTime(v.event_start_time || v.processed_at)
|
||||||
|
if (start && proc && (proc - start) / 1000 > 60) {
|
||||||
|
s += `(分析于 ${fmtMonthDayTime(v.processed_at)})`
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelBadges = computed(() => {
|
||||||
|
const provider = detail.value?.video?.compute_provider || ''
|
||||||
|
return provider ? String(provider).split(',').map(p => p.trim()).filter(Boolean) : []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader icon="🕒" title="事件时间轴" sub="视频会话 · 时间点 · 信息摘要" />
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-1.5 block text-xs font-medium text-text-dim">日期</label>
|
||||||
|
<input type="date" v-model="dateFilter" class="rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats" class="mb-6 flex flex-wrap gap-3.5">
|
||||||
|
<StatCard :value="stats.videos ?? 0" label="视频会话" />
|
||||||
|
<StatCard :value="stats.events ?? 0" label="事件数" />
|
||||||
|
<StatCard :value="stats.people ?? 0" label="出现人物" tone="ok" />
|
||||||
|
<StatCard :value="stats.attention ?? 0" label="需关注" tone="warn" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||||
|
<EmptyState v-else-if="!videos.length" icon="🗓" text="该日期暂无监控会话(甲骨文尚未同步数据?)" />
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_2.35fr]">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2.5 text-[13px] font-semibold text-text-dim">视频会话 · {{ videos.length }} 条</div>
|
||||||
|
<button v-for="v in videos" :key="v.id" @click="selectVideo(v.id)"
|
||||||
|
class="mb-1 block w-full rounded-xl border px-3.5 py-2.5 text-left text-sm transition-colors"
|
||||||
|
:class="v.id === selectedId
|
||||||
|
? 'border-transparent bg-gradient-to-br from-accent to-accent-2 font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.4)]'
|
||||||
|
: 'border-border bg-panel-2 text-text-dim hover:border-border-hi'">
|
||||||
|
<span v-if="v.id === selectedId">▶ </span>{{ fmtMonthDayTime(v.event_start_time || v.processed_at) }} · {{ v.camera_name || '未知' }} · {{ v.event_count }}事件
|
||||||
|
<div class="mt-0.5 text-[11px] font-normal opacity-80">{{ fmtDateOnly(v.event_start_time || v.processed_at) }} · {{ summaryShort(v.summary_json) }}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<button :disabled="page === 0" @click="page--"
|
||||||
|
class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40">← 上一页</button>
|
||||||
|
<button :disabled="videos.length < 15" @click="page++"
|
||||||
|
class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40">下一页 →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="detail">
|
||||||
|
<div class="mb-6 rounded-2xl border border-border-hi p-5 shadow-[var(--shadow-card)]"
|
||||||
|
style="background: linear-gradient(135deg, rgb(91 140 255 / .1), rgb(139 107 255 / .06));">
|
||||||
|
<div class="flex flex-wrap items-center gap-2.5 text-lg font-bold text-[#f7f9fc]">
|
||||||
|
<span>{{ detail.video.camera_name || '未知摄像头' }}</span>
|
||||||
|
<Badge tone="ok">会话 #{{ detail.video.id }}</Badge>
|
||||||
|
<Badge v-for="m in modelBadges" :key="m" tone="neutral">{{ m }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 font-mono text-[13px] text-text-dim tabular">⏱ {{ rangeStr }} · 文件 {{ detail.video.filename }}</div>
|
||||||
|
<div class="mt-2.5 whitespace-pre-wrap text-sm leading-relaxed text-[#d6dce6]">{{ detail.video.summary_json || '暂无全局摘要' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
|
||||||
|
<div v-else>
|
||||||
|
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
fam-ui/vite.config.js
Normal file
17
fam-ui/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// 开发环境代理 /api 到 NAS 上的 fam-core,方便本地直接联调真实数据。
|
||||||
|
// 生产环境由 fam-core 的 Flask 同源提供,不需要这个代理。
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://192.168.50.64:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user