AssetManager.UniApp/pages/config/config.vue
claw_bot 99094eeed8 feat(P4): 首页添加骨架屏,加载时显示占位动画
- 资产卡片区域:深色骨架屏匹配原有卡片风格
- 持仓列表区域:模拟卡片布局的骨架占位
- 添加 loading 状态控制,数据加载完成后切换
- 骨架屏带渐变动画效果,提升用户体验
2026-03-13 03:02:33 +00:00

692 lines
17 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">
<view class="section-card">
<view class="card-header">
<view class="header-icon bg-emerald-100">
<uni-icons type="settings" size="18" color="#064E3B"></uni-icons>
</view>
<text class="header-title">基础设置</text>
</view>
<view class="form-item">
<text class="label">组合名称</text>
<input class="input-box" type="text" v-model="form.name" placeholder="给你的组合起个名字 (如: 养老定投)"
placeholder-class="placeholder-style" />
</view>
<view class="form-item">
<text class="label">选择逻辑模板</text>
<picker @change="onStrategyChange" :value="strategyIndex" :range="strategies" range-key="name">
<view class="picker-box">
<view class="flex-row items-center gap-2" v-if="selectedStrategy">
<view class="strategy-dot" :style="{ backgroundColor: selectedStrategy.color }"></view>
<text class="picker-text">{{ selectedStrategy.name }}</text>
</view>
<text class="picker-placeholder" v-else>点击选择逻辑规则</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
<text class="helper-text" v-if="selectedStrategy">{{ selectedStrategy.desc }}</text>
</view>
<view class="form-item">
<text class="label">组合币种</text>
<picker @change="onCurrencyChange" :value="currencyIndex" :range="currencyList" range-key="name">
<view class="picker-box">
<text class="picker-text">{{ currencyList[currencyIndex].name }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
<text class="helper-text">创建后币种不可修改,所有交易只能使用该币种</text>
</view>
</view>
<view class="section-card">
<view class="card-header">
<view class="flex-row items-center gap-2">
<view class="header-icon bg-blue-100">
<uni-icons type="wallet" size="18" color="#1D4ED8"></uni-icons>
</view>
<text class="header-title">初始化记录</text>
</view>
</view>
<view class="stock-list">
<view class="stock-item" v-for="(item, index) in form.stocks" :key="index">
<view class="item-header">
<text class="item-index">单元 #{{ index + 1 }}</text>
</view>
<view class="item-grid">
<view class="grid-col relative">
<text class="sub-label">单元名称/代码</text>
<input
class="mini-input"
v-model="item.name"
placeholder="如 TMF"
:disabled="selectedStrategy?.type === 'risk_parity'"
@input="(e) => searchStock(e.detail.value, index)"
@focus="() => { activeSearchIndex.value = -1; searchResults.value = []; }"
/>
<!-- 搜索下拉列表 -->
<view class="search-dropdown" v-if="searchResults.length > 0 && activeSearchIndex.value === index">
<view
class="dropdown-item"
v-for="(result, idx) in searchResults.filter(r => r.stockIndex === index)"
:key="idx"
@click="selectStock(result)"
>
<view class="item-left">
<text class="item-ticker">{{ result.ticker }}</text>
<text class="item-type" v-if="result.assetType">{{ result.assetType }}</text>
</view>
<text class="item-exchange">{{ result.exchange }}</text>
</view>
</view>
</view>
<view class="grid-col">
<text class="sub-label">买入均价</text>
<input class="mini-input" type="digit" v-model="item.price" placeholder="0.00" />
</view>
<view class="grid-col">
<text class="sub-label">持有数量</text>
<input class="mini-input" type="number" v-model="item.amount" placeholder="0" />
</view>
</view>
<view class="date-row">
<text class="sub-label">建仓日期</text>
<picker mode="date" :value="item.date" @change="(e) => onDateChange(e, index)">
<view class="date-picker-display">
<text>{{ item.date || '请选择日期' }}</text>
<uni-icons type="calendar" size="16" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
</view>
</view>
</view>
<view class="footer-area">
<view class="total-summary">
<text class="summary-label">预计初始投入</text>
<text class="summary-val">¥ {{ totalInvestment }}</text>
</view>
<button class="submit-btn" @click="submitForm">创建组合</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { api } from '../../utils/api';
const strategies = ref([]);
const strategyIndex = ref(-1);
// 币种选择
const currencyList = ref([
{ name: '人民币 CNY', code: 'CNY' },
{ name: '美元 USD', code: 'USD' },
{ name: '港币 HKD', code: 'HKD' }
]);
const currencyIndex = ref(0); // 默认CNY
// 防止重复请求的标志
let isFetching = false;
const form = ref({
name: '',
stocks: [
{ name: '', price: '', amount: '', date: '' }
]
});
// 股票搜索相关
const searchResults = ref([]);
const activeSearchIndex = ref(-1);
const searchTimer = ref(null);
const searchStock = async (keyword, stockIndex) => {
// 防抖
if (searchTimer.value) clearTimeout(searchTimer.value);
// 赋值当前激活的搜索下标
activeSearchIndex.value = stockIndex;
if (!keyword || keyword.length < 1) {
searchResults.value = [];
activeSearchIndex.value = -1;
return;
}
searchTimer.value = setTimeout(async () => {
try {
const res = await api.ticker.search(keyword);
if (res.code === 200) {
searchResults.value = res.data.map(item => ({
...item,
stockIndex: stockIndex
}));
}
} catch (err) {
console.error('搜索股票失败:', err);
searchResults.value = [];
activeSearchIndex.value = -1;
}
}, 300);
};
const selectStock = (result) => {
const stock = form.value.stocks[result.stockIndex];
if (stock) {
stock.name = result.ticker;
// 可以在这里额外保存资产类型等信息
stock.assetType = result.assetType;
stock.currency = result.priceCurrency;
}
searchResults.value = [];
activeSearchIndex.value = -1;
};
const selectedStrategy = computed(() => {
if (strategyIndex.value === -1) return null;
return strategies.value[strategyIndex.value];
});
const totalInvestment = computed(() => {
let total = 0;
form.value.stocks.forEach(stock => {
const p = parseFloat(stock.price) || 0;
const a = parseFloat(stock.amount) || 0;
total += p * a;
});
return total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
});
const fetchStrategies = async () => {
try {
const response = await api.strategies.getStrategies();
if (response.code === 200) {
strategies.value = response.data.map(item => {
let parameters = {};
if (item.config) {
try {
let config = JSON.parse(item.config);
// 如果解析后还是字符串,再解析一次(处理双层转义)
if (typeof config === 'string') {
config = JSON.parse(config);
}
parameters = config;
} catch (e) {
console.error('解析策略配置失败:', e);
parameters = {};
}
}
return {
id: item.id,
name: item.name,
desc: item.description,
type: item.type,
parameters: parameters,
color: '#10B981'
};
});
}
} catch (error) {
console.error('获取策略列表失败:', error);
}
};
const onStrategyChange = (e) => {
strategyIndex.value = e.detail.value;
const strategy = strategies.value[strategyIndex.value];
console.log('选择的策略:', strategy);
console.log('策略参数:', strategy?.parameters);
if (strategy && strategy.parameters && strategy.parameters.assets) {
console.log('找到assets开始填充:', strategy.parameters.assets);
form.value.stocks = strategy.parameters.assets.map(asset => ({
name: asset.symbol,
price: '',
amount: '',
date: ''
}));
console.log('填充后的form.stocks:', form.value.stocks);
} else {
console.log('条件判断失败:', {
hasStrategy: !!strategy,
hasParameters: !!(strategy && strategy.parameters),
hasAssets: !!(strategy && strategy.parameters && strategy.parameters.assets)
});
form.value.stocks = [{ name: '', price: '', amount: '', date: '' }];
console.log('未找到标的配置,使用默认值');
}
};
const onDateChange = (e, index) => {
form.value.stocks[index].date = e.detail.value;
};
const onCurrencyChange = (e) => {
currencyIndex.value = e.detail.value;
};
const submitForm = async () => {
if (!form.value.name) return uni.showToast({ title: '请输入组合名称', icon: 'none' });
if (strategyIndex.value === -1) return uni.showToast({ title: '请选择策略', icon: 'none' });
const selected = strategies.value[strategyIndex.value];
// 风险平价策略权重校验
if (selected.type === 'risk_parity' && selected.parameters?.assets) {
let totalWeight = 0;
const targetAssets = selected.parameters.assets;
for (let i = 0; i < form.value.stocks.length; i++) {
const stock = form.value.stocks[i];
const target = targetAssets.find(a => a.symbol === stock.name);
if (!target) continue;
// 计算实际持仓市值 = 价格 * 数量
const marketValue = (parseFloat(stock.price) || 0) * (parseFloat(stock.amount) || 0);
totalWeight += marketValue;
}
// 校验每个标的的实际权重和目标权重偏差不超过5%
for (let i = 0; i < form.value.stocks.length; i++) {
const stock = form.value.stocks[i];
const target = targetAssets.find(a => a.symbol === stock.name);
if (!target) continue;
const marketValue = (parseFloat(stock.price) || 0) * (parseFloat(stock.amount) || 0);
const actualWeight = totalWeight > 0 ? marketValue / totalWeight : 0;
const deviation = Math.abs(actualWeight - target.targetWeight);
if (deviation > 0.05) {
return uni.showToast({
title: `${stock.name} 权重偏差超过5%,目标${(target.targetWeight*100).toFixed(0)}%,实际${(actualWeight*100).toFixed(0)}%`,
icon: 'none',
duration: 3000
});
}
}
}
const selectedCurrency = currencyList.value[currencyIndex.value].code;
const requestData = {
name: form.value.name,
strategyId: selected.id,
currency: selectedCurrency,
stocks: form.value.stocks.map(stock => ({
name: stock.name,
code: stock.name,
price: parseFloat(stock.price) || 0,
amount: parseFloat(stock.amount) || 0,
date: stock.date,
currency: selectedCurrency
}))
};
uni.showLoading({ title: '创建中...' });
try {
const response = await api.assets.createPortfolio(requestData);
if (response.code === 200) {
uni.hideLoading();
uni.showToast({ title: '创建成功', icon: 'success' });
setTimeout(() => uni.navigateBack(), 1500);
}
} catch (error) {
console.error('创建投资组合失败:', error);
uni.hideLoading();
uni.showToast({ title: '创建失败,请重试', icon: 'none' });
}
};
onShow(async () => {
isFetching = true;
await fetchStrategies();
isFetching = false;
});
</script>
<style scoped>
/* 通用布局 */
.page-container {
min-height: 100vh;
background-color: #F3F4F6;
padding-bottom: 200rpx;
/* 给底部按钮留空 */
}
.flex-row {
display: flex;
flex-direction: row;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: 16rpx;
}
/* 导航栏 */
.nav-bar {
background-color: #fff;
padding: var(--status-bar-height) 32rpx 20rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
box-sizing: content-box;
}
.page-title {
font-size: 34rpx;
font-weight: 700;
color: #111827;
}
/* 卡片容器 */
.section-card {
background-color: #fff;
margin: 32rpx;
padding: 32rpx;
border-radius: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.03);
}
/* 卡片标题头 */
.card-header {
display: flex;
align-items: center;
margin-bottom: 32rpx;
}
.header-icon {
width: 60rpx;
height: 60rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.bg-emerald-100 {
background-color: #D1FAE5;
}
.bg-blue-100 {
background-color: #DBEAFE;
}
.header-title {
font-size: 30rpx;
font-weight: 700;
color: #1F2937;
}
/* 表单项 */
.form-item {
margin-bottom: 32rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.label {
font-size: 26rpx;
font-weight: 600;
color: #374151;
margin-bottom: 16rpx;
display: block;
}
.input-box {
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 20rpx;
height: 96rpx;
padding: 0 32rpx;
font-size: 28rpx;
color: #1F2937;
}
.placeholder-style {
color: #9CA3AF;
}
/* 选择器样式 */
.picker-box {
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 20rpx;
height: 96rpx;
padding: 0 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.picker-text {
font-size: 28rpx;
color: #1F2937;
font-weight: 500;
}
.picker-placeholder {
font-size: 28rpx;
color: #9CA3AF;
}
.strategy-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.helper-text {
font-size: 22rpx;
color: #6B7280;
margin-top: 12rpx;
display: block;
margin-left: 8rpx;
}
/* --- 动态持仓列表 --- */
.stock-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.stock-item {
background-color: #F9FAFB;
border: 1rpx solid #E5E7EB;
border-radius: 24rpx;
padding: 24rpx;
}
.item-header {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
}
.item-index {
font-size: 24rpx;
font-weight: 700;
color: #9CA3AF;
}
/* 网格输入布局 */
.item-grid {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
}
.grid-col {
flex: 1;
}
.sub-label {
font-size: 20rpx;
color: #6B7280;
margin-bottom: 8rpx;
display: block;
}
.mini-input {
background-color: #FFFFFF;
border: 1rpx solid #E5E7EB;
border-radius: 16rpx;
height: 72rpx;
padding: 0 20rpx;
font-size: 26rpx;
color: #1F2937;
}
.mini-input[disabled] {
background-color: #F3F4F6;
color: #6B7280;
cursor: not-allowed;
}
/* 搜索下拉列表 */
.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: row;
align-items: center;
gap: 12rpx;
}
.item-ticker {
font-size: 26rpx;
font-weight: 600;
color: #1F2937;
}
.item-type {
font-size: 18rpx;
color: #064E3B;
background-color: #D1FAE5;
padding: 2rpx 8rpx;
border-radius: 4rpx;
}
.item-exchange {
font-size: 22rpx;
color: #9CA3AF;
}
/* 日期选择 */
.date-picker-display {
background-color: #FFFFFF;
border: 1rpx solid #E5E7EB;
border-radius: 16rpx;
height: 72rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
color: #1F2937;
}
/* 底部固定区 */
.footer-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 32rpx 50rpx 32rpx;
/* 适配 iPhone X */
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 20rpx;
}
.total-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10rpx;
}
.summary-label {
font-size: 26rpx;
color: #6B7280;
}
.summary-val {
font-size: 36rpx;
font-weight: 700;
color: #064E3B;
font-family: 'DIN Alternate';
}
.submit-btn {
background-color: #064E3B;
color: #fff;
font-weight: 700;
border-radius: 24rpx;
height: 96rpx;
line-height: 96rpx;
font-size: 30rpx;
width: 100%;
}
.submit-btn:active {
opacity: 0.9;
}
</style>