Files
huihuiSquare/frontend/src/views/Avatars.vue
T
stefanfeng b7f8ac203b feat: add avatar photo upload via Huihui filecenter
- Add POST /avatars/{id}/upload-photo endpoint
- Use Huihui platform's filecenter API for image upload (same signing mechanism as news_service)
- Add upload button in avatar list table
- Frontend: handleUploadPhoto function with el-upload component
- Updates avatar photo_url in SQLite after successful upload
2026-08-07 14:28:22 +08:00

338 lines
14 KiB
Vue

<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="分身" min-width="220">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:10px">
<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>
<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="归属用户" 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>
</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="创建时间" 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>
</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="160" fixed="right">
<template #default="{ row }">
<div style="display:flex;gap:4px">
<el-upload :show-file-list="false" accept="image/*" :before-upload="(file) => handleUploadPhoto(row, file)">
<el-button size="small" :loading="row._uploading"> 上传</el-button>
</el-upload>
<el-button size="small" @click="openDetail(row)">详情</el-button>
</div>
</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 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>
<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, uploadAvatarPhoto } 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
}
async function handleUploadPhoto(row, file) {
const formData = new FormData()
formData.append('file', file)
row._uploading = true
try {
const res = await uploadAvatarPhoto(row.id, formData)
if (res.code === 200) {
row.photo_url = res.data?.photo_url || row.photo_url
ElMessage.success('头像上传成功')
loadAvatars()
} else {
ElMessage.error(res.message || '上传失败')
}
} catch (e) {
ElMessage.error('上传失败:' + (e.message || '未知错误'))
} finally {
row._uploading = false
}
return false // prevent el-upload default behavior
}
function handleAvatarError(e) {
// 图片加载失败时隐藏 img,只保留 emoji fallback
e.target.style.display = 'none'
}
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; width: 100%; }
/* 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>