feat: complete huihui square avatar workflows

This commit is contained in:
stefanfeng
2026-07-24 14:04:21 +08:00
parent 2ef58a44b8
commit e3bda469bb
68 changed files with 8062 additions and 132 deletions
@@ -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>
+185
View File
@@ -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>
+157
View File
@@ -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>
+100
View File
@@ -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>
+304
View File
@@ -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>
+536
View File
@@ -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>