Files
huihuiSquare/digital-avatar-app/src/views/AvatarCreate.vue
2026-07-24 14:04:21 +08:00

493 lines
19 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="create-page">
<!-- 步骤 0引导落地页 -->
<div v-if="step === 0" class="landing">
<header class="hero">
<div class="hero-icon">🤖</div>
<h1 class="hero-title">创建你的数字分身</h1>
<p class="hero-sub">AI 助手为你工作社交创造价值</p>
</header>
<section class="features">
<div class="feature-card">
<div class="feature-icon">💬</div>
<h3>智能对话</h3>
<p>24/7 在线自动回复消息处理咨询</p>
</div>
<div class="feature-card">
<div class="feature-icon">📅</div>
<h3>日程管理</h3>
<p>智能安排会议提醒重要事项</p>
</div>
<div class="feature-card">
<div class="feature-icon">🤝</div>
<h3>社交助手</h3>
<p>管理人脉扩展社交圈</p>
</div>
</section>
<section class="cta">
<button class="create-btn" @click="step = 1">
<span class="btn-icon"></span> 开始创建
</button>
<p class="hint">只需 4 快速拥有你的 AI 分身</p>
</section>
<section class="existing" v-if="avatarStore.avatars.length > 0">
<p>已有数字分身</p>
<button class="link-btn" @click="goManage">前往管理 </button>
</section>
</div>
<!-- 步骤 1-4创建向导 -->
<div v-else class="wizard">
<header class="wizard-header">
<button class="back" @click="prev"></button>
<h2 class="w-title">{{ stepTitles[step - 1] }}</h2>
<span class="step-count">{{ step }}/4</span>
</header>
<div class="progress">
<div class="progress-bar" :style="{ width: (step / 4 * 100) + '%' }"></div>
</div>
<div class="wizard-body">
<!-- Step 1基础信息 -->
<div v-if="step === 1" class="form">
<div class="field">
<label class="field-label">分身头像</label>
<div class="avatar-pick">
<div class="avatar-pick-preview">
<img v-if="form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
<span v-else>{{ form.emoji }}</span>
</div>
<p class="avatar-pick-hint">已沿用你的会会头像也可在下一步选择表情形象</p>
</div>
</div>
<div class="field">
<label class="field-label">分身名称 <span class="req">*</span></label>
<input v-model="form.name" class="input" placeholder="如:我的数字分身" />
</div>
<div class="field">
<label class="field-label">显示名称</label>
<input v-model="form.displayName" class="input" placeholder="如:会会助手" />
</div>
<div class="field">
<label class="field-label">分身描述</label>
<textarea v-model="form.description" class="textarea" rows="3" placeholder="描述它的功能与特点"></textarea>
</div>
</div>
<!-- Step 2形象风格 -->
<div v-else-if="step === 2" class="form">
<p class="field-label">选择形象</p>
<!-- 默认沿用当前用户的会会头像 -->
<div
v-if="userAvatarUrl"
class="avatar-photo-option"
:class="{ active: usePhoto }"
@click="selectMyAvatar"
>
<div class="apo-thumb">
<img :src="userAvatarUrl" alt="" referrerpolicy="no-referrer" />
</div>
<div class="apo-meta">
<span class="apo-title">使用我的头像</span>
<span class="apo-sub">沿用你的会会账号头像</span>
</div>
<span class="apo-check"></span>
</div>
<div class="emoji-grid">
<button
v-for="e in emojis"
:key="e"
class="emoji-opt"
:class="{ active: !usePhoto && form.emoji === e }"
@click="selectEmoji(e)"
>{{ e }}</button>
</div>
<p class="field-label">回复风格</p>
<div class="seg">
<button
v-for="s in styles"
:key="s.value"
class="seg-btn"
:class="{ active: form.replyStyle === s.value }"
@click="form.replyStyle = s.value"
>{{ s.label }}</button>
</div>
</div>
<!-- Step 3灵魂配置 -->
<div v-else-if="step === 3" class="form">
<div class="slider-item">
<div class="slider-head"><span>创造力</span><b>{{ form.creativity }}</b></div>
<input type="range" min="0" max="100" v-model.number="form.creativity" class="range" />
</div>
<div class="slider-item">
<div class="slider-head"><span>严谨度</span><b>{{ form.rigor }}</b></div>
<input type="range" min="0" max="100" v-model.number="form.rigor" class="range" />
</div>
<div class="slider-item">
<div class="slider-head"><span>幽默感</span><b>{{ form.humor }}</b></div>
<input type="range" min="0" max="100" v-model.number="form.humor" class="range" />
</div>
<p class="field-label">回复长度</p>
<div class="seg">
<button
v-for="l in lengths"
:key="l.value"
class="seg-btn"
:class="{ active: form.responseLength === l.value }"
@click="form.responseLength = l.value"
>{{ l.label }}</button>
</div>
<div class="field">
<label class="field-label">系统提示词选填</label>
<textarea v-model="form.systemPrompt" class="textarea" rows="3" placeholder="给分身的额外指令,如:语气要专业、回答要简洁"></textarea>
</div>
</div>
<!-- Step 4确认创建 -->
<div v-else class="confirm">
<div class="confirm-avatar">
<img v-if="usePhoto && form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
<span v-else>{{ form.emoji }}</span>
</div>
<h3 class="confirm-name">{{ form.displayName || form.name || '未命名分身' }}</h3>
<p class="confirm-desc">{{ form.description || '暂无描述' }}</p>
<ul class="confirm-list">
<li><span>回复风格</span><b>{{ styleLabel }}</b></li>
<li><span>创造力 / 严谨度 / 幽默感</span><b>{{ form.creativity }} / {{ form.rigor }} / {{ form.humor }}</b></li>
<li><span>回复长度</span><b>{{ lengthLabel }}</b></li>
</ul>
</div>
</div>
<footer class="wizard-footer">
<button v-if="step > 1" class="btn-secondary" @click="prev">上一步</button>
<button v-if="step < 4" class="btn-primary" :disabled="!canNext" @click="next">下一步</button>
<button v-else class="btn-primary" :disabled="submitting" @click="submit">
{{ submitting ? '创建中...' : '创建分身' }}
</button>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user'
import { getCurrentUser } from '@/api'
const router = useRouter()
const avatarStore = useAvatarStore()
const userStore = useUserStore()
const step = ref(0)
const stepTitles = ['基础信息', '形象风格', '灵魂配置', '确认创建']
const submitting = ref(false)
const emojis = ['🤖', '😊', '🦊', '🐱', '🦁', '🐼', '👩‍💻', '🧑‍🚀', '💡', '🌟']
const styles = [
{ value: 'professional', label: '专业' },
{ value: 'casual', label: '轻松' },
{ value: 'friendly', label: '亲切' }
]
const lengths = [
{ value: 'short', label: '简短' },
{ value: 'medium', label: '适中' },
{ value: 'long', label: '详细' }
]
const form = reactive({
name: '',
displayName: '',
photoUrl: '',
description: '',
emoji: '🤖',
replyStyle: 'professional',
creativity: 50,
rigor: 50,
humor: 30,
responseLength: 'medium',
systemPrompt: ''
})
const styleLabel = computed(() => styles.find(s => s.value === form.replyStyle)?.label || '')
const lengthLabel = computed(() => lengths.find(l => l.value === form.responseLength)?.label || '')
// 当前登录用户的会会头像(用于「形象选择」默认项)
// 优先采用真实登录态 userStore.user避免被空的 userProfile 覆盖
const userAvatarUrl = computed(() => {
const u = userStore.user
const p = avatarStore.userProfile
return ((u?.avatarUrl as string) || (p?.avatarUrl as string) || '')
})
// 形象选择:默认沿用当前用户的会会头像;选中表情后切换为 emoji 模式
const usePhoto = ref(false)
const selectMyAvatar = () => {
usePhoto.value = true
form.photoUrl = userAvatarUrl.value
}
const selectEmoji = (e: string) => {
form.emoji = e
usePhoto.value = false
form.photoUrl = ''
}
const canNext = computed(() => {
if (step.value === 1) return form.name.trim().length > 0
return true
})
const next = () => { if (step.value < 4) step.value++ }
const prev = () => { if (step.value === 1) step.value = 0; else step.value-- }
const submit = async () => {
if (submitting.value) return
submitting.value = true
try {
await avatarStore.addAvatar({
name: form.name,
displayName: form.displayName || form.name,
photoUrl: usePhoto.value ? form.photoUrl : '',
description: form.description,
emoji: form.emoji,
config: {
replyStyle: form.replyStyle,
creativity: form.creativity,
rigor: form.rigor,
humor: form.humor,
responseLength: form.responseLength,
systemPrompt: form.systemPrompt
}
})
router.push('/avatar/manage')
} catch (e) {
alert('创建失败,请稍后重试')
submitting.value = false
}
}
// 预填会会登录用户的名称与头像(沿用当前账号资料)
// 优先采用真实登录态 userStore.user持久化会话避免被空资料 / mock 覆盖
onMounted(async () => {
userStore.loadFromStorage()
const real = userStore.user
const native = avatarStore.userProfile
let nick = real?.nickname || native?.nickname || ''
let avatar = real?.avatarUrl || native?.avatarUrl || ''
// 兜底:本地资料为空时,向服务端 /huihui/me 拉取真实登录态Bearer app_token
if ((!nick || !avatar) && userStore.isLogin) {
try {
const me2: any = await getCurrentUser()
nick = nick || me2?.nickname || ''
avatar = avatar || me2?.avatarUrl || ''
} catch { /* 忽略,保持本地值 */ }
}
if (nick) {
if (!form.displayName) form.displayName = nick
if (!form.name) form.name = nick
}
if (avatar && !form.photoUrl) form.photoUrl = avatar
// 「形象选择」默认使用当前用户的会会头像
if (userAvatarUrl.value) usePhoto.value = true
})
const goManage = () => router.push('/avatar/manage')
</script>
<style scoped>
.create-page {
min-height: 100vh;
background: #F8F9FA;
}
/* ===== 落地页 ===== */
.landing { padding-bottom: 40px; }
.hero {
background: linear-gradient(135deg, #F97316 0%, #FB923C 50%, #FDBA74 100%);
padding: 60px 20px 80px;
text-align: center;
color: white;
position: relative;
overflow: hidden;
}
.hero-icon {
font-size: 80px;
margin-bottom: 20px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { transform: scale(1);} 50% { transform: scale(1.1);} }
.hero-title { font-size: 28px; font-weight: 700; margin: 0 0 12px; letter-spacing: 1px; }
.hero-sub { font-size: 16px; opacity: 0.9; margin: 0; }
.features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 20px;
margin-top: -40px;
position: relative;
z-index: 2;
}
.feature-card {
background: white;
border-radius: 16px;
padding: 20px 16px;
text-align: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.feature-icon { font-size: 36px; margin-bottom: 12px; }
.feature-card h3 { font-size: 15px; font-weight: 600; color: #18191C; margin: 0 0 8px; }
.feature-card p { font-size: 12px; color: #9398AE; margin: 0; line-height: 1.4; }
.cta { padding: 30px 20px; text-align: center; }
.create-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 16px 48px;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
color: white;
border: none;
border-radius: 16px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 20px rgba(249,115,22,0.4);
transition: transform 0.2s;
}
.create-btn:active { transform: scale(0.97); }
.hint { font-size: 13px; color: #9398AE; margin-top: 16px; }
.existing { text-align: center; padding: 10px; }
.existing p { font-size: 14px; color: #9398AE; margin: 0 0 12px; }
.link-btn {
background: none; border: 2px solid #F97316; color: #F97316;
padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer;
transition: all 0.2s;
}
.link-btn:hover { background: #F97316; color: white; }
/* ===== 向导 ===== */
.wizard { min-height: 100vh; display: flex; flex-direction: column; }
.wizard-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; background: white; border-bottom: 1px solid #EDEEF1;
}
.back { background: none; border: none; font-size: 26px; cursor: pointer; color: #18191C; padding: 0 8px; }
.w-title { font-size: 17px; font-weight: 600; margin: 0; color: #18191C; }
.step-count { font-size: 13px; color: #9398AE; }
.progress { height: 4px; background: #EDEEF1; }
.progress-bar { height: 100%; background: linear-gradient(90deg, #F97316, #FB923C); transition: width 0.35s ease; }
.wizard-body { flex: 1; padding: 24px 20px; }
.form { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field-label { font-size: 14px; font-weight: 500; color: #18191C; }
.req { color: #EF4444; }
.avatar-pick { display: flex; flex-direction: column; gap: 10px; }
.avatar-pick-preview {
width: 72px; height: 72px; border-radius: 50%; overflow: hidden;
background: linear-gradient(135deg, #F97316, #FB923C);
display: flex; align-items: center; justify-content: center;
font-size: 36px; box-shadow: 0 4px 12px rgba(249,115,22,0.3);
border: 2px solid #fff;
}
.avatar-pick-preview img { width: 100%; height: 100%; object-fit: cover; }
.avatar-pick-hint { font-size: 12px; color: #9398AE; margin: 0; }
.input, .textarea {
width: 100%; padding: 12px 16px; border: 1px solid #EDEEF1; border-radius: 10px;
font-size: 15px; color: #18191C; background: white; font-family: inherit;
transition: border-color 0.2s;
}
.input:focus, .textarea:focus { outline: none; border-color: #F97316; }
.textarea { resize: vertical; }
.emoji-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
.emoji-opt {
aspect-ratio: 1; font-size: 28px; border: 2px solid #EDEEF1; border-radius: 12px;
background: white; cursor: pointer; transition: all 0.2s;
}
.emoji-opt.active { border-color: #F97316; background: #FFF0E6; }
/* 形象选择:默认沿用会会头像 */
.avatar-photo-option {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px; border: 2px solid #EDEEF1; border-radius: 14px;
background: white; cursor: pointer; transition: all 0.2s; margin-bottom: 18px;
}
.avatar-photo-option.active { border-color: #F97316; background: #FFF0E6; }
.apo-thumb {
width: 52px; height: 52px; border-radius: 50%; overflow: hidden; flex: 0 0 auto;
background: linear-gradient(135deg, #F97316, #FB923C);
border: 2px solid #fff; box-shadow: 0 2px 8px rgba(249,115,22,0.3);
}
.apo-thumb img { width: 100%; height: 100%; object-fit: cover; }
.apo-meta { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.apo-title { font-size: 15px; font-weight: 600; color: #18191C; }
.apo-sub { font-size: 12px; color: #9398AE; }
.apo-check { font-size: 18px; color: #F97316; opacity: 0; transition: opacity 0.2s; flex: 0 0 auto; }
.avatar-photo-option.active .apo-check { opacity: 1; }
.seg { display: flex; gap: 12px; }
.seg-btn {
flex: 1; padding: 12px; border: 2px solid #EDEEF1; border-radius: 10px; background: white;
font-size: 14px; color: #9398AE; cursor: pointer; transition: all 0.2s;
}
.seg-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
.slider-item { display: flex; flex-direction: column; gap: 10px; }
.slider-head { display: flex; justify-content: space-between; font-size: 14px; color: #18191C; }
.slider-head b { color: #F97316; }
.range { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 3px; background: #EDEEF1; outline: none; }
.range::-webkit-slider-thumb {
-webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%;
background: #F97316; cursor: pointer; box-shadow: 0 2px 6px rgba(249,115,22,0.4);
}
.confirm { display: flex; flex-direction: column; align-items: center; gap: 12px; padding-top: 10px; }
.confirm-avatar {
width: 88px; height: 88px; border-radius: 50%; overflow: hidden;
background: linear-gradient(135deg, #F97316, #FB923C);
display: flex; align-items: center; justify-content: center; font-size: 44px;
box-shadow: 0 4px 12px rgba(249,115,22,0.3);
border: 2px solid #fff;
}
.confirm-avatar img { width: 100%; height: 100%; object-fit: cover; }
.confirm-name { font-size: 20px; font-weight: 700; color: #18191C; margin: 0; }
.confirm-desc { font-size: 14px; color: #9398AE; margin: 0; text-align: center; }
.confirm-list { width: 100%; list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 10px; }
.confirm-list li {
display: flex; justify-content: space-between; padding: 14px 16px; background: white;
border-radius: 10px; font-size: 13px; color: #9398AE; box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.confirm-list li b { color: #18191C; font-weight: 600; }
.wizard-footer {
display: flex; gap: 12px; padding: 16px 20px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: white; border-top: 1px solid #EDEEF1;
}
.btn-secondary {
flex: 0 0 auto; padding: 14px 24px; background: white; color: #18191C;
border: 2px solid #EDEEF1; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer;
}
.btn-primary {
flex: 1; padding: 14px; background: linear-gradient(135deg, #F97316, #FB923C); color: white;
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer;
box-shadow: 0 4px 12px rgba(249,115,22,0.3); transition: opacity 0.2s;
}
.btn-primary:disabled { background: #D1D5DB; box-shadow: none; cursor: not-allowed; }
</style>