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

591 lines
14 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 v-if="!isEmbedded" class="page-header">
<div class="header-left">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">数字分身管理</h1>
</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>
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
<div class="token-card">
<div class="token-info">
<span class="token-label">积分余额</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">
<div class="section-heading-copy">
<h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span>
</div>
<button class="section-create-btn" type="button" @click="goCreate">
<span aria-hidden="true">+</span> 添加分身
</button>
</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() }} 积分</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'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter()
const avatarStore = useAvatarStore()
const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false
// 当前登录会会用户的资料(头像 / 昵称)
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-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;
justify-content: space-between;
gap: 12px;
margin: 8px 0 12px;
}
.section-heading-copy {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.section-create-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 8px 12px;
border: 1px solid #FED7B5;
border-radius: 999px;
color: #E9650C;
background: #FFF7ED;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.section-create-btn span { font-size: 17px; line-height: 1; }
.section-create-btn:active { background: #FFEDD5; }
.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>