Files
sionrui/frontend/app/web-gold/src/views/user/Profile.vue
2026-03-17 23:41:49 +08:00

710 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, ref, reactive } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import { useUserStore } from '@/stores/user'
import { getPointRecordPage } from '@/api/pointRecord'
import { redeemCode as redeemCodeApi } from '@/api/redeemCode'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@/components/ui/dialog'
const userStore = useUserStore()
// 兑换码相关
const showRedeemDialog = ref(false)
const redeemCodeInput = ref('')
const redeeming = ref(false)
// 积分记录数据
const pointRecords = ref([])
const recordsLoading = ref(false)
const recordsPagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
// 存储空间数据
const GB_TO_MB = 1024
const totalStorage = computed(() => userStore.totalStorage * GB_TO_MB)
const usedStorage = computed(() => userStore.usedStorage * GB_TO_MB)
const storagePercent = computed(() => {
if (totalStorage.value === 0) return 0
return Math.min(100, (usedStorage.value / totalStorage.value) * 100)
})
// 格式化函数
function formatStorage(mb) {
if (mb >= 1024) {
return (mb / 1024).toFixed(1) + ' GB'
}
return Math.round(mb) + ' MB'
}
function formatMoney(amount) {
return amount?.toFixed(2) || '0.00'
}
function formatCredits(credits) {
return credits?.toLocaleString() || '0'
}
// 格式化日期
function formatDate(dateStr) {
if (!dateStr) return '未设置'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\//g, '-')
}
// 脱敏手机号
function maskMobile(mobile) {
if (!mobile) return '未设置'
return mobile.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
// 获取积分记录(只显示已完成的记录)
async function fetchPointRecords() {
recordsLoading.value = true
try {
const res = await getPointRecordPage({
pageNo: recordsPagination.current,
pageSize: recordsPagination.pageSize,
status: 'confirmed'
})
if (res.data) {
pointRecords.value = res.data.list || []
recordsPagination.total = res.data.total || 0
}
} catch (e) {
console.error('获取积分记录失败:', e)
} finally {
recordsLoading.value = false
}
}
// 分页变化
function handlePageChange(page) {
recordsPagination.current = page
fetchPointRecords()
}
// 计算总页数
const totalPages = computed(() => {
return Math.ceil(recordsPagination.total / recordsPagination.pageSize)
})
// 格式化积分记录时间
function formatRecordTime(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 获取业务类型显示名称
function getBizTypeName(bizType) {
const typeMap = {
'signin': '签到',
'recharge': '充值',
'exchange': '兑换',
'admin': '后台调整',
'gift': '礼包赠送',
'dify_chat': 'AI文案',
'digital_human': '数字人',
'voice_tts': '语音克隆',
'tikhub_fetch': '数据采集',
'forecast_rewrite': '文案改写'
}
return typeMap[bizType] || bizType || '其他'
}
// 获取状态显示
function getStatusInfo(status) {
const statusMap = {
'pending': { text: '处理中', color: 'orange' },
'confirmed': { text: '已完成', color: 'green' },
'canceled': { text: '已取消', color: 'default' }
}
return statusMap[status] || { text: status, color: 'default' }
}
// 兑换码兑换
async function handleRedeem() {
const code = redeemCodeInput.value.trim()
if (!code) {
toast.error('请输入兑换码')
return
}
redeeming.value = true
try {
await redeemCodeApi(code)
toast.success('兑换成功,积分已到账')
showRedeemDialog.value = false
redeemCodeInput.value = ''
// 刷新用户信息和积分记录
await userStore.fetchUserProfile()
await fetchPointRecords()
} catch (e) {
const msg = e?.response?.data?.msg || e?.message || '兑换失败'
toast.error(msg)
} finally {
redeeming.value = false
}
}
onMounted(async () => {
if (userStore.isLoggedIn) {
// 获取用户基本信息和档案信息
if (!userStore.mobile) {
await userStore.fetchUserInfo()
}
if (!userStore.profile) {
await userStore.fetchUserProfile()
}
// 获取积分记录
await fetchPointRecords()
}
})
</script>
<template>
<div class="profile-container">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- 左侧用户信息卡片 -->
<div class="lg:col-span-4">
<div class="user-card">
<div class="user-info-header">
<div class="avatar-wrapper">
<img
v-if="userStore.displayAvatar"
class="user-avatar"
:src="userStore.displayAvatar"
alt="avatar"
/>
<div v-else class="user-avatar-placeholder">
{{ userStore.displayName?.charAt(0) || 'U' }}
</div>
</div>
<h2 class="user-name">{{ userStore.displayName }}</h2>
<div class="user-role-badge">普通用户</div>
</div>
<div class="divider"></div>
<div class="user-details">
<div class="detail-item">
<span class="detail-label">注册时间</span>
<span class="detail-value">{{ formatDate(userStore.profile?.registerTime || userStore.profile?.createTime) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">绑定手机</span>
<span class="detail-value">{{ maskMobile(userStore.mobile || userStore.profile?.mobile) }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧资源统计与活动 -->
<div class="lg:col-span-8">
<!-- 资源概览卡片 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- 存储空间 -->
<div class="stat-card">
<div class="stat-icon-wrapper blue">
<Icon icon="lucide:database" class="text-2xl" />
</div>
<div class="stat-content">
<div class="stat-label">存储空间</div>
<div class="stat-value">{{ formatStorage(usedStorage) }} <span class="stat-unit">/ {{ formatStorage(totalStorage) }}</span></div>
<Progress :value="storagePercent" class="stat-progress h-2" />
</div>
</div>
<!-- 剩余积分 -->
<div class="stat-card">
<div class="stat-icon-wrapper purple">
<Icon icon="lucide:wallet" class="text-2xl" />
</div>
<div class="stat-content">
<div class="stat-label">剩余积分</div>
<div class="stat-value">{{ formatCredits(userStore.remainingPoints) }}</div>
<div class="stat-desc">用于生成消耗</div>
</div>
</div>
<!-- 账户余额 -->
<div class="stat-card">
<div class="stat-icon-wrapper orange">
<Icon icon="lucide:coins" class="text-2xl" />
</div>
<div class="stat-content">
<div class="stat-label">账户余额</div>
<div class="stat-value">¥{{ formatMoney(userStore.totalRecharge) }}</div>
<div class="stat-desc">累计充值金额</div>
</div>
</div>
<!-- 兑换码充值 -->
<div class="stat-card">
<div class="stat-icon-wrapper green">
<Icon icon="lucide:gift" class="text-2xl" />
</div>
<div class="stat-content">
<div class="stat-label">兑换码充值</div>
<div class="stat-value" style="font-size: 16px;">
<Button
variant="outline"
size="sm"
@click="showRedeemDialog = true"
>
输入兑换码
</Button>
</div>
<div class="stat-desc">使用兑换码获取积分</div>
</div>
</div>
</div>
<!-- 积分记录 -->
<div class="activity-card mt-6">
<div class="activity-header">
<h3 class="activity-title">积分记录</h3>
<span class="record-count"> {{ recordsPagination.total }} 条记录</span>
</div>
<!-- 加载状态 -->
<div v-if="recordsLoading" class="loading-state">
<span class="custom-spinner"></span>
<span>加载中...</span>
</div>
<!-- 积分记录列表 -->
<div v-else-if="pointRecords.length > 0" class="point-record-list">
<div
v-for="item in pointRecords"
:key="item.id"
class="record-item"
>
<div class="record-left">
<div :class="['record-icon', item.type === 'increase' ? 'increase' : 'decrease']">
<Icon v-if="item.type === 'increase'" icon="lucide:plus" />
<Icon v-else icon="lucide:minus" />
</div>
<div class="record-info">
<div class="record-reason">{{ getBizTypeName(item.bizType) }}</div>
<div class="record-time">{{ formatRecordTime(item.createTime) }}</div>
</div>
</div>
<div :class="['record-amount', item.type === 'increase' ? 'increase' : 'decrease']">
{{ item.type === 'increase' ? '+' : '-' }}{{ Math.abs(item.pointAmount) }}
</div>
</div>
<!-- 分页 -->
<div v-if="recordsPagination.total > recordsPagination.pageSize" class="record-pagination">
<Button
variant="outline"
size="sm"
:disabled="recordsPagination.current === 1"
@click="handlePageChange(recordsPagination.current - 1)"
>
上一页
</Button>
<span class="page-info">
{{ recordsPagination.current }} / {{ totalPages }}
</span>
<Button
variant="outline"
size="sm"
:disabled="recordsPagination.current >= totalPages"
@click="handlePageChange(recordsPagination.current + 1)"
>
下一页
</Button>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<Icon icon="lucide:clock" class="empty-icon" />
<p>暂无积分记录</p>
</div>
</div>
</div>
</div>
<!-- 兑换码弹窗 -->
<Dialog v-model:open="showRedeemDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>兑换码充值</DialogTitle>
<DialogDescription>
输入您的兑换码积分将立即到账
</DialogDescription>
</DialogHeader>
<div class="py-4">
<Input
v-model="redeemCodeInput"
placeholder="请输入兑换码"
class="w-full"
@keyup.enter="handleRedeem"
/>
</div>
<DialogFooter>
<Button variant="outline" @click="showRedeemDialog = false">
取消
</Button>
<Button @click="handleRedeem" :disabled="redeeming">
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<style scoped>
.profile-container {
padding: var(--space-6);
max-width: 1200px;
margin: 0 auto;
}
.profile-content {
text-align: center;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
background: var(--card);
height: 100%;
padding: 24px;
}
.user-info-header {
padding: 24px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.avatar-wrapper {
margin-bottom: 16px;
position: relative;
}
.user-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 4px solid rgba(var(--primary-color-rgb, 24, 144, 255), 0.1);
}
.user-avatar-placeholder {
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 40px;
font-weight: 600;
border: 4px solid rgba(24, 144, 255, 0.1);
}
.user-name {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
color: var(--foreground);
}
.user-role-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}
.user-details {
padding: 16px 0;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.detail-item:last-child {
border-bottom: none;
}
.detail-label {
color: var(--muted-foreground);
}
.detail-value {
color: var(--foreground);
font-weight: 500;
}
/* Stat Cards */
.stat-card {
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
background: var(--card);
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
}
.stat-icon-wrapper {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.stat-icon-wrapper.blue {
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
}
.stat-icon-wrapper.purple {
background: rgba(114, 46, 209, 0.1);
color: #722ed1;
}
.stat-icon-wrapper.orange {
background: rgba(250, 173, 20, 0.1);
color: #faad14;
}
.stat-icon-wrapper.green {
background: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
.stat-label {
font-size: 14px;
color: var(--muted-foreground);
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--foreground);
margin-bottom: 8px;
}
.stat-unit {
font-size: 14px;
font-weight: 400;
color: var(--muted-foreground);
}
.stat-desc {
font-size: 12px;
color: var(--muted-foreground);
}
.stat-progress {
margin-top: 8px;
}
.mt-6 {
margin-top: 24px;
}
/* Loading State */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 0;
color: var(--muted-foreground);
}
.custom-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(24, 144, 255, 0.2);
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.empty-state {
text-align: center;
padding: 40px 0;
color: var(--muted-foreground);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
/* Point Record List */
.activity-card {
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
background: var(--card);
padding: 20px;
}
.activity-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.activity-title {
font-size: 16px;
font-weight: 600;
color: var(--foreground);
margin: 0;
}
.record-count {
color: var(--muted-foreground);
font-size: 13px;
}
.point-record-list {
max-height: 400px;
overflow-y: auto;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.record-item:last-child {
border-bottom: none;
}
.record-left {
display: flex;
align-items: center;
gap: 12px;
}
.record-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.record-icon.increase {
background: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
.record-icon.decrease {
background: rgba(255, 77, 79, 0.1);
color: #ff4d4f;
}
.record-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.record-reason {
font-weight: 500;
color: var(--foreground);
font-size: 14px;
}
.record-time {
font-size: 12px;
color: var(--muted-foreground);
}
.record-amount {
font-size: 18px;
font-weight: 600;
}
.record-amount.increase {
color: #52c41a;
}
.record-amount.decrease {
color: #ff4d4f;
}
.record-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.page-info {
font-size: 14px;
color: var(--muted-foreground);
}
</style>