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
+2 -1
View File
@@ -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=["数字分身管理"])
+65
View File
@@ -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()
+3
View File
@@ -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"
+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()
+2
View File
@@ -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
+5
View File
@@ -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" } })
+4
View File
@@ -11,6 +11,10 @@
<el-icon><DataAnalysis /></el-icon>
<span>数据看板</span>
</el-menu-item>
<el-menu-item index="/avatars">
<el-icon><UserFilled /></el-icon>
<span>数字分身管理</span>
</el-menu-item>
<el-menu-item index="/users">
<el-icon><User /></el-icon>
<span>虚拟用户</span>
+1
View File
@@ -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模型配置' } },
+300
View File
@@ -0,0 +1,300 @@
<template>
<div class="page-container">
<!-- Header -->
<div class="page-header">
<div style="display:flex;align-items:center;gap:12px">
<span class="page-title">数字分身管理</span>
<el-tag type="info">共 {{ total }} 个分身</el-tag>
</div>
</div>
<!-- Filters -->
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索分身名称" clearable @change="loadAvatars" style="width:200px">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filterStatus" placeholder="状态" clearable @change="loadAvatars" style="width:130px">
<el-option label="运行中" value="active" />
<el-option label="已关机" value="inactive" />
<el-option label="训练中" value="training" />
</el-select>
<el-button @click="resetFilters">重置</el-button>
</div>
<!-- Table -->
<el-table :data="avatars" v-loading="loading" :row-class-name="rowClassName" class="data-table">
<el-table-column label="分身" width="240">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:10px">
<el-avatar :size="40" :src="row.photo_url || ''" style="background:#30363d;flex-shrink:0">
{{ row.emoji || '🤖' }}
</el-avatar>
<div>
<div style="font-weight:600;font-size:13px">{{ row.name }}</div>
<div v-if="row.display_name" style="font-size:11px;color:var(--color-text-muted)">{{ row.display_name }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="归属用户" width="160">
<template #default="{ row }">
<span v-if="row.owner_nickname">{{ row.owner_nickname }}</span>
<span v-else style="color:var(--color-text-muted)">未绑定</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ statusLabel[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Token 余额" width="120" align="center">
<template #default="{ row }">
<span style="font-weight:600">{{ row.token_balance?.toLocaleString() ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.created_at ? new Date(row.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '--' }}</span>
</template>
</el-table-column>
<el-table-column label="开关机" width="100" align="center">
<template #default="{ row }">
<el-tooltip :content="row.status === 'active' ? '点击关机' : '点击开机'" placement="top">
<el-switch
:model-value="row.status === 'active'"
@change="toggleStatus(row)"
:loading="row._switching"
active-color="var(--color-accent-green)"
inactive-color="var(--color-accent-orange)"
inline-prompt
active-text="开"
inactive-text="关"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize"
:total="total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@change="loadAvatars" style="margin-top:16px;justify-content:flex-end;display:flex"
/>
<!-- Detail Dialog -->
<el-dialog v-model="detailVisible" title="数字分身详情" width="640px">
<div v-if="detailAvatar" class="detail-panel">
<!-- Header with avatar -->
<div class="detail-header">
<el-avatar :size="72" :src="detailAvatar.photo_url || ''" style="background:#30363d">
<span style="font-size:32px">{{ detailAvatar.emoji || '🤖' }}</span>
</el-avatar>
<div style="margin-left:16px">
<div class="detail-name">{{ detailAvatar.name }}</div>
<div v-if="detailAvatar.display_name" class="detail-display-name">{{ detailAvatar.display_name }}</div>
<el-tag :type="statusType[detailAvatar.status]" size="small" style="margin-top:4px">
{{ statusLabel[detailAvatar.status] }}
</el-tag>
</div>
</div>
<el-divider />
<!-- Basic Info -->
<div class="detail-section">
<div class="detail-section-title">基本信息</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">名称</span>
<span class="detail-value">{{ detailAvatar.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">显示名称</span>
<span class="detail-value">{{ detailAvatar.display_name || '--' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">归属用户</span>
<span class="detail-value">{{ detailAvatar.owner_nickname || '未绑定' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Token 余额</span>
<span class="detail-value">{{ detailAvatar.token_balance?.toLocaleString() ?? 0 }}</span>
</div>
<div class="detail-item">
<span class="detail-label">创建时间</span>
<span class="detail-value">{{ detailAvatar.created_at ? new Date(detailAvatar.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '--' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">更新时间</span>
<span class="detail-value">{{ detailAvatar.updated_at ? new Date(detailAvatar.updated_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '--' }}</span>
</div>
</div>
</div>
<el-divider />
<!-- Description -->
<div class="detail-section">
<div class="detail-section-title">描述</div>
<div class="detail-value" style="line-height:1.6">{{ detailAvatar.description || '暂无描述' }}</div>
</div>
<el-divider />
<!-- Config -->
<div class="detail-section">
<div class="detail-section-title">配置参数</div>
<div v-if="hasConfig" class="detail-grid">
<div class="detail-item" v-if="detailAvatar.config.replyStyle">
<span class="detail-label">回复风格</span>
<span class="detail-value">{{ detailAvatar.config.replyStyle }}</span>
</div>
<div class="detail-item" v-if="detailAvatar.config.creativity">
<span class="detail-label">创造力</span>
<span class="detail-value">{{ detailAvatar.config.creativity }}</span>
</div>
<div class="detail-item" v-if="detailAvatar.config.rigor">
<span class="detail-label">严谨度</span>
<span class="detail-value">{{ detailAvatar.config.rigor }}</span>
</div>
<div class="detail-item" v-if="detailAvatar.config.humor">
<span class="detail-label">幽默度</span>
<span class="detail-value">{{ detailAvatar.config.humor }}</span>
</div>
<div class="detail-item" v-if="detailAvatar.config.responseLength">
<span class="detail-label">回复长度</span>
<span class="detail-value">{{ detailAvatar.config.responseLength }}</span>
</div>
</div>
<div v-else style="color:var(--color-text-muted);font-size:13px">暂无配置</div>
<div v-if="detailAvatar.config.systemPrompt" style="margin-top:12px">
<span class="detail-label" style="display:block;margin-bottom:4px">系统提示词</span>
<pre class="system-prompt">{{ detailAvatar.config.systemPrompt }}</pre>
</div>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getAvatars, updateAvatarStatus } from '@/api'
const avatars = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const filterStatus = ref('')
const statusType = { active: 'success', inactive: 'info', training: 'warning' }
const statusLabel = { active: '运行中', inactive: '已关机', training: '训练中' }
const detailVisible = ref(false)
const detailAvatar = ref(null)
const hasConfig = computed(() => {
if (!detailAvatar.value || !detailAvatar.value.config) return false
const c = detailAvatar.value.config
return c.replyStyle || c.creativity || c.rigor || c.humor || c.responseLength
})
async function loadAvatars() {
loading.value = true
try {
const params = { page: page.value, page_size: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (filterStatus.value) params.status = filterStatus.value
const res = await getAvatars(params)
avatars.value = res.data?.items || []
total.value = res.data?.total || 0
} finally { loading.value = false }
}
function resetFilters() {
keyword.value = ''
filterStatus.value = ''
page.value = 1
loadAvatars()
}
async function toggleStatus(row) {
const newStatus = row.status === 'active' ? 'inactive' : 'active'
row._switching = true
try {
await updateAvatarStatus(row.id, newStatus)
row.status = newStatus
const msg = newStatus === 'active' ? '已开机' : '已关机'
ElMessage.success(msg)
} catch {
ElMessage.error('状态切换失败')
} finally {
row._switching = false
}
}
function openDetail(row) {
detailAvatar.value = row
detailVisible.value = true
}
function rowClassName({ rowIndex }) {
return rowIndex % 2 === 0 ? 'row-dark' : 'row-darker'
}
onMounted(() => { loadAvatars(); window.addEventListener('page-refresh', loadAvatars) })
onUnmounted(() => window.removeEventListener('page-refresh', loadAvatars))
</script>
<style scoped>
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.data-table { border-radius: 8px; overflow: hidden; }
/* Detail panel */
.detail-header { display: flex; align-items: center; }
.detail-name { font-size: 18px; font-weight: 700; }
.detail-display-name { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; }
.detail-section { margin-bottom: 4px; }
.detail-section-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.detail-item { display: flex; align-items: center; gap: 8px; }
.detail-label { font-size: 12px; color: var(--color-text-muted); white-space: nowrap; width: 70px; flex-shrink: 0; }
.detail-value { font-size: 13px; }
.system-prompt {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.6;
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
color: var(--color-text);
}
/* Dark theme rows */
:deep(.row-dark td) { background: var(--color-bg-card) !important; }
:deep(.row-darker td) { background: var(--color-bg-secondary) !important; }
:deep(.el-table__row:hover td) { background: rgba(88,166,255,0.08) !important; }
:deep(.el-table__header-wrapper th) {
background: var(--color-bg) !important;
color: var(--color-text-muted) !important;
border-bottom: 2px solid var(--color-border) !important;
font-size: 12px !important;
font-weight: 600 !important;
}
:deep(.el-table__body-wrapper) { background: transparent !important; }
:deep(.el-table) { background: transparent !important; }
</style>