AssetManager.UniApp/pages/index/index.vue

757 lines
17 KiB
Vue
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-container">
<!-- 骨架屏资产卡片区域 -->
<view class="header-section" v-if="loading">
<view class="skeleton-asset-card">
<view class="skeleton-row">
<view class="skeleton-text skeleton-label"></view>
</view>
<view class="skeleton-row">
<view class="skeleton-text skeleton-big"></view>
</view>
<view class="skeleton-row skeleton-bottom">
<view class="skeleton-text skeleton-stat"></view>
<view class="skeleton-text skeleton-stat"></view>
</view>
</view>
</view>
<!-- 真实内容资产卡片 -->
<view class="header-section" v-else>
<view class="asset-card">
<view class="card-row top-row">
<view class="row-left">
<text class="label-text">账本总额 (CNY)</text>
<view class="eye-btn">
<uni-icons type="eye-filled" size="18" color="rgba(255,255,255,0.7)"></uni-icons>
</view>
</view>
</view>
<view class="card-row main-row">
<text class="currency-symbol">¥</text>
<text class="big-number">{{ (assetData.totalValue || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</text>
</view>
<view class="card-row bottom-row">
<view class="stat-col">
<text class="stat-label">今日账面变动</text>
<text class="stat-value">{{ (assetData.todayProfit || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(assetData.todayProfitCurrency || 'CNY') }}{{ (assetData.todayProfit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</text>
</view>
<view class="stat-col align-right">
<text class="stat-label">历史总变动</text>
<text class="stat-value">{{ (assetData.totalReturnRate || 0) >= 0 ? '+' : '' }}{{ assetData.totalReturnRate || 0 }}%</text>
</view>
</view>
</view>
</view>
<view class="part-add-portfolio">
<view class="dashed-btn" @click="goConfig">
<uni-icons type="plus" size="20" color="#9CA3AF"></uni-icons>
<text class="btn-text">新建组合</text>
</view>
</view>
<view class="part-holdings-list">
<view class="section-header">
<text class="section-title">当前记录组合</text>
</view>
<!-- 骨架屏持仓卡片 -->
<view v-if="loading" class="holding-card" v-for="i in 2" :key="'skeleton-' + i">
<view class="card-top">
<view class="flex-row items-center gap-2">
<view class="skeleton-icon"></view>
<view class="flex-col">
<view class="skeleton-text skeleton-name"></view>
<view class="skeleton-text skeleton-tags"></view>
</view>
</view>
<view class="skeleton-text skeleton-status"></view>
</view>
<view class="card-divider"></view>
<view class="card-bottom">
<view class="skeleton-text skeleton-data"></view>
<view class="skeleton-text skeleton-data"></view>
</view>
</view>
<!-- 空状态 -->
<view v-else-if="holdings.length === 0" class="empty-state">
<view class="empty-icon">
<uni-icons type="folder" size="64" color="#D1D5DB"></uni-icons>
</view>
<text class="empty-title">暂无组合记录</text>
<text class="empty-desc">点击上方"新建组合"开始记账</text>
<view class="empty-btn" @click="goConfig">
<uni-icons type="plus" size="16" color="#064E3B"></uni-icons>
<text class="empty-btn-text">新建第一个组合</text>
</view>
</view>
<!-- 真实内容:持仓卡片 -->
<view
v-else
v-for="holding in holdings"
:key="holding.id"
class="holding-card"
@click="goDetail(holding.id)"
>
<view class="card-top">
<view class="flex-row items-center gap-2">
<view class="strategy-icon" :class="holding.iconBgClass">
<text class="icon-text" :class="holding.iconTextClass">{{ holding.iconChar }}</text>
</view>
<view class="flex-col">
<text class="card-name">{{ holding.name }}</text>
<text class="card-tags">{{ holding.tags }}</text>
</view>
</view>
<view class="status-badge" :class="getStatusBgClass(holding.statusType)">
<text class="status-text" :class="getStatusTextClass(holding.statusType)">● {{ holding.status }}</text>
</view>
</view>
<view class="card-divider"></view>
<view class="card-bottom">
<view class="data-col">
<text class="data-label">当前估值</text>
<text class="data-val">{{ getCurrencySymbol(holding.currency) }}{{ (holding.value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</text>
</view>
<view class="data-col align-right">
<text class="data-label">收益率</text>
<text class="data-val" :class="holding.returnType === 'positive' ? 'text-red' : 'text-green'">{{ (holding.returnRate || 0) >= 0 ? '+' : '' }}{{ holding.returnRate || 0 }}%</text>
</view>
</view>
<view class="card-extra" v-if="holding.todayProfit">
<text class="extra-label">今日变动</text>
<text class="extra-val" :class="(holding.todayProfit || 0) >= 0 ? 'text-red' : 'text-green'">
{{ (holding.todayProfit || 0) >= 0 ? '+' : '-' }}{{ getCurrencySymbol(holding.todayProfitCurrency || holding.currency) }}{{ Math.abs(holding.todayProfit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</text>
</view>
</view>
<view style="height: 100rpx;"></view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app';
import { api } from '@/utils/api';
import { getCurrencySymbol } from '@/utils/currency';
import type { PortfolioListItem, TotalAssetsResponse } from '@/types';
const loading = ref<boolean>(true);
const refreshing = ref<boolean>(false);
const assetData = ref<TotalAssetsResponse>({
totalValue: 0,
todayProfit: 0,
todayProfitCurrency: 'CNY',
totalReturnRate: 0
});
const holdings = ref<PortfolioListItem[]>([]);
const getStatusBgClass = (statusType?: string): string => {
const classes: Record<string, string> = {
'green': 'bg-green-50',
'yellow': 'bg-yellow-50',
'red': 'bg-red-50',
'gray': 'bg-gray-100'
};
return classes[statusType || 'gray'] || 'bg-gray-100';
};
const getStatusTextClass = (statusType?: string): string => {
const classes: Record<string, string> = {
'green': 'text-green-600',
'yellow': 'text-yellow-600',
'red': 'text-red-600',
'gray': 'text-gray-500'
};
return classes[statusType || 'gray'] || 'text-gray-500';
};
let isFetching = false;
const fetchAssetData = async (): Promise<void> => {
try {
const response = await api.assets.getAssetData();
if (response.code === 200) {
const data = response.data;
assetData.value = {
totalValue: data.totalValue,
todayProfit: data.todayProfit,
todayProfitCurrency: data.todayProfitCurrency || 'CNY',
totalReturnRate: data.totalReturnRate
};
}
} catch (error) {
console.error('获取资产数据失败:', error);
}
};
const fetchHoldingsData = async (): Promise<void> => {
try {
const response = await api.assets.getHoldings();
if (response.code === 200) {
const items = response.data.items || [];
holdings.value = items.map((item: any) => ({
id: item.id,
name: item.name,
tags: item.tags,
status: item.status,
statusType: item.statusType,
iconChar: item.iconChar,
iconBgClass: item.iconBgClass,
iconTextClass: item.iconTextClass,
value: item.value,
currency: item.currency,
returnRate: item.returnRate,
returnType: item.returnType,
todayProfit: item.todayProfit,
todayProfitCurrency: item.todayProfitCurrency
}));
}
} catch (error) {
console.error('获取持仓数据失败:', error);
}
};
const goConfig = (): void => {
uni.navigateTo({ url: '/pages/config/config' });
};
const goDetail = (holdingId?: string): void => {
if (holdingId) {
uni.navigateTo({ url: `/pages/detail/detail?id=${holdingId}` });
}
};
onShow(async () => {
isFetching = true;
loading.value = true;
await Promise.all([fetchAssetData(), fetchHoldingsData()]);
loading.value = false;
isFetching = false;
});
// 下拉刷新
onPullDownRefresh(async () => {
refreshing.value = true;
await Promise.all([fetchAssetData(), fetchHoldingsData()]);
refreshing.value = false;
uni.stopPullDownRefresh();
});
// 刷新按钮点击
const handleRefresh = async (): Promise<void> => {
loading.value = true;
await Promise.all([fetchAssetData(), fetchHoldingsData()]);
loading.value = false;
uni.showToast({ title: '刷新成功', icon: 'success', duration: 1500 });
};
</script>
<style scoped>
/* 通用布局 */
.page-container {
min-height: 100vh;
background-color: #F9FAFB;
/* 浅灰色背景,与添加按钮背景一致 */
}
.flex-row {
display: flex;
flex-direction: row;
}
.flex-col {
display: flex;
flex-direction: column;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: 16rpx;
}
/* 字体颜色工具类 */
.text-gray-500 {
color: #6B7280;
}
.text-green-600 {
color: #059669;
}
.text-yellow-600 {
color: #D97706;
}
.text-red-600 {
color: #DC2626;
}
.text-red {
color: #EF4444;
}
/* 涨 */
.text-green {
color: #10B981;
}
/* 跌 */
/* ============================ */
/* 骨架屏样式 */
/* ============================ */
.skeleton-asset-card {
background-color: #064E3B;
border-radius: 40rpx;
padding: 40rpx 48rpx;
min-height: 320rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.skeleton-row {
display: flex;
align-items: center;
}
.skeleton-bottom {
justify-content: space-between;
}
.skeleton-text {
background: linear-gradient(90deg, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.2) 37%, rgba(255,255,255,0.1) 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 8rpx;
}
.skeleton-label {
width: 200rpx;
height: 28rpx;
}
.skeleton-big {
width: 400rpx;
height: 68rpx;
margin-top: 20rpx;
}
.skeleton-stat {
width: 180rpx;
height: 36rpx;
}
.skeleton-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #F1F2F4 25%, #e6e6e6 37%, #F1F2F4 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
}
.skeleton-name {
width: 160rpx;
height: 30rpx;
margin-bottom: 8rpx;
background: linear-gradient(90deg, #F1F2F4 25%, #e6e6e6 37%, #F1F2F4 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
}
.skeleton-tags {
width: 100rpx;
height: 22rpx;
background: linear-gradient(90deg, #F1F2F4 25%, #e6e6e6 37%, #F1F2F4 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
}
.skeleton-status {
width: 100rpx;
height: 32rpx;
border-radius: 100rpx;
background: linear-gradient(90deg, #F1F2F4 25%, #e6e6e6 37%, #F1F2F4 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
}
.skeleton-data {
width: 140rpx;
height: 32rpx;
background: linear-gradient(90deg, #F1F2F4 25%, #e6e6e6 37%, #F1F2F4 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
}
@keyframes skeleton-loading {
0% {
background-position: 100% 50%
}
100% {
background-position: 0 50%
}
}
/* ============================ */
/* Part 1: 资产卡片样式 (精修版) */
/* ============================ */
.header-section {
padding: 20rpx 32rpx;
background-color: #F9FAFB;
/* 浅灰色背景,与页面背景一致 */
}
.asset-card {
background-color: #064E3B;
/* 深墨绿色 */
border-radius: 40rpx;
padding: 40rpx 48rpx;
position: relative;
overflow: hidden;
box-shadow: 0 10rpx 30rpx rgba(6, 78, 59, 0.25);
min-height: 320rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* 内容行通用设置 */
.card-row {
position: relative;
z-index: 1;
}
/* 第一行:顶部布局 (关键修复:两端对齐) */
.top-row {
display: flex;
justify-content: space-between;
/* 让铃铛靠右 */
align-items: center;
margin-bottom: 20rpx;
}
.row-left {
display: flex;
align-items: center;
}
.label-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
}
.eye-btn {
margin-left: 12rpx;
opacity: 0.8;
}
/* 第二行:大数字 */
.main-row {
display: flex;
align-items: baseline;
margin-bottom: 30rpx;
}
.currency-symbol {
font-size: 40rpx;
color: #FFFFFF;
font-weight: bold;
margin-right: 8rpx;
font-family: 'DIN Alternate', sans-serif;
}
.big-number {
font-size: 68rpx;
color: #FFFFFF;
font-weight: 800;
letter-spacing: 1rpx;
font-family: 'DIN Alternate', sans-serif;
}
/* 第三行:底部数据 */
.bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.stat-col {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.align-right {
align-items: flex-end;
}
.stat-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.stat-value {
font-size: 36rpx;
color: #FFFFFF;
font-weight: 700;
font-family: 'DIN Alternate', sans-serif;
}
/* ============================ */
/* Part 2: 添加组合按钮样式 */
/* ============================ */
.part-add-portfolio {
padding: 10rpx 32rpx 30rpx 32rpx;
}
.dashed-btn {
width: 100%;
height: 96rpx;
background-color: #F9FAFB;
border: 2rpx dashed #D1D5DB;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
transition: all 0.2s;
}
.dashed-btn:active {
background-color: #F3F4F6;
border-color: #9CA3AF;
}
.btn-text {
font-size: 28rpx;
color: #6B7280;
font-weight: 500;
}
/* ============================ */
/* Part 3: 持仓列表样式 */
/* ============================ */
.part-holdings-list {
padding: 0 32rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding-left: 8rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 800;
color: #1F2937;
}
/* 卡片样式 */
.holding-card {
background-color: #FFFFFF;
border: 1rpx solid #E5E7EB;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
}
.holding-card:active {
transform: translateY(2rpx);
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.04);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.strategy-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.bg-green-50 {
background-color: #ECFDF5;
}
.bg-yellow-50 {
background-color: #FFFBEB;
}
.bg-red-50 {
background-color: #FEF2F2;
}
.bg-gray-100 {
background-color: #F3F4F6;
}
.icon-text {
font-size: 36rpx;
font-weight: 800;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: #1F2937;
margin-bottom: 6rpx;
max-width: 320rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-tags {
font-size: 22rpx;
color: #9CA3AF;
}
.status-badge {
padding: 6rpx 16rpx;
border-radius: 100rpx;
}
.status-text {
font-size: 20rpx;
font-weight: 600;
}
.card-divider {
height: 1rpx;
background-color: #F3F4F6;
margin: 24rpx 0;
}
.card-bottom {
display: flex;
justify-content: space-between;
}
.data-col {
display: flex;
flex-direction: column;
}
.data-label {
font-size: 22rpx;
color: #9CA3AF;
margin-bottom: 8rpx;
}
.data-val {
font-size: 32rpx;
font-weight: 700;
font-family: 'DIN Alternate', sans-serif;
color: #1F2937;
}
.card-extra {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #F3F4F6;
}
.extra-label {
font-size: 22rpx;
color: #9CA3AF;
}
.extra-val {
font-size: 28rpx;
font-weight: 600;
font-family: 'DIN Alternate', sans-serif;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
background-color: #FFFFFF;
border-radius: 32rpx;
margin: 20rpx 0;
}
.empty-icon {
margin-bottom: 32rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 700;
color: #374151;
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 26rpx;
color: #9CA3AF;
margin-bottom: 40rpx;
}
.empty-btn {
display: flex;
align-items: center;
gap: 12rpx;
background-color: #ECFDF5;
padding: 20rpx 40rpx;
border-radius: 100rpx;
border: 2rpx solid #10B981;
}
.empty-btn:active {
background-color: #D1FAE5;
}
.empty-btn-text {
font-size: 28rpx;
font-weight: 600;
color: #064E3B;
}
</style>