Files
sionrui/frontend/app/web-gold/src/views/user/Profile.vue
2026-03-04 03:27:16 +08:00

577 lines
14 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 { useUserStore } from '@/stores/user'
import { getPointRecordPage } from '@/api/pointRecord'
import {
UserOutlined,
DatabaseOutlined,
WalletOutlined,
PayCircleOutlined,
ClockCircleOutlined,
SafetyCertificateOutlined,
PlusOutlined,
MinusOutlined
} from '@ant-design/icons-vue'
const userStore = useUserStore()
// 积分记录数据
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 handleTableChange(page, pageSize) {
recordsPagination.current = page
recordsPagination.pageSize = pageSize
fetchPointRecords()
}
// 格式化积分记录时间
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' }
}
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="page-header">
<h1 class="page-title">个人中心</h1>
<p class="page-subtitle">管理您的账户信息和资源使用情况</p>
</div>
<a-row :gutter="[24, 24]">
<!-- 左侧用户信息卡片 -->
<a-col :xs="24" :lg="8">
<a-card class="user-card" :bordered="false">
<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>
<a-divider />
<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>
</a-card>
</a-col>
<!-- 右侧资源统计与活动 -->
<a-col :xs="24" :lg="16">
<!-- 资源概览卡片 -->
<a-row :gutter="[24, 24]">
<a-col :xs="24" :sm="12" :md="8">
<a-card class="stat-card" :bordered="false">
<div class="stat-icon-wrapper blue">
<DatabaseOutlined />
</div>
<div class="stat-content">
<div class="stat-label">存储空间</div>
<div class="stat-value">{{ formatStorage(usedStorage) }} <span class="stat-unit">/ {{ formatStorage(totalStorage) }}</span></div>
<a-progress
:percent="storagePercent"
:show-info="false"
:stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }"
size="small"
class="stat-progress"
/>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card class="stat-card" :bordered="false">
<div class="stat-icon-wrapper purple">
<WalletOutlined />
</div>
<div class="stat-content">
<div class="stat-label">剩余积分</div>
<div class="stat-value">{{ formatCredits(userStore.remainingPoints) }}</div>
<div class="stat-desc">用于生成消耗</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card class="stat-card" :bordered="false">
<div class="stat-icon-wrapper orange">
<PayCircleOutlined />
</div>
<div class="stat-content">
<div class="stat-label">账户余额</div>
<div class="stat-value">¥{{ formatMoney(userStore.totalRecharge) }}</div>
<div class="stat-desc">累计充值金额</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 积分记录 -->
<a-card title="积分记录" :bordered="false" class="activity-card mt-6">
<template #extra>
<span class="record-count"> {{ recordsPagination.total }} 条记录</span>
</template>
<a-spin :spinning="recordsLoading">
<a-list
v-if="pointRecords.length > 0"
item-layout="horizontal"
:data-source="pointRecords"
class="point-record-list"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<div :class="['record-icon', item.type === 'increase' ? 'increase' : 'decrease']">
<PlusOutlined v-if="item.type === 'increase'" />
<MinusOutlined v-else />
</div>
</template>
<template #title>
<div class="record-title">
<span class="record-reason">{{ getBizTypeName(item.bizType) }}</span>
</div>
</template>
<template #description>
<div class="record-desc">
<span>{{ formatRecordTime(item.createTime) }}</span>
</div>
</template>
</a-list-item-meta>
<div :class="['record-amount', item.type === 'increase' ? 'increase' : 'decrease']">
{{ item.type === 'increase' ? '+' : '-' }}{{ Math.abs(item.pointAmount) }}
</div>
</a-list-item>
</template>
</a-list>
<a-pagination
v-if="recordsPagination.total > recordsPagination.pageSize"
v-model:current="recordsPagination.current"
v-model:page-size="recordsPagination.pageSize"
:total="recordsPagination.total"
:show-size-changer="false"
:show-total="total => `${total}`"
size="small"
class="record-pagination"
@change="handleTableChange"
/>
<div v-if="pointRecords.length === 0 && !recordsLoading" class="empty-state">
<ClockCircleOutlined class="empty-icon" />
<p>暂无积分记录</p>
</div>
</a-spin>
</a-card>
</a-col>
</a-row>
</div>
</template>
<style scoped>
.profile-container {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 8px;
}
.page-subtitle {
color: var(--color-text-secondary);
font-size: 14px;
}
/* User Card */
.user-card {
text-align: center;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
background: var(--color-bg-container, #fff);
height: 100%;
}
.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(--color-text);
}
.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;
}
.user-details {
padding: 16px 0;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--color-border-secondary, #f0f0f0);
}
.detail-item:last-child {
border-bottom: none;
}
.detail-label {
color: var(--color-text-secondary);
}
.detail-value {
color: var(--color-text);
font-weight: 500;
}
/* Stat Cards */
.stat-card {
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
transition: transform 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;
font-size: 24px;
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-label {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 8px;
}
.stat-unit {
font-size: 14px;
font-weight: 400;
color: var(--color-text-third);
}
.stat-desc {
font-size: 12px;
color: var(--color-text-third);
}
.stat-progress {
margin-top: 8px;
}
.mt-6 {
margin-top: 24px;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 40px 0;
color: var(--color-text-third);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.security-icon {
font-size: 24px;
color: #52c41a;
margin-right: 12px;
}
/* Point Record List */
.activity-card {
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
}
.record-count {
color: var(--color-text-secondary);
font-size: 13px;
}
.point-record-list {
max-height: 400px;
overflow-y: auto;
}
.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-title {
display: flex;
align-items: center;
gap: 8px;
}
.record-reason {
font-weight: 500;
color: var(--color-text);
}
.record-desc {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--color-text-secondary);
}
.record-biz-type {
padding: 1px 6px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
}
.record-amount {
font-size: 18px;
font-weight: 600;
}
.record-amount.increase {
color: #52c41a;
}
.record-amount.decrease {
color: #ff4d4f;
}
.record-pagination {
margin-top: 16px;
text-align: center;
}
</style>