From bbbc97eb4a57cb1f406238dca821144800d8a4fe Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Thu, 6 Aug 2026 17:43:50 +0800 Subject: [PATCH] feat: add digital avatar management to admin dashboard - Add avatars list page with pagination, search, status filter - Add avatar detail dialog showing full info and config params - Add on/off switch (active/inactive status toggle) - Backend: avatar_service.py connects to digital avatar SQLite - Backend: /api/avatars endpoints for list, detail, status update - Sidebar menu item added below dashboard - docker-compose: mount SQLite db and set AVATAR_DB_PATH env var --- backend/app/api/__init__.py | 3 +- backend/app/api/endpoints/avatars.py | 65 ++++++ backend/app/core/config.py | 3 + backend/app/services/avatar_service.py | 181 +++++++++++++++ docker-compose.yml | 2 + frontend/src/api/index.js | 5 + frontend/src/layouts/MainLayout.vue | 4 + frontend/src/router/index.js | 1 + frontend/src/views/Avatars.vue | 300 +++++++++++++++++++++++++ 9 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/endpoints/avatars.py create mode 100644 backend/app/services/avatar_service.py create mode 100644 frontend/src/views/Avatars.vue diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index d875c26..b7dc2b0 100755 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,6 +1,6 @@ """API路由汇总""" from fastapi import APIRouter -from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs +from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs, avatars router = APIRouter() @@ -10,3 +10,4 @@ router.include_router(ai_models.router, prefix="/ai-models", tags=["AI模型配 router.include_router(dashboard.router, prefix="/dashboard", tags=["数据看板"]) router.include_router(system.router, prefix="/system", tags=["系统设置"]) router.include_router(logs.router, prefix="/logs", tags=["日志管理"]) +router.include_router(avatars.router, prefix="/avatars", tags=["数字分身管理"]) diff --git a/backend/app/api/endpoints/avatars.py b/backend/app/api/endpoints/avatars.py new file mode 100644 index 0000000..3196e74 --- /dev/null +++ b/backend/app/api/endpoints/avatars.py @@ -0,0 +1,65 @@ +"""数字分身管理 API 端点""" +from fastapi import APIRouter, Query, HTTPException + +from app.schemas import ApiResponse +from app.services.avatar_service import avatar_service, get_session + +router = APIRouter() + + +@router.get("") +def list_avatars( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + keyword: str = Query(default=None), + status: str = Query(default=None), +): + """分页查询所有数字分身""" + db = get_session() + try: + total, items = avatar_service.list_avatars( + db, page, page_size, keyword, status + ) + return ApiResponse( + data={ + "total": total, + "page": page, + "page_size": page_size, + "items": items, + } + ) + finally: + db.close() + + +@router.get("/{avatar_id}") +def get_avatar(avatar_id: str): + """获取单个数字分身详情""" + db = get_session() + try: + data = avatar_service.get_avatar(db, avatar_id) + if not data: + raise HTTPException(status_code=404, detail="数字分身不存在") + return ApiResponse(data=data) + finally: + db.close() + + +@router.put("/{avatar_id}/status") +def update_avatar_status(avatar_id: str, body: dict): + """更新数字分身状态(开机/关机)""" + status = body.get("status") + if status not in ("active", "inactive", "training"): + raise HTTPException( + status_code=400, + detail="status 必须是 active、inactive 或 training", + ) + db = get_session() + try: + data = avatar_service.update_status(db, avatar_id, status) + if not data: + raise HTTPException(status_code=404, detail="数字分身不存在") + msg = "已开机" if status == "active" else "已关机" + return ApiResponse(data=data, message=msg) + finally: + db.close() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7b00a17..87a2964 100755 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -25,6 +25,9 @@ class Settings(BaseSettings): "NEWS_PLATFORM_BASE_URL", "http://192.168.1.200:63120" ) + # 数字分身 SQLite 数据库路径(直连数字分身应用的 SQLite) + AVATAR_DB_PATH: str = os.getenv("AVATAR_DB_PATH", "") + # 日志目录 LOG_DIR: str = "/app/logs" diff --git a/backend/app/services/avatar_service.py b/backend/app/services/avatar_service.py new file mode 100644 index 0000000..90d1fd4 --- /dev/null +++ b/backend/app/services/avatar_service.py @@ -0,0 +1,181 @@ +"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库""" +import os +from typing import Optional, Tuple + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker, Session + +from app.core.config import settings + + +_engine = None +_SessionLocal: Optional[sessionmaker] = None + + +def _get_engine_and_session(): + global _engine, _SessionLocal + if _engine is None: + db_path = settings.AVATAR_DB_PATH + if not db_path: + # 默认路径:从 backend/app/core/ 向上三级 + base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + db_path = os.path.join(base, "digital-avatar-app", "backend", "avatar.db") + if not os.path.isabs(db_path): + db_path = os.path.abspath(db_path) + if not os.path.exists(db_path): + raise FileNotFoundError(f"数字分身数据库不存在: {db_path}") + _engine = create_engine( + f"sqlite:///{db_path}", + connect_args={"check_same_thread": False}, + ) + _SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False) + return _engine, _SessionLocal() + + +def get_session() -> Session: + _, session = _get_engine_and_session() + return session + + +class AvatarService: + + @staticmethod + def list_avatars( + db: Session, + page: int = 1, + page_size: int = 20, + keyword: Optional[str] = None, + status: Optional[str] = None, + ) -> Tuple[int, list]: + """分页查询所有数字分身(含归属用户信息)""" + where_clauses = [] + params = {} + + if keyword: + where_clauses.append( + "(a.name LIKE :kw OR a.display_name LIKE :kw)" + ) + params["kw"] = f"%{keyword}%" + + if status: + where_clauses.append("a.status = :status") + params["status"] = status + + where_sql = "" + if where_clauses: + where_sql = "WHERE " + " AND ".join(where_clauses) + + # 计数 + count_sql = f"SELECT COUNT(*) FROM avatars a {where_sql}" + total = db.execute(text(count_sql), params).scalar() or 0 + + # 分页查询 + offset = (page - 1) * page_size + params["limit"] = page_size + params["offset"] = offset + + query = text(f""" + SELECT + a.id, a.name, a.display_name, a.description, + a.photo_url, a.emoji, a.status, a.token_balance, + a.config, a.created_at, a.updated_at, + u.nickname AS owner_nickname, + u.phone AS owner_phone + FROM avatars a + LEFT JOIN users u ON a.owner_id = u.huihui_user_id + {where_sql} + ORDER BY a.created_at DESC + LIMIT :limit OFFSET :offset + """) + rows = db.execute(query, params).fetchall() + + items = [] + for row in rows: + config = {} + if row.config: + if isinstance(row.config, str): + import json + try: + config = json.loads(row.config) + except (json.JSONDecodeError, ValueError): + config = {} + elif isinstance(row.config, dict): + config = row.config + items.append({ + "id": row.id, + "name": row.name, + "display_name": row.display_name, + "description": row.description or "", + "photo_url": row.photo_url or "", + "emoji": row.emoji or "🤖", + "status": row.status or "active", + "token_balance": row.token_balance or 0, + "config": config, + "owner_nickname": row.owner_nickname or "", + "owner_phone": row.owner_phone or "", + "created_at": str(row.created_at) if row.created_at else "", + "updated_at": str(row.updated_at) if row.updated_at else "", + }) + + return total, items + + @staticmethod + def get_avatar(db: Session, avatar_id: str) -> Optional[dict]: + """获取单个数字分身详情""" + query = text(""" + SELECT + a.id, a.name, a.display_name, a.description, + a.photo_url, a.emoji, a.status, a.token_balance, + a.config, a.created_at, a.updated_at, + u.nickname AS owner_nickname, + u.phone AS owner_phone + FROM avatars a + LEFT JOIN users u ON a.owner_id = u.huihui_user_id + WHERE a.id = :avatar_id + """) + row = db.execute(query, {"avatar_id": avatar_id}).fetchone() + if not row: + return None + + config = {} + if row.config: + if isinstance(row.config, str): + import json + try: + config = json.loads(row.config) + except (json.JSONDecodeError, ValueError): + config = {} + elif isinstance(row.config, dict): + config = row.config + + return { + "id": row.id, + "name": row.name, + "display_name": row.display_name, + "description": row.description or "", + "photo_url": row.photo_url or "", + "emoji": row.emoji or "🤖", + "status": row.status or "active", + "token_balance": row.token_balance or 0, + "config": config, + "owner_nickname": row.owner_nickname or "", + "owner_phone": row.owner_phone or "", + "created_at": str(row.created_at) if row.created_at else "", + "updated_at": str(row.updated_at) if row.updated_at else "", + } + + @staticmethod + def update_status(db: Session, avatar_id: str, status: str) -> Optional[dict]: + """更新数字分身状态(开关机)""" + query = text(""" + UPDATE avatars SET status = :status, updated_at = datetime('now') + WHERE id = :avatar_id + """) + result = db.execute(query, {"status": status, "avatar_id": avatar_id}) + db.commit() + if result.rowcount == 0: + return None + return AvatarService.get_avatar(db, avatar_id) + + +avatar_service = AvatarService() diff --git a/docker-compose.yml b/docker-compose.yml index f5bc92c..c9908da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,10 +18,12 @@ services: - SECRET_KEY=your-secret-key-change-in-production - AES_KEY=your-aes-key-32-chars-change-now! - TZ=Asia/Shanghai + - AVATAR_DB_PATH=/app/avatar.db volumes: - ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建 - ./backend/logs:/app/logs - ./backend/config:/app/config + - ./digital-avatar-app/backend/avatar.db:/app/avatar.db:ro # 数字分身 SQLite(只读) depends_on: - ai-virtual-mysql - ai-virtual-redis diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 976a51a..2d5dc51 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -86,5 +86,10 @@ export const getLoginLogs = (params) => request.get('/logs/login', { params }) export const getLogFiles = () => request.get('/logs/files') export const tailLogFile = (filename, lines = 100) => request.get(`/logs/files/${filename}/tail`, { params: { lines } }) +// Avatars (数字分身管理) +export const getAvatars = (params) => request.get('/avatars', { params }) +export const getAvatarDetail = (id) => request.get(`/avatars/${id}`) +export const updateAvatarStatus = (id, status) => request.put(`/avatars/${id}/status`, { status }) + export default request export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } }) diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index b9555aa..3225c75 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -11,6 +11,10 @@ 数据看板 + + + 数字分身管理 + 虚拟用户 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 02b7025..22e4806 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -7,6 +7,7 @@ const routes = [ children: [ { path: '', redirect: '/dashboard' }, { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据看板' } }, + { path: 'avatars', component: () => import('@/views/Avatars.vue'), meta: { title: '数字分身管理' } }, { path: 'users', component: () => import('@/views/Users.vue'), meta: { title: '虚拟用户管理' } }, { path: 'interactions', component: () => import('@/views/Interactions.vue'), meta: { title: '互动记录' } }, { path: 'ai-models', component: () => import('@/views/AIModels.vue'), meta: { title: 'AI模型配置' } }, diff --git a/frontend/src/views/Avatars.vue b/frontend/src/views/Avatars.vue new file mode 100644 index 0000000..d3ea9a4 --- /dev/null +++ b/frontend/src/views/Avatars.vue @@ -0,0 +1,300 @@ + + + + +