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:
@@ -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=["数字分身管理"])
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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" } })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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模型配置' } },
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user