Compare commits

...
Author SHA1 Message Date
stefanfeng a5f7c86a4b fix: avatar list table full-width and image fallback
- Changed fixed width columns to min-width for elastic layout
- Add @error handler on el-avatar to hide broken images and show emoji
- Detail dialog avatar also handles image load errors
2026-08-07 14:04:45 +08:00
stefanfeng 6026b1279e fix: avatar list fixes — full-width table, absolute photo URLs, global token balance
- Table width set to 100%
- Photo URLs resolved to absolute paths using AVATAR_BACKEND_URL
- Token balance now reads from global token_account table
- Config: added AVATAR_BACKEND_URL setting
2026-08-07 13:53:02 +08:00
stefanfeng 57af1c7db1 fix: gracefully handle missing avatar SQLite database
- Return empty list instead of crashing when avatar.db doesn't exist
- Add is_available() check before each endpoint
- 503 status when trying to access unavailable database
2026-08-06 17:52:01 +08:00
4 changed files with 64 additions and 13 deletions
+7 -1
View File
@@ -2,7 +2,7 @@
from fastapi import APIRouter, Query, HTTPException
from app.schemas import ApiResponse
from app.services.avatar_service import avatar_service, get_session
from app.services.avatar_service import avatar_service, get_session, is_available
router = APIRouter()
@@ -15,6 +15,8 @@ def list_avatars(
status: str = Query(default=None),
):
"""分页查询所有数字分身"""
if not is_available():
return ApiResponse(data={"total": 0, "page": page, "page_size": page_size, "items": []})
db = get_session()
try:
total, items = avatar_service.list_avatars(
@@ -35,6 +37,8 @@ def list_avatars(
@router.get("/{avatar_id}")
def get_avatar(avatar_id: str):
"""获取单个数字分身详情"""
if not is_available():
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
db = get_session()
try:
data = avatar_service.get_avatar(db, avatar_id)
@@ -54,6 +58,8 @@ def update_avatar_status(avatar_id: str, body: dict):
status_code=400,
detail="status 必须是 active、inactive 或 training",
)
if not is_available():
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
db = get_session()
try:
data = avatar_service.update_status(db, avatar_id, status)
+3
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
# 数字分身 SQLite 数据库路径(直连数字分身应用的 SQLite)
AVATAR_DB_PATH: str = os.getenv("AVATAR_DB_PATH", "")
# 数字分身后端服务地址(用于拼接头像等文件 URL)
AVATAR_BACKEND_URL: str = os.getenv("AVATAR_BACKEND_URL", "")
# 日志目录
LOG_DIR: str = "/app/logs"
+37 -6
View File
@@ -23,7 +23,8 @@ def _get_engine_and_session():
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}")
# 数据库不存在时返回 None,由调用方处理
return None, None
_engine = create_engine(
f"sqlite:///{db_path}",
connect_args={"check_same_thread": False},
@@ -32,11 +33,39 @@ def _get_engine_and_session():
return _engine, _SessionLocal()
def get_session() -> Session:
def get_session() -> Optional[Session]:
_, session = _get_engine_and_session()
return session
def is_available() -> bool:
"""检查数字分身数据库是否可用"""
engine, _ = _get_engine_and_session()
return engine is not None
def _resolve_photo_url(photo_url: str) -> str:
"""将相对路径的头像 URL 补全为绝对路径"""
if not photo_url:
return ""
if photo_url.startswith(("http://", "https://")):
return photo_url
base = settings.AVATAR_BACKEND_URL
if base:
base = base.rstrip("/")
return f"{base}{photo_url}"
return photo_url
def _get_global_token_balance(db: Session) -> int:
"""获取全局 token_account 余额(单行表)"""
try:
result = db.execute(text("SELECT balance FROM token_account LIMIT 1")).fetchone()
return result.balance if result and result.balance else 0
except Exception:
return 0
class AvatarService:
@staticmethod
@@ -90,6 +119,7 @@ class AvatarService:
rows = db.execute(query, params).fetchall()
items = []
global_token = _get_global_token_balance(db)
for row in rows:
config = {}
if row.config:
@@ -106,10 +136,10 @@ class AvatarService:
"name": row.name,
"display_name": row.display_name,
"description": row.description or "",
"photo_url": row.photo_url or "",
"photo_url": _resolve_photo_url(row.photo_url),
"emoji": row.emoji or "🤖",
"status": row.status or "active",
"token_balance": row.token_balance or 0,
"token_balance": global_token or (row.token_balance or 0),
"config": config,
"owner_nickname": row.owner_nickname or "",
"owner_phone": row.owner_phone or "",
@@ -148,15 +178,16 @@ class AvatarService:
elif isinstance(row.config, dict):
config = row.config
global_token = _get_global_token_balance(db)
return {
"id": row.id,
"name": row.name,
"display_name": row.display_name,
"description": row.description or "",
"photo_url": row.photo_url or "",
"photo_url": _resolve_photo_url(row.photo_url),
"emoji": row.emoji or "🤖",
"status": row.status or "active",
"token_balance": row.token_balance or 0,
"token_balance": global_token or (row.token_balance or 0),
"config": config,
"owner_nickname": row.owner_nickname or "",
"owner_phone": row.owner_phone or "",
+17 -6
View File
@@ -23,10 +23,13 @@
<!-- Table -->
<el-table :data="avatars" v-loading="loading" :row-class-name="rowClassName" class="data-table">
<el-table-column label="分身" width="240">
<el-table-column label="分身" min-width="220">
<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">
<el-avatar v-if="row.photo_url" :size="40" :src="row.photo_url" style="background:#30363d;flex-shrink:0" @error="handleAvatarError">
{{ row.emoji || '🤖' }}
</el-avatar>
<el-avatar v-else :size="40" style="background:#30363d;flex-shrink:0">
{{ row.emoji || '🤖' }}
</el-avatar>
<div>
@@ -36,7 +39,7 @@
</div>
</template>
</el-table-column>
<el-table-column label="归属用户" width="160">
<el-table-column label="归属用户" min-width="140">
<template #default="{ row }">
<span v-if="row.owner_nickname">{{ row.owner_nickname }}</span>
<span v-else style="color:var(--color-text-muted)">未绑定</span>
@@ -52,7 +55,7 @@
<span style="font-weight:600">{{ row.token_balance?.toLocaleString() ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<el-table-column label="创建时间" min-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>
@@ -92,9 +95,12 @@
<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">
<el-avatar v-if="detailAvatar.photo_url" :size="72" :src="detailAvatar.photo_url" style="background:#30363d" @error="handleAvatarError">
<span style="font-size:32px">{{ detailAvatar.emoji || '🤖' }}</span>
</el-avatar>
<el-avatar v-else :size="72" 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>
@@ -249,6 +255,11 @@ function openDetail(row) {
detailVisible.value = true
}
function handleAvatarError(e) {
// 图片加载失败时隐藏 img,只保留 emoji fallback
e.target.style.display = 'none'
}
function rowClassName({ rowIndex }) {
return rowIndex % 2 === 0 ? 'row-dark' : 'row-darker'
}
@@ -259,7 +270,7 @@ onUnmounted(() => window.removeEventListener('page-refresh', loadAvatars))
<style scoped>
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.data-table { border-radius: 8px; overflow: hidden; }
.data-table { border-radius: 8px; overflow: hidden; width: 100%; }
/* Detail panel */
.detail-header { display: flex; align-items: center; }