feat: complete huihui square avatar workflows
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="auth-manage-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">授权管理</h1>
|
||||
<button class="add-btn" @click="addAuthorization">+</button>
|
||||
</header>
|
||||
|
||||
<!-- 授权列表 -->
|
||||
<section class="auth-list" v-if="authList.length > 0">
|
||||
<div class="auth-card" v-for="auth in authList" :key="auth.id">
|
||||
<div class="auth-icon" :class="auth.targetType">
|
||||
{{ getAuthIcon(auth.targetType) }}
|
||||
</div>
|
||||
<div class="auth-info">
|
||||
<h3 class="auth-name">{{ auth.targetName }}</h3>
|
||||
<p class="auth-type">{{ getAuthTypeText(auth.targetType) }}</p>
|
||||
<div class="auth-permissions">
|
||||
<span class="permission-tag" v-for="perm in auth.permissions" :key="perm">
|
||||
{{ getPermissionText(perm) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-actions">
|
||||
<span class="auth-status" :class="auth.status">
|
||||
{{ auth.status === 'active' ? '已授权' : '已撤销' }}
|
||||
</span>
|
||||
<button class="auth-toggle-btn" @click="toggleAuth(auth)">
|
||||
{{ auth.status === 'active' ? '撤销' : '授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<section class="empty-state" v-else>
|
||||
<div class="empty-icon">🔐</div>
|
||||
<h3 class="empty-title">暂无授权</h3>
|
||||
<p class="empty-desc">授权其他用户或应用访问你的数字分身</p>
|
||||
<button class="empty-btn" @click="addAuthorization">添加授权</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { getAuthorizationList, updateAuthorization } from '@/api'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
|
||||
// 授权列表
|
||||
const authList = ref<Array<{
|
||||
id: string
|
||||
targetType: 'user' | 'organization' | 'application'
|
||||
targetName: string
|
||||
permissions: string[]
|
||||
status: 'active' | 'inactive'
|
||||
}>>([])
|
||||
|
||||
// 从后端加载授权列表
|
||||
const loadAuth = async () => {
|
||||
try {
|
||||
if (!avatarStore.avatars.length) {
|
||||
await avatarStore.loadAvatars()
|
||||
}
|
||||
const avatarId = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars)
|
||||
if (!avatarId) {
|
||||
authList.value = []
|
||||
return
|
||||
}
|
||||
const res: any = await getAuthorizationList(avatarId)
|
||||
authList.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
console.error('加载授权失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取授权图标
|
||||
const getAuthIcon = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'user': '👤',
|
||||
'organization': '🏢',
|
||||
'application': '📱'
|
||||
}
|
||||
return map[type] || '🔑'
|
||||
}
|
||||
|
||||
// 获取授权类型文本
|
||||
const getAuthTypeText = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'user': '用户',
|
||||
'organization': '组织',
|
||||
'application': '应用'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 获取权限文本
|
||||
const getPermissionText = (perm: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'read': '读取',
|
||||
'write': '写入',
|
||||
'reply': '回复',
|
||||
'edit': '编辑'
|
||||
}
|
||||
return map[perm] || perm
|
||||
}
|
||||
|
||||
// 切换授权状态(写入后端)
|
||||
const toggleAuth = async (auth: any) => {
|
||||
const newStatus = auth.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
const avatarId = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars)
|
||||
if (!avatarId) return
|
||||
const res: any = await updateAuthorization(avatarId, {
|
||||
id: auth.id,
|
||||
status: newStatus
|
||||
})
|
||||
authList.value = unwrapListData(res)
|
||||
} catch (e) {
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 添加授权
|
||||
const addAuthorization = () => {
|
||||
alert('添加授权功能开发中...')
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-manage-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 授权列表 */
|
||||
.auth-list {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
font-size: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: #FFF0E6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.auth-type {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.auth-permissions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.permission-tag {
|
||||
padding: 4px 8px;
|
||||
background: #F3F4F6;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-status.active {
|
||||
color: #22C55E;
|
||||
}
|
||||
|
||||
.auth-status.inactive {
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.auth-toggle-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #9398AE;
|
||||
margin: 0 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
padding: 12px 32px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="avatar-card-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身名片</h1>
|
||||
</div>
|
||||
<button class="share-btn" @click="shareCard">分享</button>
|
||||
</header>
|
||||
|
||||
<!-- 名片预览 -->
|
||||
<section class="card-preview">
|
||||
<div class="digital-card">
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">{{ avatar.emoji }}</div>
|
||||
<span class="card-status" :class="avatar.status">● {{ statusText(avatar.status) }}</span>
|
||||
</div>
|
||||
<h2 class="card-name">{{ avatar.displayName }}</h2>
|
||||
<p class="card-desc">{{ avatar.description }}</p>
|
||||
<div class="card-tags">
|
||||
<span class="card-tag" v-for="t in avatar.tags" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
<div class="card-qr">
|
||||
<div class="qr-placeholder" aria-hidden="true">▦</div>
|
||||
<span class="qr-tip">扫码添加我的分身</span>
|
||||
</div>
|
||||
<div class="card-id">会会号:{{ avatar.huihuiId }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 名片信息 -->
|
||||
<section class="info-section">
|
||||
<h3 class="section-title">名片信息</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row"><span class="info-label">分身名称</span><span class="info-value">{{ avatar.displayName }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会会号</span><span class="info-value">{{ avatar.huihuiId }}</span></div>
|
||||
<div class="info-row"><span class="info-label">状态</span><span class="info-value">{{ statusText(avatar.status) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建时间</span><span class="info-value">{{ formatDate(avatar.createdAt) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作 -->
|
||||
<section class="action-section">
|
||||
<button class="primary-btn" @click="copyLink">复制分享链接</button>
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { getAvatarDetail } from '@/api'
|
||||
import { pickAvatarId } from '@/utils/avatar-page-data'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const toast = ref('')
|
||||
const loading = ref(true)
|
||||
const selectedAvatarId = ref('')
|
||||
|
||||
const avatar = ref({
|
||||
emoji: '🤖',
|
||||
displayName: '会会助手',
|
||||
description: '我是您的AI数字分身,可以帮您管理日程、回复消息、处理任务。',
|
||||
huihuiId: 'huihui_8848',
|
||||
status: 'active',
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
tags: ['智能对话', '日程管理', '社交助手']
|
||||
})
|
||||
|
||||
const applyAvatar = (a: any) => {
|
||||
if (!a) return
|
||||
avatar.value = {
|
||||
emoji: a.emoji || '🤖',
|
||||
displayName: a.displayName || a.name || '我的分身',
|
||||
description: a.description || '暂无描述',
|
||||
huihuiId: a.huihuiId || 'huihui_' + (a.id || '0000'),
|
||||
status: a.status || 'active',
|
||||
createdAt: a.createdAt || new Date().toISOString(),
|
||||
tags: a.tags || ['智能对话', '日程管理']
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||
const requestedId = String(route.query.id || '')
|
||||
const id = pickAvatarId(requestedId, avatarStore.avatars)
|
||||
selectedAvatarId.value = id || ''
|
||||
const localAvatar = avatarStore.avatars.find((a) => String(a.id) === id)
|
||||
if (localAvatar) {
|
||||
applyAvatar(localAvatar)
|
||||
} else if (requestedId) {
|
||||
applyAvatar(await getAvatarDetail(requestedId))
|
||||
selectedAvatarId.value = requestedId
|
||||
} else {
|
||||
applyAvatar(avatarStore.avatars[0])
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = (s: string) => ({ active: '活跃中', inactive: '未激活', training: '训练中' }[s] || '活跃中')
|
||||
const formatDate = (t: string) => new Date(t).toLocaleDateString('zh-CN')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const shareCard = () => {
|
||||
toast.value = '请复制链接分享给对方'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
const id = selectedAvatarId.value
|
||||
const redirect = router.resolve({ path: '/avatar/card', query: id ? { id: String(id) } : undefined }).href
|
||||
const loginUrl = router.resolve({ path: '/login/sms', query: { redirect } }).href
|
||||
const hash = loginUrl.includes('#') ? loginUrl.slice(loginUrl.indexOf('#')) : `#${loginUrl}`
|
||||
const link = `${window.location.origin}${window.location.pathname}${hash}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
toast.value = '分享链接已复制'
|
||||
} catch {
|
||||
toast.value = link
|
||||
}
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-card-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.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; }
|
||||
.share-btn { background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 16px; border-radius: 20px; font-size: 14px; cursor: pointer; }
|
||||
|
||||
.card-preview { padding: 16px 20px; }
|
||||
.digital-card {
|
||||
background: linear-gradient(160deg, #FFF7ED 0%, #FFFFFF 60%);
|
||||
border: 1px solid #FFE4CC; border-radius: 20px; padding: 24px;
|
||||
box-shadow: 0 8px 24px rgba(249,115,22,0.12);
|
||||
}
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.card-avatar {
|
||||
width: 64px; height: 64px; border-radius: 18px; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 34px;
|
||||
}
|
||||
.card-status { font-size: 12px; color: #22C55E; background: #ECFDF5; padding: 4px 10px; border-radius: 20px; }
|
||||
.card-name { font-size: 22px; font-weight: 700; color: #18191C; margin: 0 0 8px; }
|
||||
.card-desc { font-size: 14px; color: #6B7280; line-height: 1.6; margin: 0 0 16px; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||
.card-tag { padding: 4px 12px; background: #FFF0E6; color: #F97316; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||
.card-qr {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 16px; background: white; border-radius: 14px; border: 1px dashed #FED7AA; margin-bottom: 14px;
|
||||
}
|
||||
.qr-placeholder { font-size: 56px; line-height: 1; color: #F97316; letter-spacing: 4px; }
|
||||
.qr-tip { font-size: 12px; color: #9398AE; }
|
||||
.card-id { text-align: center; font-size: 13px; color: #9398AE; }
|
||||
|
||||
.info-section { padding: 0 20px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #18191C; }
|
||||
.info-list { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); overflow: hidden; }
|
||||
.info-row { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #F3F4F6; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 14px; color: #6B7280; }
|
||||
.info-value { font-size: 14px; color: #18191C; font-weight: 500; }
|
||||
|
||||
.action-section { padding: 0 20px; }
|
||||
.primary-btn {
|
||||
width: 100%; padding: 14px; background: #F97316; color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: opacity 0.2s;
|
||||
}
|
||||
.primary-btn:hover { opacity: 0.9; }
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 12px 0 0; word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="contacts-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身人脉</h1>
|
||||
</div>
|
||||
<button class="add-btn" @click="addContact">+</button>
|
||||
</header>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<section class="search-section">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input v-model="keyword" class="search-input" placeholder="搜索人脉" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 人脉列表 -->
|
||||
<section class="contacts-section">
|
||||
<div class="contact-list" v-if="filteredContacts.length > 0">
|
||||
<div class="contact-item" v-for="c in filteredContacts" :key="c.id">
|
||||
<div class="contact-avatar">{{ c.emoji }}</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name-row">
|
||||
<span class="contact-name">{{ c.name }}</span>
|
||||
<span class="relation-tag" :class="c.relation">{{ relationText(c.relation) }}</span>
|
||||
</div>
|
||||
<span class="contact-last">最近互动:{{ c.last }}</span>
|
||||
</div>
|
||||
<button class="chat-btn" @click="openChat(c)">💬</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">🤝</span>
|
||||
<p class="empty-text">还没有人脉,点击右上角添加</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const toast = ref('')
|
||||
|
||||
interface Contact { id: string; emoji: string; name: string; relation: string; last: string }
|
||||
const contacts = ref<Contact[]>([
|
||||
{ id: '1', emoji: '👩', name: '林小满', relation: 'friend', last: '今天 14:20' },
|
||||
{ id: '2', emoji: '🧑', name: '陈工', relation: 'colleague', last: '昨天 09:10' },
|
||||
{ id: '3', emoji: '👨', name: '王总', relation: 'client', last: '3 天前' },
|
||||
{ id: '4', emoji: '🧑', name: 'Anna', relation: 'friend', last: '上周' }
|
||||
])
|
||||
|
||||
const filteredContacts = computed(() =>
|
||||
contacts.value.filter(c => c.name.includes(keyword.value))
|
||||
)
|
||||
|
||||
const relationText = (r: string) => ({
|
||||
friend: '好友', colleague: '同事', client: '客户'
|
||||
}[r] || '好友')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const addContact = () => {
|
||||
const sample = ['👤', '🧑', '👩', '🧑']
|
||||
const names = ['新朋友', '合作伙伴', '张同学', '李经理']
|
||||
const relations = ['friend', 'colleague', 'client']
|
||||
const idx = contacts.value.length
|
||||
contacts.value.unshift({
|
||||
id: String(Date.now()),
|
||||
emoji: sample[idx % sample.length],
|
||||
name: names[idx % names.length],
|
||||
relation: relations[idx % relations.length],
|
||||
last: '刚刚添加'
|
||||
})
|
||||
toast.value = '已添加示例人脉'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const openChat = (c: Contact) => {
|
||||
toast.value = `打开与 ${c.name} 的对话(示例)`
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.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; }
|
||||
.add-btn { background: rgba(255,255,255,0.2); border: none; color: white; font-size: 22px; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; }
|
||||
|
||||
.search-section { padding: 16px 20px; }
|
||||
.search-box { display: flex; align-items: center; gap: 8px; background: white; border-radius: 12px; padding: 10px 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.search-icon { font-size: 16px; }
|
||||
.search-input { flex: 1; border: none; outline: none; font-size: 14px; color: #18191C; background: transparent; }
|
||||
|
||||
.contacts-section { padding: 0 20px; }
|
||||
.contact-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; gap: 12px; padding: 14px 16px;
|
||||
background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.contact-avatar {
|
||||
width: 44px; height: 44px; border-radius: 50%; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.contact-info { flex: 1; min-width: 0; }
|
||||
.contact-name-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.contact-name { font-size: 15px; font-weight: 600; color: #18191C; }
|
||||
.relation-tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.relation-tag.friend { background: #ECFDF5; color: #22C55E; }
|
||||
.relation-tag.colleague { background: #EFF6FF; color: #3B82F6; }
|
||||
.relation-tag.client { background: #FFF0E6; color: #F97316; }
|
||||
.contact-last { font-size: 12px; color: #9398AE; }
|
||||
.chat-btn {
|
||||
width: 38px; height: 38px; border-radius: 50%; border: none; background: #FFF0E6;
|
||||
font-size: 18px; cursor: pointer; flex-shrink: 0; transition: transform 0.2s;
|
||||
}
|
||||
.chat-btn:hover { transform: scale(1.08); }
|
||||
|
||||
.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; }
|
||||
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 16px 20px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,492 @@
|
||||
<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>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="create-org-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">创建组织</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 表单 -->
|
||||
<section class="form-section">
|
||||
<div class="form-card">
|
||||
<!-- Logo 选择 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织标识</label>
|
||||
<div class="emoji-picker">
|
||||
<button
|
||||
v-for="e in emojiOptions"
|
||||
:key="e"
|
||||
class="emoji-option"
|
||||
:class="{ active: form.emoji === e }"
|
||||
@click="form.emoji = e"
|
||||
>{{ e }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织名称</label>
|
||||
<input v-model="form.name" class="text-input" placeholder="例如:会会增长团队" maxlength="20" />
|
||||
</div>
|
||||
|
||||
<!-- 简介 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织简介</label>
|
||||
<textarea v-model="form.desc" class="text-area" rows="3" placeholder="一句话介绍这个组织的用途" maxlength="100"></textarea>
|
||||
<span class="char-count">{{ form.desc.length }}/100</span>
|
||||
</div>
|
||||
|
||||
<!-- 类型 -->
|
||||
<div class="field">
|
||||
<label class="field-label">组织类型</label>
|
||||
<div class="type-picker">
|
||||
<button
|
||||
v-for="t in typeOptions"
|
||||
:key="t.value"
|
||||
class="type-option"
|
||||
:class="{ active: form.type === t.value }"
|
||||
@click="form.type = t.value"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 提交 -->
|
||||
<section class="submit-section">
|
||||
<button class="submit-btn" :disabled="!canSubmit || submitting" @click="submit">
|
||||
{{ submitting ? '创建中...' : '创建组织' }}
|
||||
</button>
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createOrganization } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const emojiOptions = ['🏢', '🚀', '💡', '🌟', '🔥', '🐳']
|
||||
const typeOptions = [
|
||||
{ value: 'team', label: '团队' },
|
||||
{ value: 'company', label: '企业' },
|
||||
{ value: 'community', label: '社群' }
|
||||
]
|
||||
|
||||
const form = ref({ emoji: '🏢', name: '', desc: '', type: 'team' })
|
||||
|
||||
const canSubmit = computed(() => form.value.name.trim().length > 0)
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit.value || submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await createOrganization({
|
||||
name: form.value.name,
|
||||
desc: form.value.desc,
|
||||
emoji: form.value.emoji,
|
||||
type: form.value.type
|
||||
})
|
||||
toast.value = `组织「${form.value.name}」创建成功`
|
||||
setTimeout(() => router.push('/avatar/manage'), 1200)
|
||||
} catch (e) {
|
||||
toast.value = '创建失败,请重试'
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-org-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.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; }
|
||||
|
||||
.form-section { padding: 16px 20px; }
|
||||
.form-card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.field { margin-bottom: 20px; position: relative; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field-label { display: block; font-size: 14px; font-weight: 600; color: #18191C; margin-bottom: 10px; }
|
||||
|
||||
.emoji-picker { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.emoji-option {
|
||||
width: 44px; height: 44px; border-radius: 12px; border: 2px solid #F3F4F6;
|
||||
background: #F8F9FA; font-size: 22px; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.emoji-option.active { border-color: #F97316; background: #FFF0E6; transform: scale(1.05); }
|
||||
|
||||
.text-input, .text-area {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid #E5E7EB; border-radius: 10px;
|
||||
padding: 12px 14px; font-size: 14px; color: #18191C; outline: none; font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.text-input:focus, .text-area:focus { border-color: #F97316; }
|
||||
.text-area { resize: none; }
|
||||
.char-count { position: absolute; right: 4px; bottom: -18px; font-size: 11px; color: #C9CDD2; }
|
||||
|
||||
.type-picker { display: flex; gap: 10px; }
|
||||
.type-option {
|
||||
flex: 1; padding: 10px; border: 1px solid #E5E7EB; border-radius: 10px;
|
||||
background: white; font-size: 14px; color: #6B7280; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.type-option.active { border-color: #F97316; background: #FFF0E6; color: #F97316; font-weight: 600; }
|
||||
|
||||
.submit-section { padding: 0 20px; }
|
||||
.submit-btn {
|
||||
width: 100%; padding: 14px; background: #F97316; color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.submit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.submit-btn:not(:disabled):hover { opacity: 0.9; }
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 12px 0 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="projects-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">我的项目</h1>
|
||||
</div>
|
||||
<span class="count-badge">{{ projects.length }}</span>
|
||||
</header>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<section class="projects-section">
|
||||
<div class="project-list" v-if="projects.length > 0">
|
||||
<div class="project-card" v-for="p in projects" :key="p.id">
|
||||
<div class="project-top">
|
||||
<div class="project-emoji">{{ p.emoji }}</div>
|
||||
<div class="project-head">
|
||||
<h2 class="project-name">{{ p.name }}</h2>
|
||||
<span class="role-badge" :class="p.role">{{ roleText(p.role) }}</span>
|
||||
</div>
|
||||
<span class="status-dot" :class="p.status"></span>
|
||||
</div>
|
||||
<p class="project-desc">{{ p.desc }}</p>
|
||||
<div class="project-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: p.progress + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ p.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">📁</span>
|
||||
<p class="empty-text">分身暂未参与任何项目</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface Project { id: string; emoji: string; name: string; role: string; desc: string; status: string; progress: number }
|
||||
const projects = ref<Project[]>([
|
||||
{ id: '1', emoji: '🚀', name: '会会增长计划', role: 'owner', desc: '负责品牌分身的对话策略与内容生成', status: 'active', progress: 72 },
|
||||
{ id: '2', emoji: '📊', name: '用户访谈分析', role: 'member', desc: '协助整理 200 份访谈记录并提炼洞察', status: 'active', progress: 45 },
|
||||
{ id: '3', emoji: '🎯', name: '私域运营 SOP', role: 'viewer', desc: '只读权限,跟进流程文档', status: 'paused', progress: 20 }
|
||||
])
|
||||
|
||||
const roleText = (r: string) => ({ owner: '负责人', member: '成员', viewer: '访客' }[r] || '成员')
|
||||
|
||||
const goBack = () => router.back()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.projects-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.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; }
|
||||
.count-badge { background: rgba(255,255,255,0.2); padding: 2px 12px; border-radius: 20px; font-size: 14px; }
|
||||
|
||||
.projects-section { padding: 16px 20px; }
|
||||
.project-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.project-card { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.project-top { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.project-emoji {
|
||||
width: 44px; height: 44px; border-radius: 12px; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.project-head { flex: 1; min-width: 0; }
|
||||
.project-name { font-size: 15px; font-weight: 600; margin: 0 0 4px; color: #18191C; }
|
||||
.role-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.role-badge.owner { background: #FFF0E6; color: #F97316; }
|
||||
.role-badge.member { background: #EFF6FF; color: #3B82F6; }
|
||||
.role-badge.viewer { background: #F3F4F6; color: #6B7280; }
|
||||
.status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.active { background: #22C55E; }
|
||||
.status-dot.paused { background: #F59E0B; }
|
||||
.project-desc { font-size: 13px; color: #6B7280; line-height: 1.5; margin: 0 0 12px; }
|
||||
.project-progress { display: flex; align-items: center; gap: 10px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: #F3F4F6; border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #F97316, #FB923C); border-radius: 3px; transition: width 0.4s ease; }
|
||||
.progress-text { font-size: 12px; color: #9398AE; flex-shrink: 0; }
|
||||
|
||||
.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; }
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="qa-edit-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">{{ isEdit ? '编辑问答对' : '添加问答对' }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="form-section">
|
||||
<label class="field-label">问题</label>
|
||||
<textarea
|
||||
v-model="form.question"
|
||||
class="field-input"
|
||||
rows="3"
|
||||
placeholder="例如:你们的退款政策是什么?"
|
||||
></textarea>
|
||||
|
||||
<label class="field-label">标准答案</label>
|
||||
<textarea
|
||||
v-model="form.answer"
|
||||
class="field-input"
|
||||
rows="5"
|
||||
placeholder="输入该问题的标准回答"
|
||||
></textarea>
|
||||
|
||||
<div class="switch-row">
|
||||
<div class="switch-text">
|
||||
<span class="switch-title">启用</span>
|
||||
<span class="switch-desc">关闭后该问答对不参与分身作答</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-text">{{ error }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-cancel" @click="goBack">取消</button>
|
||||
<button class="btn-save" :disabled="saving" @click="save">保存</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import { getQAPairs, createQAPair, updateQAPair } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAvatarStore()
|
||||
|
||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
||||
const qaId = computed(() => (route.params.qaId as string) || null)
|
||||
const isEdit = computed(() => !!qaId.value)
|
||||
|
||||
const form = reactive({ question: '', answer: '', enabled: true })
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const loadForEdit = async () => {
|
||||
if (!avatarId.value || !qaId.value) return
|
||||
try {
|
||||
const res: any = await getQAPairs(avatarId.value)
|
||||
const list: any[] = unwrapListData(res)
|
||||
const target = list.find((q) => q.id === qaId.value)
|
||||
if (target) {
|
||||
form.question = target.question
|
||||
form.answer = target.answer
|
||||
form.enabled = target.enabled !== false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
error.value = ''
|
||||
if (!avatarId.value) {
|
||||
error.value = '请先创建数字分身'
|
||||
return
|
||||
}
|
||||
if (!form.question.trim() || !form.answer.trim()) {
|
||||
error.value = '请填写问题和答案'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
question: form.question.trim(),
|
||||
answer: form.answer.trim(),
|
||||
enabled: form.enabled
|
||||
}
|
||||
if (isEdit.value && qaId.value) {
|
||||
await updateQAPair(avatarId.value, qaId.value, payload)
|
||||
} else {
|
||||
await createQAPair(avatarId.value, payload)
|
||||
}
|
||||
// 保存成功返回知识库管理页
|
||||
router.replace('/knowledge')
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.avatars.length) {
|
||||
await store.loadAvatars()
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await loadForEdit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qa-edit-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: #18191C;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.switch-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.switch-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.switch-desc {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 开关 */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: #E5E7EB;
|
||||
border-radius: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: #F97316;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
color: #EF4444;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="glow glow-top" aria-hidden="true" />
|
||||
<div class="glow glow-bottom" aria-hidden="true" />
|
||||
|
||||
<div class="login-content">
|
||||
<div class="brand">
|
||||
<div class="brand-logo" aria-hidden="true">
|
||||
<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="brand-grad" x1="0" y1="0" x2="88" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFB36B" />
|
||||
<stop offset="1" stop-color="#FF7A1A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="44" cy="44" r="42" fill="url(#brand-grad)" />
|
||||
<circle cx="44" cy="44" r="42" fill="none" stroke="#FFFFFF" stroke-opacity="0.5" stroke-width="2" />
|
||||
<rect x="26" y="30" width="36" height="32" rx="10" fill="#FFFFFF" />
|
||||
<circle cx="37" cy="46" r="3.5" fill="#FF7A1A" />
|
||||
<circle cx="51" cy="46" r="3.5" fill="#FF7A1A" />
|
||||
<rect x="36" y="53" width="16" height="3.5" rx="1.75" fill="#FF7A1A" />
|
||||
<rect x="42" y="18" width="4" height="10" rx="2" fill="#FFFFFF" />
|
||||
<circle cx="44" cy="16" r="4" fill="#FFFFFF" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="brand-title">会会数字分身</h1>
|
||||
<p class="brand-sub">你的专属 AI 数字分身</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换:默认账号密码 -->
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'password' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'password'"
|
||||
@click="activeTab = 'password'"
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'sms' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'sms'"
|
||||
@click="activeTab = 'sms'"
|
||||
>
|
||||
短信登录
|
||||
</button>
|
||||
<span class="tab-indicator" :class="activeTab" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<form v-if="activeTab === 'password'" class="form" @submit.prevent="onPwdLogin" novalidate>
|
||||
<div class="input-card">
|
||||
<input
|
||||
v-model="account"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
placeholder="手机号 / 会会账号"
|
||||
aria-label="账号"
|
||||
@input="errorMsg = ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-card">
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
aria-label="密码"
|
||||
@input="errorMsg = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="!canPwdLogin || loading">
|
||||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 短信验证码登录 -->
|
||||
<form v-else class="form" @submit.prevent="onSmsLogin" novalidate>
|
||||
<div class="input-card">
|
||||
<span class="prefix" aria-hidden="true">+86</span>
|
||||
<span class="divider" aria-hidden="true" />
|
||||
<input
|
||||
v-model="phone"
|
||||
class="input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
aria-label="手机号"
|
||||
@input="onPhoneInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-card code-card">
|
||||
<input
|
||||
v-model="code"
|
||||
class="input"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
aria-label="验证码"
|
||||
@input="onCodeInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="code-btn"
|
||||
:disabled="!canSend || counting"
|
||||
@click="onSendCode"
|
||||
>
|
||||
{{ counting ? `${countdown}s 后重发` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="!canLogin || loading">
|
||||
{{ loading ? '登录中...' : '登录 / 创建分身' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
<p class="footer-hint">
|
||||
{{ activeTab === 'password' ? '使用会会账号密码登录,首次登录将自动创建分身' : '未注册的手机号验证后将自动创建分身账号' }}
|
||||
</p>
|
||||
<p class="footer-agree">登录即代表同意《用户协议》与《隐私政策》</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const avatarStore = useAvatarStore()
|
||||
|
||||
const activeTab = ref<'password' | 'sms'>('password')
|
||||
|
||||
// 短信登录
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
|
||||
// 密码登录
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const counting = ref(false)
|
||||
const countdown = ref(60)
|
||||
let timer: any = null
|
||||
|
||||
const canSend = computed(() => /^1\d{10}$/.test(phone.value))
|
||||
const canLogin = computed(() => canSend.value && /^\d{4,6}$/.test(code.value))
|
||||
const canPwdLogin = computed(() => account.value.trim().length > 0 && password.value.length > 0)
|
||||
|
||||
const onPhoneInput = () => {
|
||||
phone.value = phone.value.replace(/\D/g, '').slice(0, 11)
|
||||
errorMsg.value = ''
|
||||
}
|
||||
const onCodeInput = () => {
|
||||
code.value = code.value.replace(/\D/g, '').slice(0, 6)
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
const startCountdown = () => {
|
||||
counting.value = true
|
||||
countdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
counting.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const onSendCode = async () => {
|
||||
if (!canSend.value || counting.value) return
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await userStore.sendCode(phone.value)
|
||||
startCountdown()
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '验证码发送失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
const afterLogin = (res: any) => {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: res.huihui?.userId || res.user?.huihuiUserId || '',
|
||||
nickname: res.huihui?.nickname || res.user?.nickname || '',
|
||||
avatarUrl: res.huihui?.avatarUrl || res.user?.avatarUrl || ''
|
||||
})
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.replace(redirect)
|
||||
}
|
||||
|
||||
const onSmsLogin = async () => {
|
||||
if (!canLogin.value || loading.value) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res: any = await userStore.login(phone.value, code.value)
|
||||
afterLogin(res)
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '登录失败,请检查验证码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onPwdLogin = async () => {
|
||||
if (!canPwdLogin.value || loading.value) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res: any = await userStore.loginByPwd(account.value.trim(), password.value)
|
||||
afterLogin(res)
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '登录失败,请检查账号或密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding-top: calc(20px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
overflow: hidden;
|
||||
background: #fff6ec;
|
||||
animation: fadeIn 0.45s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(50px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.glow-top {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
top: -90px;
|
||||
right: -40px;
|
||||
background: rgba(255, 179, 107, 0.5);
|
||||
}
|
||||
|
||||
.glow-bottom {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
bottom: -120px;
|
||||
left: -110px;
|
||||
background: rgba(255, 205, 166, 0.55);
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
filter: drop-shadow(0 12px 24px rgba(255, 122, 26, 0.3));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #4a2511;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #a9744f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── 分段切换 ── */
|
||||
.tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: inset 0 1px 2px rgba(74, 37, 17, 0.05);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #a9744f;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #4a2511;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
width: calc(50% - 5px);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(74, 37, 17, 0.1);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tab-indicator.password {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.tab-indicator.sms {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* ── 表单 ── */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
animation: formIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes formIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.input-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 56px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(74, 37, 17, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.8);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.input-card:focus-within {
|
||||
border-color: rgba(255, 140, 66, 0.65);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(74, 37, 17, 0.08),
|
||||
0 0 0 3px rgba(255, 140, 66, 0.15),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.prefix {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4a2511;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(74, 37, 17, 0.15);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: #4a2511;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #a9744f;
|
||||
}
|
||||
|
||||
.code-card {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
flex-shrink: 0;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #ff8c42;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.code-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.code-btn:disabled {
|
||||
background: #f3d2c3;
|
||||
color: #fff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: -4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ff8c42 0%, #f96e1c 100%);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 20px rgba(255, 140, 66, 0.45);
|
||||
transition: transform 0.15s, opacity 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
background: #e5e7eb;
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #a9744f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-agree {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #c9a88e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="token-charge-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">Token 充值</h1>
|
||||
<div class="header-right"></div>
|
||||
</header>
|
||||
|
||||
<!-- 当前余额 -->
|
||||
<section class="balance-section">
|
||||
<div class="balance-card">
|
||||
<span class="balance-label">当前余额</span>
|
||||
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
||||
<span class="balance-unit">Token</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 充值套餐 -->
|
||||
<section class="plans-section">
|
||||
<h3 class="section-title">选择充值套餐</h3>
|
||||
<div class="plan-list">
|
||||
<div
|
||||
class="plan-card"
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:class="{ selected: selectedPlan?.id === plan.id }"
|
||||
@click="selectedPlan = plan"
|
||||
>
|
||||
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
||||
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div>
|
||||
<div class="plan-unit">Token</div>
|
||||
<div class="plan-price">¥{{ plan.price }}</div>
|
||||
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 支付方式 -->
|
||||
<section class="payment-section">
|
||||
<h3 class="section-title">支付方式</h3>
|
||||
<div class="payment-list">
|
||||
<div
|
||||
class="payment-card"
|
||||
:class="{ selected: paymentMethod === 'wechat' }"
|
||||
@click="paymentMethod = 'wechat'"
|
||||
>
|
||||
<span class="payment-icon">💚</span>
|
||||
<span class="payment-name">微信支付</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
||||
</div>
|
||||
<div
|
||||
class="payment-card"
|
||||
:class="{ selected: paymentMethod === 'alipay' }"
|
||||
@click="paymentMethod = 'alipay'"
|
||||
>
|
||||
<span class="payment-icon">💙</span>
|
||||
<span class="payment-name">支付宝</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 充值按钮 -->
|
||||
<section class="checkout-section">
|
||||
<button
|
||||
class="checkout-btn"
|
||||
:class="{ disabled: !selectedPlan }"
|
||||
:disabled="!selectedPlan"
|
||||
@click="doCharge"
|
||||
>
|
||||
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 当前余额
|
||||
const currentBalance = ref<number>(1250)
|
||||
|
||||
// 充值套餐
|
||||
const plans = ref<Array<{
|
||||
id: string
|
||||
amount: number
|
||||
price: number
|
||||
badge?: string
|
||||
desc?: string
|
||||
}>>([])
|
||||
|
||||
// 选中的套餐
|
||||
const selectedPlan = ref<any>(null)
|
||||
|
||||
// 支付方式
|
||||
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
||||
|
||||
// 从后端加载余额与套餐
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const b: any = await getTokenBalance()
|
||||
currentBalance.value = b?.balance ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
try {
|
||||
const p: any = await getRechargePlans()
|
||||
plans.value = p || []
|
||||
} catch (e) {
|
||||
console.error('加载套餐失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行充值(写入后端)
|
||||
const charging = ref(false)
|
||||
const doCharge = async () => {
|
||||
if (!selectedPlan.value || charging.value) return
|
||||
charging.value = true
|
||||
try {
|
||||
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
|
||||
const res: any = await chargeToken(selectedPlan.value.id)
|
||||
currentBalance.value = res?.balance ?? currentBalance.value
|
||||
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`)
|
||||
} catch (e) {
|
||||
alert('充值失败,请重试')
|
||||
} finally {
|
||||
charging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-charge-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
/* 当前余额 */
|
||||
.balance-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.balance-unit {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 充值套餐 */
|
||||
.plans-section {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
position: relative;
|
||||
padding: 20px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 2px solid #EDEEF1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.plan-card.selected {
|
||||
border-color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 0 12px 0 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #18191C;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plan-unit {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #F97316;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
font-size: 11px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 支付方式 */
|
||||
.payment-section {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.payment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #EDEEF1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.payment-card:hover {
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.payment-card.selected {
|
||||
border-color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.payment-check {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #F97316;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 充值按钮 */
|
||||
.checkout-section {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkout-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.checkout-btn.disabled {
|
||||
background: #D1D5DB;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkout-btn.disabled:hover {
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user