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
This commit is contained in:
stefanfeng
2026-08-06 17:43:50 +08:00
parent 1da5f7f10d
commit bbbc97eb4a
9 changed files with 563 additions and 1 deletions
+181
View File
@@ -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()