AssetManager.UniApp/pages/config/config.vue
claw_bot a9f3692aa8 feat: 升级UI组件为uview-plus
- config.vue: 替换原生picker为u-picker,统一toast为u-toast,按钮为u-button
- detail.vue: 替换交易表单input为u-input,所有按钮为u-button,toast为u-toast
- 清理已替换组件的CSS样式
- 添加todo.md记录后续升级计划
2026-03-13 07:43:16 +00:00

781 lines
19 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="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>
<u-input
v-model="form.name"
placeholder="给你的组合起个名字 (如: 养老定投)"
:border="false"
:customStyle="{ backgroundColor: '#F9FAFB', borderRadius: '20rpx', height: '96rpx', padding: '0 32rpx' }"
/>
</view>
<view class="form-item">
<text class="label">选择逻辑模板</text>
<u-picker
:show="showStrategyPicker"
:columns="[strategies.map(s => s.name)]"
keyName="name"
@confirm="onStrategyPickerConfirm"
@cancel="showStrategyPicker = false"
>
<view class="picker-box" @click="showStrategyPicker = true">
<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>
<u-icon name="arrow-down" size="14" color="#9CA3AF"></u-icon>
</view>
</u-picker>
<text class="helper-text" v-if="selectedStrategy">{{ selectedStrategy.desc }}</text>
</view>
<view class="form-item">
<text class="label">组合币种</text>
<u-picker
:show="showCurrencyPicker"
:columns="[currencyList.map(c => c.name)]"
keyName="name"
@confirm="onCurrencyPickerConfirm"
@cancel="showCurrencyPicker = false"
>
<view class="picker-box" @click="showCurrencyPicker = true">
<text class="picker-text">{{ currencyList[currencyIndex].name }}</text>
<u-icon name="arrow-down" size="14" color="#9CA3AF"></u-icon>
</view>
</u-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>
<u-input
v-model="item.name"
placeholder="如 TMF"
:disabled="selectedStrategy?.type === 'risk_parity'"
:border="false"
:customStyle="{ backgroundColor: '#FFFFFF', borderRadius: '16rpx', height: '72rpx', padding: '0 20rpx' }"
@input="(e) => searchStock(e, 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>
<u-input
v-model="item.price"
type="number"
placeholder="0.00"
:border="false"
:customStyle="{ backgroundColor: '#FFFFFF', borderRadius: '16rpx', height: '72rpx', padding: '0 20rpx' }"
/>
</view>
<view class="grid-col">
<text class="sub-label">持有数量</text>
<u-input
v-model="item.amount"
type="number"
placeholder="0"
:border="false"
:customStyle="{ backgroundColor: '#FFFFFF', borderRadius: '16rpx', height: '72rpx', padding: '0 20rpx' }"
/>
</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>
<u-icon name="calendar" size="16" color="#9CA3AF"></u-icon>
</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>
<u-button
class="btn-submit"
@click="submitForm"
:customStyle="{
backgroundColor: '#064E3B',
color: '#fff',
fontWeight: '700',
borderRadius: '24rpx',
height: '96rpx',
fontSize: '30rpx',
width: '100%',
border: 'none'
}"
>
创建组合
</u-button>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { api } from '../../utils/api';
// 获取 u-toast 实例
const { proxy } = getCurrentInstance();
const uToastRef = ref();
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
// Picker 控制变量
const showStrategyPicker = ref(false);
const showCurrencyPicker = ref(false);
// 防止重复请求的标志
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;
};
// u-picker 确认方法
const onStrategyPickerConfirm = (e) => {
const { value, index } = e;
strategyIndex.value = index[0];
showStrategyPicker.value = false;
// 调用原有的策略变更逻辑
const strategy = strategies.value[strategyIndex.value];
if (strategy && strategy.parameters && strategy.parameters.assets) {
form.value.stocks = strategy.parameters.assets.map(asset => ({
name: asset.symbol,
price: '',
amount: '',
date: ''
}));
} else {
form.value.stocks = [{ name: '', price: '', amount: '', date: '' }];
}
};
const onCurrencyPickerConfirm = (e) => {
const { value, index } = e;
currencyIndex.value = index[0];
showCurrencyPicker.value = false;
};
const submitForm = async () => {
if (!form.value.name) {
proxy?.$refs.uToastRef?.show({
type: 'warning',
message: '请输入组合名称',
icon: 'warning'
});
return;
}
if (strategyIndex.value === -1) {
proxy?.$refs.uToastRef?.show({
type: 'warning',
message: '请选择策略',
icon: 'warning'
});
return;
}
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) {
proxy?.$refs.uToastRef?.show({
type: 'error',
message: `${stock.name} 权重偏差超过5%,目标${(target.targetWeight*100).toFixed(0)}%,实际${(actualWeight*100).toFixed(0)}%`,
duration: 3000
});
return;
}
}
}
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();
proxy?.$refs.uToastRef?.show({
type: 'success',
message: '创建成功',
icon: 'success'
});
setTimeout(() => uni.navigateBack(), 1500);
}
} catch (error) {
console.error('创建投资组合失败:', error);
uni.hideLoading();
proxy?.$refs.uToastRef?.show({
type: 'error',
message: '创建失败,请重试',
icon: 'error'
});
}
};
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';
}
/* .btn-submit 样式已通过 u-button 的 customStyle 设置,此处保留空类避免冲突 */
.btn-submit {
/* 样式已内联设置 */
}
</style>