AssetManager.UniApp/pages/detail/detail.vue
claw_bot 632b5b6f6d refactor: 改用 uni_modules 方式引入 qiun-data-charts
- 移除 npm @qiun/ucharts 依赖
- 改用 qiun-data-charts 组件(需从插件市场安装)
- 恢复 computed chartData 和 ref chartOpts 配置
2026-03-15 07:07:36 +00:00

1347 lines
40 KiB
Vue
Executable File
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.

<template>
<view class="page-container">
<!-- uView Toast 组件 -->
<u-toast ref="uToastRef" />
<!-- 骨架屏资产卡片 -->
<view class="header-section" v-if="loading">
<view class="skeleton-card">
<view class="skeleton-row">
<view class="skeleton-text skeleton-label"></view>
<view class="skeleton-text skeleton-badge"></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-watermark">
<uni-icons type="vip-filled" size="120" color="rgba(255,255,255,0.05)"></uni-icons>
</view>
<view class="card-top">
<text class="label-text">组合总额 (NV)</text>
<view class="status-badge">
<text class="status-text">账本追踪中</text>
</view>
</view>
<view class="card-main">
<text class="currency">{{ getCurrencySymbol(portfolioData.currency) }}</text>
<text class="big-number">{{ (portfolioData.portfolioValue || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</text>
</view>
<view class="card-bottom">
<view class="stat-item">
<text class="stat-label">总盈亏</text>
<text class="stat-val" :class="(portfolioData.totalReturn || 0) >= 0 ? 'text-red' : 'text-green'">
{{ (portfolioData.totalReturn || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (portfolioData.totalReturn || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
<text style="font-size: 22rpx; opacity: 0.8; margin-left: 8rpx;">({{ (portfolioData.historicalChange || 0) >= 0 ? '+' : '' }}{{ (portfolioData.historicalChange || 0).toFixed(2) }}%)</text>
</text>
</view>
<view class="stat-item align-right">
<text class="stat-label">当日盈亏</text>
<text class="stat-val" :class="(portfolioData.todayProfit || 0) >= 0 ? 'text-red' : 'text-green'">
{{ (portfolioData.todayProfit || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (portfolioData.todayProfit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</text>
</view>
</view>
</view>
</view>
<!-- 收益曲线 -->
<view class="section-container">
<view class="section-header">
<text class="section-title">收益曲线</text>
<view class="period-tabs">
<text :class="['period-tab', {active: navPeriod === '7d'}]" @click="changeNavPeriod('7d')">7天</text>
<text :class="['period-tab', {active: navPeriod === '30d'}]" @click="changeNavPeriod('30d')">30天</text>
<text :class="['period-tab', {active: navPeriod === 'all'}]" @click="changeNavPeriod('all')">全部</text>
</view>
</view>
<view class="nav-chart-container">
<!-- 加载中 -->
<view v-if="navLoading" class="nav-loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 无数据 -->
<view v-else-if="!navHistory || navHistory.length === 0" class="nav-empty">
<text class="empty-text">暂无收益数据</text>
</view>
<!-- 收益曲线 -->
<view v-else class="nav-chart-wrapper">
<qiun-data-charts
type="line"
:opts="chartOpts"
:chartData="chartData"
:canvas2d="true"
canvasId="navChartCanvas"
/>
</view>
<!-- 统计指标 -->
<view v-if="navStatistics" class="nav-stats">
<view class="stat-item">
<text class="stat-label">总收益</text>
<text class="stat-val" :class="navStatistics.totalReturn >= 0 ? 'text-red' : 'text-green'">
{{ navStatistics.totalReturn >= 0 ? '+' : '' }}{{ navStatistics.totalReturn.toFixed(2) }}%
</text>
</view>
<view class="stat-item">
<text class="stat-label">最大回撤</text>
<text class="stat-val text-green">{{ navStatistics.maxDrawdown.toFixed(2) }}%</text>
</view>
<view class="stat-item">
<text class="stat-label">夏普比率</text>
<text class="stat-val">{{ navStatistics.sharpeRatio.toFixed(2) }}</text>
</view>
<view class="stat-item">
<text class="stat-label">波动率</text>
<text class="stat-val">{{ navStatistics.volatility.toFixed(2) }}%</text>
</view>
</view>
</view>
</view>
<view class="section-container pb-0">
<view class="section-header">
<text class="section-title">当前逻辑模型</text>
<view class="flex-row items-center" @click="goStrategyConfig">
<text class="section-sub text-brand">参数配置</text>
<uni-icons type="right" size="12" color="#064E3B"></uni-icons>
</view>
</view>
<u-card
v-if="portfolioData.logicModel"
:border="false"
:shadow="false"
:show-head="false"
:show-foot="false"
class="strategy-info-card"
>
<template #body>
<view class="st-left">
<view class="st-icon-box bg-green-100">
<text class="st-icon-text text-green">{{ portfolioData.logicModel?.charAt(0) || 'S' }}</text>
</view>
<view class="flex-col gap-1">
<text class="st-name">{{ portfolioData.logicModel || '未设置策略' }}</text>
<view class="flex-row gap-2">
<text class="st-tag" v-if="portfolioData.logicModelDescription">{{ portfolioData.logicModelDescription }}</text>
</view>
</view>
</view>
<view class="st-right">
<view class="flex-row items-center gap-1">
<view class="status-dot pulsing"></view>
<text class="st-status-text">{{ portfolioData.logicModelStatus || '监控中' }}</text>
</view>
</view>
</template>
</u-card>
</view>
<view class="section-container">
<view class="section-header">
<text class="section-title">当前记录项 ({{ portfolioData.totalItems || positions.length }})</text>
<text class="section-sub">占比 {{ portfolioData.totalRatio || 100 }}%</text>
</view>
<view class="position-list">
<u-card
v-for="(item, index) in positions"
:key="item.id || index"
:border="false"
:shadow="false"
:show-head="false"
:show-foot="false"
class="position-card"
>
<template #body>
<view class="pos-top">
<view class="flex-row items-center gap-2">
<view class="stock-icon" :class="index % 2 === 0 ? 'bg-blue-100 text-blue' : 'bg-orange-100 text-orange'">
<text class="icon-char">{{ item.stockName?.charAt(0) || item.stockCode?.charAt(0) || 'S' }}</text>
</view>
<view class="flex-col flex-1">
<!-- 第一行:股票名 + 持仓市值 -->
<view class="flex-row justify-between items-center">
<text class="stock-name">{{ item.stockName || item.stockCode }}</text>
<text class="market-val">{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.totalValue || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</text>
</view>
<!-- 第二行:代码+数量 + 现价 -->
<view class="flex-row justify-between items-center mt-1">
<text class="stock-code">{{ item.stockCode }} · {{ item.amount }}份</text>
<text class="price-text">{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.currentPrice || 0).toLocaleString('zh-CN', { minimumFractionDigits: 3, maximumFractionDigits: 3 }) }}</text>
</view>
<!-- 第三行:占比 + 成本价 -->
<view class="flex-row justify-between items-center mt-1">
<text class="weight-tag">比例 {{ (item.ratio || 0).toFixed(1) }}%</text>
<text class="cost-text">{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.averagePrice || 0).toLocaleString('zh-CN', { minimumFractionDigits: 3, maximumFractionDigits: 3 }) }}</text>
</view>
<!-- 第四行:(空) + 当日盈亏 -->
<view class="flex-row justify-between items-center mt-1">
<text></text>
<text class="pnl-val" :class="(item.changeAmount || 0) >= 0 ? 'text-red' : 'text-green'">
当日 {{ (item.changeAmount || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.changeAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</text>
</view>
<!-- 第五行:(空) + 持仓盈亏 -->
<view class="flex-row justify-between items-center mt-1">
<text></text>
<text class="pnl-val" :class="(item.profit || 0) >= 0 ? 'text-red' : 'text-green'">
持仓 {{ (item.profit || 0) >= 0 ? '+' : '' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (item.profit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
<text class="pnl-rate"> ({{ (item.profitRate || 0) >= 0 ? '+' : '' }}{{ (item.profitRate || 0).toFixed(2) }}%)</text>
</text>
</view>
</view>
</view>
</view>
</template>
</u-card>
</view>
</view>
<view class="section-container">
<view class="section-header">
<text class="section-title">最近交易记录</text>
</view>
<view class="timeline-box">
<view class="timeline-item" v-for="(log, k) in logs" :key="k">
<view class="tl-left">
<text class="tl-date">{{ log.date }}</text>
<text class="tl-time">{{ log.time }}</text>
</view>
<view class="tl-line">
<view class="tl-dot" :class="log.type === 'buy' ? 'bg-red' : 'bg-green'"></view>
<view class="tl-dash" v-if="k !== logs.length - 1"></view>
</view>
<view class="tl-right">
<view class="flex-row justify-between items-center w-full">
<text class="tl-title">{{ log.title }} {{ log.stockCode || '' }}</text>
<text class="tl-amount" :class="log.type === 'buy' ? 'text-red' : 'text-green'">
{{ log.type === 'buy' ? '+' : '-' }}{{ getCurrencySymbol(portfolioData.currency) }}{{ (log.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</text>
</view>
<text class="tl-desc mt-1">{{ log.type === 'buy' ? '买入' : '卖出' }} 操作</text>
</view>
</view>
</view>
</view>
<view class="action-section fixed-bottom">
<u-button
class="btn-delete"
@click="deletePortfolio"
:customStyle="{
backgroundColor: '#FEF2F2',
color: '#DC2626',
fontWeight: '600',
borderRadius: '20rpx',
height: '80rpx',
fontSize: '28rpx',
width: '80rpx',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}"
>
<u-icon name="trash" size="20" color="#DC2626"></u-icon>
</u-button>
<u-button
class="btn-buy"
@click="handleBuy"
:customStyle="{
backgroundColor: '#064E3B',
color: '#FFFFFF',
fontWeight: '600',
borderRadius: '20rpx',
height: '80rpx',
fontSize: '28rpx',
flex: '1',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8rpx'
}"
>
<u-icon name="download" size="18" color="#FFFFFF"></u-icon>
<text>增加</text>
</u-button>
<u-button
class="btn-sell"
@click="handleSell"
:customStyle="{
backgroundColor: '#D1FAE5',
color: '#064E3B',
fontWeight: '600',
borderRadius: '20rpx',
height: '80rpx',
fontSize: '28rpx',
flex: '1',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8rpx'
}"
>
<u-icon name="upload" size="18" color="#064E3B"></u-icon>
<text>减少</text>
</u-button>
</view>
<!-- 交易表单弹窗 -->
<view v-if="showTransactionForm" class="transaction-modal" @click="showTransactionForm = false">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ transactionType === 'buy' ? '增加' : '减少' }}</text>
<view class="close-btn" @click="showTransactionForm = false">
<uni-icons type="close" size="20" color="#6B7280"></uni-icons>
</view>
</view>
<view class="form-content">
<view class="form-item">
<text class="form-label">{{ transactionType === 'sell' ? '选择持仓' : '股票代码' }}</text>
<view class="relative">
<input
v-model="transactionForm.stockCode"
class="stock-input"
:placeholder="transactionType === 'sell' ? '点击选择要卖出的持仓' : '请输入股票代码搜索'"
:disabled="transactionType === 'sell'"
@input="onStockInput"
@click="handleStockInputClick"
/>
<!-- 搜索下拉列表 -->
<view class="search-dropdown" v-if="searchResults.length > 0">
<view
class="dropdown-item"
v-for="(result, idx) in searchResults"
:key="idx"
@click="selectStock(result)"
>
<view class="item-left">
<text class="item-ticker">{{ result.ticker || result.stockCode }}</text>
<text class="item-name">{{ result.name || result.stockName }}</text>
</view>
<view class="item-right">
<text class="item-type" v-if="result.assetType">{{ result.assetType }}</text>
<text class="item-exchange">{{ result.exchange || '' }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="form-item">
<text class="form-label">数量{{ transactionType === 'sell' && maxSellAmount > 0 ? ` (最多可卖 ${maxSellAmount} 份)` : '' }}</text>
<input
v-model="transactionForm.amount"
type="number"
class="form-input"
:placeholder="transactionType === 'sell' && maxSellAmount > 0 ? `请输入数量,不超过 ${maxSellAmount}` : '请输入数量'"
/>
</view>
<view class="form-item">
<text class="form-label">价格</text>
<input
v-model="transactionForm.price"
type="digit"
class="form-input"
placeholder="请输入价格"
/>
</view>
<view class="form-item">
<text class="form-label">交易时间</text>
<picker mode="date" :value="transactionForm.transactionDate" @change="onDateChange">
<view class="form-select">
<text>{{ transactionForm.transactionDate || '请选择日期' }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<u-input
v-model="transactionForm.remark"
placeholder="请输入备注(可选)"
:border="false"
:customStyle="{ backgroundColor: '#F9FAFB', borderRadius: '16rpx', height: '80rpx', padding: '0 20rpx', border: '2rpx solid #E5E7EB' }"
/>
</view>
</view>
<view class="modal-footer">
<u-button
class="btn-cancel"
@click="showTransactionForm = false"
:customStyle="{
backgroundColor: '#FFFFFF',
color: '#6B7280',
fontWeight: '600',
borderRadius: '16rpx',
height: '80rpx',
fontSize: '28rpx',
flex: '1',
border: '2rpx solid #E5E7EB'
}"
>
取消
</u-button>
<u-button
class="btn-confirm"
@click="submitTransaction"
:customStyle="{
backgroundColor: '#064E3B',
color: '#FFFFFF',
fontWeight: '600',
borderRadius: '16rpx',
height: '80rpx',
fontSize: '28rpx',
flex: '1',
border: 'none'
}"
>
确认
</u-button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance, computed } from 'vue';
import { api } from '../../utils/api';
// 获取 u-toast 实例
const { proxy } = getCurrentInstance();
const uToastRef = ref();
// 加载状态
const loading = ref(true);
// 获取货币符号
const getCurrencySymbol = (currency) => {
const symbols = {
'CNY': '¥',
'USD': '$',
'HKD': 'HK$'
};
return symbols[currency] || '¥';
};
const portfolioId = ref('');
const portfolioData = ref({
id: '',
name: '',
currency: 'CNY',
status: '',
portfolioValue: 0,
totalReturn: 0,
todayProfit: 0,
todayProfitCurrency: 'CNY',
historicalChange: 0,
dailyVolatility: 0,
logicModel: '',
logicModelStatus: '',
logicModelDescription: '',
totalItems: 0,
totalRatio: 100,
strategy: null
});
const positions = ref([]);
const logs = ref([]);
// 净值历史相关
const navLoading = ref(false);
const navPeriod = ref('30d');
const navHistory = ref([]);
const navStatistics = ref(null);
// 图表数据
const chartData = computed(() => {
if (!navHistory.value || navHistory.value.length === 0) {
return { categories: [], series: [] };
}
return {
categories: navHistory.value.map(item => item.date.split('-').slice(1).join('/')),
series: [{
name: '净值',
data: navHistory.value.map(item => item.nav)
}]
};
});
// 图表配置
const chartOpts = ref({
color: ['#064E3B'],
padding: [15, 15, 0, 15],
enableScroll: false,
legend: {
show: false
},
xAxis: {
disableGrid: true,
axisLine: false,
fontSize: 10,
fontColor: '#6B7280'
},
yAxis: {
data: [{ min: 0 }],
gridColor: '#E5E7EB',
gridType: 'dash',
dashLength: 4,
fontSize: 10,
fontColor: '#6B7280'
},
extra: {
line: {
type: 'curve',
width: 2,
activeType: 'hollow'
}
}
});
// 交易表单
const showTransactionForm = ref(false);
const transactionType = ref('buy'); // buy 或 sell
// 获取当前日期格式化为YYYY-MM-DD
const getCurrentDate = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const transactionForm = ref({
stockCode: '',
amount: '',
price: '',
currency: '',
transactionDate: getCurrentDate(),
dateTimestamp: Date.now(),
remark: ''
});
const maxSellAmount = ref(0);
// 股票搜索相关
const searchResults = ref([]);
const searchTimer = ref(null);
const handleStockInputClick = () => {
if (transactionType.value === 'sell') {
searchResults.value = positions.value.map(pos => ({
ticker: pos.stockCode,
stockCode: pos.stockCode,
stockName: pos.stockName,
name: pos.stockName,
assetType: pos.assetType || 'Stock',
currency: pos.currency,
amount: pos.amount,
exchange: ''
}));
}
};
const onStockInput = (e) => {
const keyword = e.detail.value;
console.log('🔍 股票输入:', keyword);
searchStock(keyword);
};
const searchStock = async (keyword) => {
console.log('🔍 searchStock 调用:', keyword);
if (searchTimer.value) clearTimeout(searchTimer.value);
if (!keyword || keyword.length < 1) {
searchResults.value = [];
return;
}
searchTimer.value = setTimeout(async () => {
try {
console.log('📤 调用 api.ticker.search:', keyword);
const res = await api.ticker.search(keyword);
console.log('📥 搜索结果:', res);
if (res.code === 200) {
searchResults.value = res.data;
}
} catch (err) {
console.error('搜索股票失败:', err);
searchResults.value = [];
}
}, 300);
};
const selectStock = (result) => {
transactionForm.value.stockCode = result.ticker || result.Ticker || result.stockCode;
const currency = result.priceCurrency || result.currency;
if (currency) {
transactionForm.value.currency = currency;
}
if (transactionType.value === 'sell') {
const position = positions.value.find(pos => pos.stockCode === transactionForm.value.stockCode);
maxSellAmount.value = position ? position.amount : 0;
} else {
maxSellAmount.value = 0;
}
searchResults.value = [];
};
const fetchPortfolioData = async () => {
try {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const id = currentPage.options?.id;
if (!id) {
console.error('缺少投资组合ID');
return;
}
portfolioId.value = id;
const response = await api.assets.getPortfolio(id);
if (response.code === 200) {
portfolioData.value = response.data;
positions.value = response.data.positions || [];
console.log('投资组合数据获取成功:', response.data);
}
} catch (error) {
console.error('获取投资组合数据失败:', error);
proxy?.$refs.uToastRef?.show({
type: 'error',
message: '加载失败,请重试',
icon: 'error'
});
}
};
const fetchTransactions = async () => {
try {
if (!portfolioId.value) return;
const response = await api.assets.getTransactions({
portfolioId: portfolioId.value,
limit: 10,
offset: 0
});
if (response.code === 200) {
logs.value = response.data.items || [];
console.log('交易记录获取成功:', response.data);
}
} catch (error) {
console.error('获取交易记录失败:', error);
proxy?.$refs.uToastRef?.show({
type: 'error',
message: '加载交易记录失败',
icon: 'error'
});
}
};
// 获取净值历史
const fetchNavHistory = async () => {
if (!portfolioId.value) return;
navLoading.value = true;
try {
const today = new Date();
let startDate;
if (navPeriod.value === '7d') {
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
} else if (navPeriod.value === '30d') {
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
} else {
startDate = null; // 全部
}
const params = {};
if (startDate) {
params.startDate = startDate.toISOString().split('T')[0];
}
params.endDate = today.toISOString().split('T')[0];
const response = await api.assets.getNavHistory(portfolioId.value, params);
if (response.code === 200 && response.data) {
navHistory.value = response.data.navHistory || [];
navStatistics.value = response.data.statistics || null;
}
} catch (error) {
console.error('获取净值历史失败:', error);
} finally {
navLoading.value = false;
}
};
// 切换时间周期
const changeNavPeriod = (period) => {
navPeriod.value = period;
fetchNavHistory();
};
onMounted(async () => {
loading.value = true;
await fetchPortfolioData();
await fetchTransactions();
await fetchNavHistory();
loading.value = false;
});
const goStrategyConfig = () => {
if (portfolioData.value.strategy?.id) {
uni.navigateTo({ url: `/pages/strategies/edit/edit?id=${portfolioData.value.strategy.id}` });
}
};
const handleBuy = () => {
transactionType.value = 'buy';
resetTransactionForm();
transactionForm.value.currency = portfolioData.value.currency;
showTransactionForm.value = true;
};
const handleSell = () => {
transactionType.value = 'sell';
resetTransactionForm();
transactionForm.value.currency = portfolioData.value.currency;
showTransactionForm.value = true;
};
const resetTransactionForm = () => {
transactionForm.value = {
stockCode: '',
amount: '',
price: '',
currency: 'CNY',
transactionDate: getCurrentDate(),
dateTimestamp: Date.now(),
remark: ''
};
searchResults.value = [];
maxSellAmount.value = 0;
};
const onDateChange = (e) => {
// 原生 picker 直接返回日期字符串 YYYY-MM-DD
transactionForm.value.transactionDate = e.detail.value;
transactionForm.value.dateTimestamp = new Date(e.detail.value).getTime();
};
const submitTransaction = async () => {
// 表单验证
if (!transactionForm.value.stockCode) {
proxy?.$refs.uToastRef?.show({
type: 'warning',
message: transactionType.value === 'sell' ? '请选择要卖出的持仓' : '请输入股票代码',
icon: 'warning'
});
return;
}
const amount = parseFloat(transactionForm.value.amount);
if (!amount || amount <= 0) {
proxy?.$refs.uToastRef?.show({
type: 'warning',
message: '请输入有效的数量',
icon: 'warning'
});
return;
}
// 卖出时校验数量不超过持仓
if (transactionType.value === 'sell' && amount > maxSellAmount.value) {
proxy?.$refs.uToastRef?.show({
type: 'warning',
message: `卖出数量不能超过持仓数量 ${maxSellAmount.value}`,
icon: 'warning'
});
return;
}
if (!transactionForm.value.price || parseFloat(transactionForm.value.price) <= 0) {
proxy?.$refs.uToastRef?.show({
type: 'warning',
message: '请输入有效的价格',
icon: 'warning'
});
return;
}
const transactionData = {
portfolioId: portfolioId.value,
type: transactionType.value,
stockCode: transactionForm.value.stockCode,
amount: parseFloat(transactionForm.value.amount),
price: parseFloat(transactionForm.value.price),
currency: transactionForm.value.currency,
transactionDate: transactionForm.value.transactionDate,
remark: transactionForm.value.remark
};
uni.showLoading({ title: '提交中...', mask: true });
try {
const response = await api.assets.createTransaction(transactionData);
if (response.code === 200) {
uni.hideLoading();
proxy?.$refs.uToastRef?.show({
type: 'success',
message: '交易提交成功',
icon: 'success'
});
showTransactionForm.value = false;
// 重新获取交易记录
await fetchTransactions();
// 重新获取投资组合数据
await fetchPortfolioData();
}
} catch (error) {
console.error('创建交易失败:', error);
uni.hideLoading();
proxy?.$refs.uToastRef?.show({
type: 'error',
message: '提交失败,请重试',
icon: 'error'
});
}
};
// 删除组合
const deletePortfolio = async () => {
uni.showModal({
title: '确认删除',
content: '删除后所有持仓和交易记录都会丢失,确定要删除这个组合吗?',
confirmText: '删除',
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中', mask: true });
try {
// 调用删除组合接口
const response = await uni.request({
url: `${import.meta.env.VITE_API_BASE_URL || 'https://localhost:7040/'}api/v1/portfolio/${portfolioId.value}`,
method: 'DELETE',
header: {
'Authorization': `Bearer ${uni.getStorageSync('token')}`
}
});
if (response.statusCode === 200) {
uni.hideLoading();
proxy?.$refs.uToastRef?.show({
type: 'success',
message: '删除成功',
icon: 'success'
});
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1500);
} else {
throw new Error('删除失败');
}
} catch (error) {
uni.hideLoading();
proxy?.$refs.uToastRef?.show({
type: 'error',
message: '删除失败,请重试',
icon: 'error'
});
}
}
}
});
};
</script>
<style scoped>
/* 基础设置 */
.page-container {
min-height: 100vh;
background-color: #F9FAFB;
padding-bottom: 180rpx;
}
/* 骨架屏样式 */
.skeleton-card {
background-color: #064E3B;
border-radius: 40rpx;
padding: 40rpx 48rpx;
min-height: 320rpx;
}
.skeleton-row {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.skeleton-bottom {
justify-content: space-between;
margin-bottom: 0;
}
.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: 160rpx;
height: 28rpx;
}
.skeleton-badge {
width: 120rpx;
height: 40rpx;
border-radius: 20rpx;
}
.skeleton-big {
width: 350rpx;
height: 68rpx;
}
.skeleton-stat {
width: 150rpx;
height: 36rpx;
}
@keyframes skeleton-loading {
0% { background-position: 100% 50% }
100% { background-position: 0 50% }
}
/* 工具类 */
.flex-row { display: flex; flex-direction: row; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 8rpx; }
.gap-2 { gap: 16rpx; }
.align-right { align-items: flex-end; }
.pb-0 { padding-bottom: 0 !important; }
.mt-1 { margin-top: 8rpx; }
.flex-1 { flex: 1; }
/* 颜色工具 */
.text-red { color: #EF4444; }
.text-green { color: #10B981; }
.text-brand { color: #064E3B; }
.bg-blue-100 { background-color: #EFF6FF; }
.text-blue { color: #2563EB; }
.bg-orange-100 { background-color: #FFF7ED; }
.text-orange { color: #EA580C; }
.bg-green-100 { background-color: #ECFDF5; }
.bg-red { background-color: #EF4444; }
.bg-green { background-color: #10B981; }
.header-section { padding: 20rpx 32rpx; }
.asset-card {
background-color: #064E3B;
border-radius: 40rpx;
padding: 40rpx;
position: relative;
overflow: hidden;
box-shadow: 0 10rpx 30rpx rgba(6, 78, 59, 0.25);
color: #fff;
}
.card-watermark { position: absolute; right: -20rpx; top: -20rpx; opacity: 0.1; transform: rotate(15deg); }
.card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.label-text { font-size: 26rpx; opacity: 0.8; }
.status-badge { background-color: rgba(255,255,255,0.2); padding: 4rpx 16rpx; border-radius: 20rpx; }
.status-text { font-size: 22rpx; font-weight: 600; }
.card-main { display: flex; align-items: baseline; margin-bottom: 40rpx; }
.currency { font-size: 40rpx; font-weight: 700; margin-right: 8rpx; }
.big-number { font-size: 64rpx; font-weight: 800; font-family: 'DIN Alternate'; }
.card-bottom { display: flex; justify-content: space-between; }
.stat-item { display: flex; flex-direction: column; gap: 8rpx; }
.stat-label { font-size: 24rpx; opacity: 0.7; }
.stat-val { font-size: 32rpx; font-weight: 700; font-family: 'DIN Alternate'; }
/* 策略信息卡片 */
.strategy-info-card {
border-radius: 24rpx;
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.st-left { display: flex; align-items: center; gap: 20rpx; }
.st-icon-box {
width: 80rpx; height: 80rpx;
border-radius: 20rpx;
display: flex; align-items: center; justify-content: center;
}
.st-icon-text { font-size: 36rpx; font-weight: 800; }
.st-name { font-size: 28rpx; font-weight: 700; color: #1F2937; }
.st-tag {
font-size: 20rpx; color: #6B7280;
background-color: #F3F4F6; padding: 2rpx 10rpx; border-radius: 8rpx;
}
.st-status-text { font-size: 24rpx; font-weight: 600; color: #059669; }
.status-dot {
width: 12rpx; height: 12rpx; border-radius: 50%;
background-color: #10B981;
}
.pulsing { animation: pulse 2s infinite; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
70% { box-shadow: 0 0 0 10rpx rgba(16, 185, 129, 0); }
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
/* 通用容器 */
.section-container { padding: 20rpx 32rpx; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
.section-title { font-size: 30rpx; font-weight: 800; color: #1F2937; border-left: 8rpx solid #064E3B; padding-left: 16rpx; line-height: 1; }
.section-sub { font-size: 24rpx; color: #9CA3AF; margin-right: 4rpx; }
/* 持仓卡片 */
.position-card {
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.stock-icon { width: 80rpx; height: 80rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; }
.icon-char { font-size: 32rpx; font-weight: 800; }
.stock-name { font-size: 30rpx; font-weight: 700; color: #1F2937; }
.stock-code { font-size: 24rpx; color: #9CA3AF; margin-top: 4rpx; }
.market-val { font-size: 30rpx; font-weight: 700; color: #1F2937; }
.price-text { font-size: 26rpx; color: #1F2937; }
.cost-text { font-size: 24rpx; color: #6B7280; }
.weight-tag { font-size: 22rpx; color: #6B7280; background-color: #F3F4F6; padding: 2rpx 8rpx; border-radius: 6rpx; }
.pnl-val { font-size: 26rpx; font-weight: 700; }
.pnl-rate { font-size: 22rpx; opacity: 0.9; }
/* 交易明细 */
.timeline-box { padding: 0 16rpx; }
.timeline-item { display: flex; margin-bottom: 0; min-height: 120rpx; }
.tl-left { width: 120rpx; text-align: right; padding-right: 24rpx; display: flex; flex-direction: column; }
.tl-date { font-size: 26rpx; font-weight: 600; color: #374151; }
.tl-time { font-size: 22rpx; color: #9CA3AF; margin-top: 4rpx; }
.tl-line { width: 40rpx; display: flex; flex-direction: column; align-items: center; position: relative; }
.tl-dot { width: 16rpx; height: 16rpx; border-radius: 50%; z-index: 2; margin-top: 10rpx; }
.tl-dash { width: 2rpx; flex: 1; background-color: #E5E7EB; margin-top: 8rpx; }
.tl-right { flex: 1; padding-left: 24rpx; padding-bottom: 40rpx; }
.tl-title { font-size: 28rpx; font-weight: 600; color: #1F2937; }
.tl-amount { font-size: 26rpx; font-weight: 700; }
.tl-desc { font-size: 24rpx; color: #6B7280; margin-top: 8rpx; }
.w-full { width: 100%; }
.fixed-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #FFFFFF;
display: flex;
gap: 24rpx;
padding: 20rpx 32rpx 50rpx 32rpx;
box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.05);
z-index: 999;
}
/* .btn-delete 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
.btn-delete {
/* 样式已内联设置 */
}
/* .btn-buy 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
.btn-buy {
/* 样式已内联设置 */
}
/* .btn-sell 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
.btn-sell {
/* 样式已内联设置 */
}
.transaction-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #E5E7EB;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.close-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.form-content {
padding: 32rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-label {
display: block;
font-size: 24rpx;
font-weight: 500;
color: #374151;
margin-bottom: 12rpx;
}
.stock-input {
width: 100%;
height: 80rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 16rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #1F2937;
box-sizing: border-box;
}
.form-input {
width: 100%;
height: 80rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 16rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #1F2937;
box-sizing: border-box;
}
.form-select {
width: 100%;
height: 80rpx;
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 16rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
color: #1F2937;
}
.modal-footer {
padding: 32rpx;
border-top: 1rpx solid #E5E7EB;
display: flex;
gap: 16rpx;
}
/* .btn-cancel 和 .btn-confirm 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
.btn-cancel,
.btn-confirm {
/* 样式已内联设置 */
}
.relative { position: relative; }
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: #FFFFFF;
border: 1rpx solid #E5E7EB;
border-radius: 12rpx;
margin-top: 4rpx;
max-height: 300rpx;
overflow-y: auto;
z-index: 100;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.dropdown-item {
padding: 16rpx 20rpx;
border-bottom: 1rpx solid #F3F4F6;
display: flex;
justify-content: space-between;
align-items: center;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:active {
background-color: #F3F4F6;
}
.item-left {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.item-ticker {
font-size: 26rpx;
font-weight: 600;
color: #1F2937;
}
.item-name {
font-size: 22rpx;
color: #6B7280;
}
.item-right {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.item-type {
font-size: 18rpx;
color: #064E3B;
background-color: #D1FAE5;
padding: 2rpx 8rpx;
border-radius: 4rpx;
}
.item-exchange {
font-size: 22rpx;
color: #9CA3AF;
}
/* 收益曲线样式 */
.nav-chart-container {
background-color: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
min-height: 400rpx;
}
.nav-loading,
.nav-empty {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
}
.loading-text,
.empty-text {
font-size: 28rpx;
color: #9CA3AF;
}
.nav-chart-wrapper {
position: relative;
width: 100%;
height: 400rpx;
}
.period-tabs {
display: flex;
gap: 16rpx;
}
.period-tab {
font-size: 24rpx;
color: #6B7280;
padding: 8rpx 20rpx;
border-radius: 20rpx;
background-color: #F3F4F6;
}
.period-tab.active {
color: #FFFFFF;
background-color: #064E3B;
}
.nav-stats {
display: flex;
justify-content: space-between;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #E5E7EB;
}
.nav-stats .stat-item {
text-align: center;
}
.nav-stats .stat-label {
display: block;
font-size: 22rpx;
color: #6B7280;
margin-bottom: 8rpx;
}
.nav-stats .stat-val {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.nav-stats .text-red {
color: #EF4444;
}
.nav-stats .text-green {
color: #10B981;
}
</style>