AssetManager.UniApp/pages/config/config.vue
claw_bot e7a3f1d26c fix: 微信小程序兼容性调整
- config.vue: picker 组件使用条件编译,H5/APP使用u-picker,微信小程序使用原生picker
- detail.vue: 交易表单input使用条件编译,H5/APP使用u-input,微信小程序使用原生input
- 保留u-button和u-toast在所有平台使用
2026-03-13 08:01:27 +00:00

829 lines
21 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>
<!-- #ifdef H5 || APP-PLUS -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<picker @change="onStrategyChange" :value="strategyIndex" :range="strategies" range-key="name">
<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>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
<!-- #endif -->
<text class="helper-text" v-if="selectedStrategy">{{ selectedStrategy.desc }}</text>
</view>
<view class="form-item">
<text class="label">组合币种</text>
<!-- #ifdef H5 || APP-PLUS -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<picker @change="onCurrencyChange" :value="currencyIndex" :range="currencyList" range-key="name">
<view class="picker-box" @click="showCurrencyPicker = true">
<text class="picker-text">{{ currencyList[currencyIndex].name }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
<!-- #endif -->
<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);
// 小程序原生 picker 事件处理
const onStrategyChange = (e) => {
strategyIndex.value = e.detail.value;
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 onCurrencyChange = (e) => {
currencyIndex.value = e.detail.value;
showCurrencyPicker.value = 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>