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

625 lines
15 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="edit-avatar-page">
<!-- 顶部导航 -->
<header v-if="!isEmbedded" class="page-header">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">分身微调</h1>
<span class="header-spacer" aria-hidden="true"></span>
</header>
<div v-if="loading" class="status-banner">加载中...</div>
<div v-else-if="errorMsg" class="status-banner error">{{ errorMsg }}</div>
<!-- 头像预览 -->
<section class="photo-section">
<label class="photo-container" for="avatar-photo-input">
<div class="photo-preview">
<img v-if="formData.photoUrl" :src="formData.photoUrl" alt="" class="photo-image" referrerpolicy="no-referrer" />
<div v-else class="photo-placeholder">🤖</div>
<span class="photo-edit-mark">更换</span>
</div>
<span class="photo-hint">{{ uploadingPhoto ? '头像上传中...' : '点击头像上传新图片' }}</span>
</label>
<input id="avatar-photo-input" class="photo-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="uploadingPhoto" @change="selectPhoto" />
</section>
<!-- 基本信息表单 -->
<section class="form-section">
<h3 class="section-title">基本信息</h3>
<div class="form-item">
<label class="form-label">分身名称</label>
<input
v-model="formData.name"
class="form-input"
placeholder="请输入分身名称"
/>
</div>
<div class="form-item">
<label class="form-label">显示名称</label>
<input
v-model="formData.displayName"
class="form-input"
placeholder="请输入显示名称"
/>
</div>
<div class="form-item">
<label class="form-label">分身描述</label>
<textarea
v-model="formData.description"
class="form-textarea"
placeholder="描述你的数字分身的功能和特点"
rows="3"
></textarea>
</div>
<div class="form-item">
<label class="form-label">职业</label>
<input v-model="formData.profession" class="form-input" placeholder="例如:医生、律师、产品经理" />
</div>
<div class="form-item">
<label class="form-label">职位</label>
<input v-model="formData.position" class="form-input" placeholder="例如:主任医师、部门负责人" />
</div>
<div class="form-item">
<label class="form-label">单位</label>
<input v-model="formData.organization" class="form-input" placeholder="请输入所在单位" />
</div>
<div class="form-item">
<label class="form-label">单位地址</label>
<input v-model="formData.organizationAddress" class="form-input" placeholder="请输入单位详细地址" />
</div>
<div class="form-item">
<label class="form-label">状态</label>
<div class="status-selector">
<button
class="status-option"
:class="{ active: formData.status === 'active' }"
@click="formData.status = 'active'"
>
活跃中
</button>
<button
class="status-option"
:class="{ active: formData.status === 'inactive' }"
@click="formData.status = 'inactive'"
>
未激活
</button>
</div>
</div>
</section>
<!-- 高级设置 -->
<section class="form-section">
<h3 class="section-title">高级设置</h3>
<div class="form-item">
<label class="form-label">回复风格</label>
<select v-model="formData.replyStyle" class="form-select">
<option value="professional">专业正式</option>
<option value="casual">轻松随意</option>
<option value="friendly">友好亲切</option>
</select>
</div>
<div class="form-item">
<div class="slider-head"><label class="form-label">创造力</label><b>{{ formData.creativity }}</b></div>
<input v-model.number="formData.creativity" type="range" min="0" max="100" class="range" />
</div>
<div class="form-item">
<div class="slider-head"><label class="form-label">严谨度</label><b>{{ formData.rigor }}</b></div>
<input v-model.number="formData.rigor" type="range" min="0" max="100" class="range" />
</div>
<div class="form-item">
<div class="slider-head"><label class="form-label">幽默感</label><b>{{ formData.humor }}</b></div>
<input v-model.number="formData.humor" type="range" min="0" max="100" class="range" />
</div>
<div class="form-item">
<label class="form-label">回复长度</label>
<div class="choice-row">
<button v-for="item in responseLengths" :key="item.value" type="button" class="choice-btn" :class="{ active: formData.responseLength === item.value }" @click="formData.responseLength = item.value">{{ item.label }}</button>
</div>
</div>
<div class="form-item">
<label class="form-label">系统提示词</label>
<textarea v-model="formData.systemPrompt" class="form-textarea" rows="4" placeholder="给分身的额外指令,例如语气、禁用内容或回答边界"></textarea>
</div>
<div class="form-item">
<label class="form-label">自动回复</label>
<div class="toggle-container">
<span class="toggle-label">{{ formData.autoReply ? '开启' : '关闭' }}</span>
<button
class="toggle-btn"
:class="{ active: formData.autoReply }"
@click="formData.autoReply = !formData.autoReply"
>
<span class="toggle-dot"></span>
</button>
</div>
</div>
</section>
<!-- 危险操作 -->
<section class="danger-section">
<button class="delete-btn" :disabled="loading || deleting" @click="deleteAvatar">
{{ deleting ? '删除中...' : '删除数字分身' }}
</button>
</section>
<footer class="edit-action-bar">
<button class="action-back-btn" type="button" :disabled="saving" @click="goBack">返回</button>
<button class="action-save-btn" type="button" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
{{ saving ? '保存中...' : '保存修改' }}
</button>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
import { useAvatarStore } from '@/store/avatar'
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter()
const route = useRoute()
const avatarStore = useAvatarStore()
const avatarId = route.params.id as string
const isEmbedded = isHuihuiEmbeddedMode()
// 表单数据
const formData = reactive({
name: '',
displayName: '',
description: '',
status: 'active' as 'active' | 'inactive',
photoUrl: '',
replyStyle: 'professional',
creativity: 50,
rigor: 50,
humor: 30,
responseLength: 'medium',
systemPrompt: '',
profession: '',
position: '',
organization: '',
organizationAddress: '',
autoReply: true
})
const responseLengths = [
{ value: 'short', label: '简短' },
{ value: 'medium', label: '适中' },
{ value: 'long', label: '详细' }
]
const loading = ref(true)
const saving = ref(false)
const uploadingPhoto = ref(false)
const deleting = ref(false)
const errorMsg = ref('')
const loadAvatar = async () => {
loading.value = true
errorMsg.value = ''
try {
const avatar: any = await getAvatarDetail(avatarId)
Object.assign(formData, normalizeAvatarEditForm(avatar))
} catch (e: any) {
errorMsg.value = e?.message || '分身加载失败'
} finally {
loading.value = false
}
}
const selectPhoto = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || uploadingPhoto.value) return
uploadingPhoto.value = true
errorMsg.value = ''
try {
const result = await uploadAvatarPhoto(avatarId, file)
formData.photoUrl = result.photoUrl
} catch (e: any) {
errorMsg.value = e?.message || '头像上传失败,请重试'
} finally {
uploadingPhoto.value = false
}
}
// 保存修改
const saveChanges = async () => {
if (loading.value || saving.value) return
if (!formData.name.trim()) {
errorMsg.value = '请输入分身名称'
return
}
saving.value = true
errorMsg.value = ''
try {
await updateAvatar(avatarId, buildAvatarUpdatePayload(formData))
await avatarStore.loadAvatars()
router.replace('/avatar/manage')
} catch (e: any) {
errorMsg.value = e?.message || '保存失败,请重试'
} finally {
saving.value = false
}
}
// 删除分身
const deleteAvatar = async () => {
if (loading.value || deleting.value) return
if (confirm('确定要删除这个数字分身吗?此操作不可恢复。')) {
deleting.value = true
errorMsg.value = ''
try {
await apiDeleteAvatar(avatarId)
await avatarStore.loadAvatars()
router.replace('/avatar/manage')
} catch (e: any) {
errorMsg.value = e?.message || '删除失败,请重试'
} finally {
deleting.value = false
}
}
}
// 返回
const goBack = () => {
router.back()
}
onMounted(async () => {
await loadAvatar()
})
</script>
<style scoped>
.edit-avatar-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: calc(104px + env(safe-area-inset-bottom));
}
/* 顶部导航 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #EDEEF1;
}
.back-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
color: #18191C;
}
.page-title {
font-size: 17px;
font-weight: 600;
margin: 0;
color: #18191C;
}
.status-banner {
margin: 16px 20px 0;
padding: 12px 14px;
border-radius: 10px;
background: #FFF7ED;
color: #9A3412;
font-size: 14px;
}
.status-banner.error {
background: #FEF2F2;
color: #B91C1C;
}
.header-spacer { width: 40px; }
/* 头像上传 */
.photo-section {
padding: 30px 20px;
display: flex;
justify-content: center;
}
.photo-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
}
.photo-preview {
position: relative;
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
overflow: hidden;
}
.photo-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-placeholder {
font-size: 48px;
}
.photo-hint {
font-size: 13px;
color: #F97316;
font-weight: 500;
}
.photo-input { display: none; }
.photo-edit-mark {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 5px 0 7px;
color: white;
background: rgba(47, 26, 16, .68);
font-size: 12px;
text-align: center;
}
/* 表单区域 */
.form-section {
padding: 20px;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
color: #18191C;
}
.form-item {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #18191C;
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #EDEEF1;
border-radius: 10px;
font-size: 15px;
color: #18191C;
background: white;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #F97316;
}
.form-textarea {
width: 100%;
padding: 12px 16px;
border: 1px solid #EDEEF1;
border-radius: 10px;
font-size: 15px;
color: #18191C;
background: white;
resize: vertical;
font-family: inherit;
transition: border-color 0.2s;
}
.form-textarea:focus {
outline: none;
border-color: #F97316;
}
.form-select {
width: 100%;
padding: 12px 16px;
border: 1px solid #EDEEF1;
border-radius: 10px;
font-size: 15px;
color: #18191C;
background: white;
cursor: pointer;
}
.slider-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.slider-head .form-label { margin-bottom: 0; }
.slider-head b { color: #F97316; font-size: 14px; }
.range { width: 100%; accent-color: #F97316; }
.choice-row { display: flex; gap: 8px; }
.choice-btn {
flex: 1; padding: 10px 8px; border: 1px solid #EDEEF1; border-radius: 10px;
background: white; color: #6B7280; cursor: pointer;
}
.choice-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
/* 状态选择器 */
.status-selector {
display: flex;
gap: 12px;
}
.status-option {
flex: 1;
padding: 12px;
border: 2px solid #EDEEF1;
border-radius: 10px;
background: white;
font-size: 14px;
color: #9398AE;
cursor: pointer;
transition: all 0.2s;
}
.status-option.active {
border-color: #F97316;
color: #F97316;
background: #FFF0E6;
}
/* 开关切换 */
.toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: white;
border-radius: 10px;
border: 1px solid #EDEEF1;
}
.toggle-label {
font-size: 15px;
color: #18191C;
}
.toggle-btn {
width: 48px;
height: 28px;
border-radius: 14px;
border: none;
background: #D1D5DB;
cursor: pointer;
position: relative;
transition: background 0.3s;
padding: 0;
}
.toggle-btn.active {
background: #F97316;
}
.toggle-dot {
position: absolute;
top: 2px;
left: 2px;
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.toggle-btn.active .toggle-dot {
transform: translateX(20px);
}
/* 危险操作 */
.danger-section {
padding: 20px;
}
.delete-btn {
width: 100%;
padding: 14px;
background: white;
color: #EF4444;
border: 2px solid #EF4444;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.delete-btn:hover {
background: #EF4444;
color: white;
}
.edit-action-bar {
position: fixed;
z-index: 30;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 12px;
padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(229, 231, 235, .9);
background: rgba(248, 249, 250, .96);
box-shadow: 0 -8px 24px rgba(56, 38, 24, .06);
backdrop-filter: blur(12px);
}
.action-back-btn,
.action-save-btn {
height: 48px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.action-back-btn {
flex: 0 0 104px;
border: 1px solid #E4E0DC;
color: #655E58;
background: #fff;
}
.action-save-btn {
min-width: 0;
flex: 1;
border: 0;
color: #fff;
background: linear-gradient(105deg, #F79A38, #F97316);
box-shadow: 0 8px 18px rgba(249, 115, 22, .18);
}
.action-back-btn:disabled,
.action-save-btn:disabled { opacity: .6; cursor: not-allowed; }
</style>