Files
huihuiSquare/digital-avatar-app/src/views/AvatarManage.vue
T

559 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="avatar-manage-page">
<!-- 顶部导航 -->
<header class="page-header">
<div class="header-left">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">数字分身管理</h1>
</div>
<!-- 右上角创建入口 -->
<div class="header-right">
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
</div>
</header>
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
<section class="profile-section">
<div class="profile-avatar">
<img v-if="me?.avatarUrl" :src="me.avatarUrl" alt="头像" referrerpolicy="no-referrer" />
<span v-else class="profile-avatar-fallback">🤖</span>
</div>
<div class="profile-meta">
<span class="profile-name">{{ me?.nickname || '会会用户' }}</span>
<span class="profile-sub">{{ me?.phone ? '手机 ' + me.phone : '已登录 · 会会账号' }}</span>
</div>
</section>
<!-- Token 余额条 -->
<section class="token-section">
<div class="token-card">
<div class="token-info">
<span class="token-label">Token 余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div>
<button class="recharge-btn" @click="goToRecharge">充值</button>
</div>
</section>
<!-- 数字分身列表(只放分身相关) -->
<section class="avatar-list-section">
<div class="section-head">
<h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span>
</div>
<div v-if="avatars.length" class="avatar-list">
<div class="avatar-card" v-for="a in avatars" :key="a.id">
<button class="card-edit" @click="goToEdit(a.id)" :aria-label="`编辑${a.displayName || a.name}`">
<span aria-hidden="true">✎</span> 编辑
</button>
<div class="avatar-card-main">
<div class="avatar-photo">
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
<div v-else class="avatar-placeholder">{{ a.emoji || '🤖' }}</div>
</div>
<div class="avatar-details">
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span>
</div>
</div>
<div class="avatar-actions">
<button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button>
<button class="knowledge-link" @click="goToKnowledge(a.id)"><span>📚</span> 知识库</button>
<button class="authorization-link" @click="goToAuthorization(a.id)"><span>🔑</span> 授权</button>
<button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<span class="empty-icon">🤖</span>
<p class="empty-text">还没有数字分身</p>
<button class="empty-create-btn" @click="goCreate">立即创建一个</button>
</div>
</section>
<p v-if="shareToast" class="share-toast">{{ shareToast }}</p>
<!-- 分身工具和分身动态暂不展示,入口统一收拢到每张分身卡片。 -->
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user'
import { createAvatarShareLink } from '@/api'
const router = useRouter()
const avatarStore = useAvatarStore()
const userStore = useUserStore()
// 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user)
// 状态(来自 store / 后端)
const tokenBalance = computed(() => avatarStore.tokenBalance)
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
const avatars = computed(() => avatarStore.avatars)
const avatarTokenUsage = (id: string) => avatarStore.tokenUsageByAvatar[id] || 0
const shareToast = ref('')
// 状态文本
const statusText = (status: string) => {
const map: Record<string, string> = {
'active': '活跃中',
'inactive': '未激活',
'training': '训练中'
}
return map[status] || status
}
// 导航
const goBack = () => {
router.back()
}
const goToRecharge = () => {
router.push('/token/charge')
}
const goCreate = () => {
router.push('/avatar/create')
}
const goToKnowledge = (id: string) => {
router.push({ name: 'AvatarKnowledgeManage', params: { avatarId: id } })
}
const goToAuthorization = (id: string) => {
router.push({ name: 'AvatarAuthorizationManage', params: { avatarId: id } })
}
const goToEdit = (id: string) => {
router.push(`/avatar/edit/${id}`)
}
const goToChat = (id: string) => {
router.push(`/avatar/chat/${id}`)
}
const showShareToast = (message: string) => {
shareToast.value = message
window.setTimeout(() => { shareToast.value = '' }, 2400)
}
const copyShareLink = async (link: string) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link)
showShareToast('公开对话链接已复制')
return
}
window.prompt('复制公开对话链接', link)
}
const shareAvatar = async (avatar: any) => {
try {
const result: any = await createAvatarShareLink(avatar.id)
const token = result?.shareToken
if (!token) throw new Error('未能生成分享链接')
const link = `${window.location.origin}${window.location.pathname}#/share/${token}`
const title = `${avatar.displayName || avatar.name},和我聊聊`
if (navigator.share) {
await navigator.share({ title, text: avatar.description || '点击和我聊聊', url: link })
showShareToast('已唤起分享')
return
}
await copyShareLink(link)
} catch (error: any) {
if (error?.name !== 'AbortError') showShareToast(error?.message || '分享链接生成失败')
}
}
onMounted(() => {
userStore.loadFromStorage()
avatarStore.loadAvatars()
avatarStore.loadTokenBalance()
avatarStore.loadTokenUsage()
})
</script>
<style scoped>
.avatar-manage-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: calc(28px + env(safe-area-inset-bottom));
}
/* 顶部导航 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
color: white;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 4px;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
}
/* 用户资料头 */
.profile-section {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px 8px;
background: #F8F9FA;
}
.profile-avatar {
width: 54px;
height: 54px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, #FFB36B, #FF7A1A);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.28);
border: 2px solid #fff;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-avatar-fallback {
font-size: 28px;
}
.profile-meta {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.profile-name {
font-size: 17px;
font-weight: 700;
color: #18191C;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.profile-sub {
font-size: 12px;
color: #9398AE;
}
.icon-btn {
background: rgba(255, 255, 255, 0.18);
border: none;
color: white;
font-size: 18px;
width: 36px;
height: 36px;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Token 余额条 */
.token-section {
padding: 16px 20px;
}
.token-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.token-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.token-label {
font-size: 13px;
color: #6B7280;
}
.token-amount {
font-size: 24px;
font-weight: 700;
color: #F97316;
}
.token-used {
margin-top: 3px;
color: #A0A5B4;
font-size: 11px;
}
.recharge-btn {
padding: 8px 16px;
background: #F97316;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.recharge-btn:hover {
opacity: 0.9;
}
/* 分身列表 */
.avatar-list-section {
padding: 0 20px 8px;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #18191C;
}
.count-badge {
font-size: 12px;
font-weight: 600;
color: #F97316;
background: #FFF0E6;
border-radius: 999px;
padding: 2px 10px;
}
.avatar-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.avatar-card {
position: relative;
padding: 16px;
background: white;
border: 1px solid #F4E5D9;
border-radius: 18px;
box-shadow: 0 8px 22px rgba(112, 62, 22, .07);
}
.avatar-card-main { display: flex; align-items: center; gap: 14px; padding: 4px 68px 2px 0; }
.card-edit {
position: absolute;
top: 12px;
right: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 7px 10px;
border: 1px solid #E9E2DC;
border-radius: 999px;
color: #6B625C;
background: #FAF8F6;
font-size: 12px;
cursor: pointer;
}
.card-edit:active { background: #F2EDE8; }
.avatar-photo {
width: 66px;
height: 66px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: #FFF0E6;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder {
font-size: 28px;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-details {
flex: 1;
min-width: 0;
}
.avatar-name-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.avatar-name {
min-width: 0;
overflow: hidden;
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: #18191C;
}
.avatar-desc {
font-size: 13px;
color: #9398AE;
margin: 5px 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar-token-usage {
display: inline-block;
margin-top: 5px;
color: #A0A5B4;
font-size: 10px;
}
.avatar-status {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 4px;
color: #75809A;
font-size: 11px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.active {
background: #22C55E;
}
.status-dot.inactive {
background: #9398AE;
}
.status-dot.training {
background: #F59E0B;
}
.avatar-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
}
.avatar-actions button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 0;
min-height: 58px;
padding: 8px 3px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.avatar-actions button span { font-size: 17px; line-height: 1; }
.chat-link {
background: linear-gradient(135deg, #F97316, #FB923C);
color: #fff;
border: none;
}
.knowledge-link { border: 1px solid #F5D7B8; color: #A85418; background: #FFF8F1; }
.authorization-link { border: 1px solid #DCE5F2; color: #47627D; background: #F6F9FC; }
.share-link { border: 1px solid #E5E1DD; color: #675E58; background: #FAF8F6; }
.share-toast { position: fixed; left: 50%; bottom: 24px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-text {
font-size: 14px;
color: #9398AE;
margin: 0 0 16px;
}
.empty-create-btn {
padding: 10px 24px;
background: linear-gradient(135deg, #F97316, #FB923C);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
}
</style>