AssetManager.UniApp/pages/strategies/edit/edit.vue
claw_bot fa2fa98985 feat: TypeScript 迁移完成
- 新增 tsconfig.json 配置
- 新增 types/ 目录(7个类型定义文件,与后端 DTO 对齐)
- 迁移 vite.config.js → vite.config.ts
- 迁移 main.js → main.ts
- 迁移 utils/api.js → utils/api.ts(泛型化请求封装)
- 迁移 utils/currency.js → utils/currency.ts
- 迁移 6 个 Vue 页面组件(添加 lang="ts" 和类型注解)
- 新增 TYPESCRIPT_MIGRATION.md 迁移计划文档
- 更新 todo.md 进度

收益:完整类型提示、编译时错误检查、重构安全性提升
2026-03-24 05:53:29 +00:00

645 lines
25 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-title">选择逻辑模型</view>
<scroll-view scroll-x class="strategy-scroll" :show-scrollbar="false">
<view class="strategy-row">
<view
v-for="(item, index) in strategyTypes"
:key="index"
class="strategy-card"
:class="{ 'active': currentType === item.key }"
@click="selectType(item.key)"
>
<view class="icon-circle" :class="currentType === item.key ? 'bg-white text-green' : item.bgClass">
<uni-icons :type="item.icon" size="24" :color="currentType === item.key ? '#064E3B' : item.iconColor"></uni-icons>
</view>
<text class="st-name" :class="{ 'text-white': currentType === item.key }">{{ item.name }}</text>
<text class="st-tag" :class="{ 'text-green-light': currentType === item.key }">{{ item.tag }}</text>
<view class="check-mark" v-if="currentType === item.key">
<uni-icons type="checkmarkempty" size="16" color="#064E3B"></uni-icons>
</view>
</view>
</view>
</scroll-view>
<view class="desc-box" v-if="currentStrategyInfo">
<view class="desc-header">
<uni-icons type="info-filled" size="18" color="#064E3B"></uni-icons>
<text class="desc-title">策略原理</text>
</view>
<text class="desc-content">{{ currentStrategyInfo.description }}</text>
</view>
<view class="config-section">
<view class="section-title">参数配置</view>
<view class="form-card">
<view class="form-item">
<text class="label">策略名称</text>
<input
v-model="formData.name"
class="form-input"
placeholder="例如: 双均线趋势策略"
/>
</view>
<view class="form-item">
<text class="label">策略描述</text>
<input
v-model="formData.description"
class="form-input"
placeholder="描述策略的用途和特点"
/>
</view>
<view class="form-item">
<text class="label">风险等级</text>
<picker :range="['low', 'medium', 'high']" @change="onRiskLevelChange">
<view class="picker-display">
<text>{{ formData.riskLevel || 'medium' }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">标签</text>
<input
v-model="formData.tags"
class="form-input"
placeholder="用逗号分隔,如:趋势,均线"
/>
</view>
<template v-if="currentType === 'ma_trend'">
<view class="flex-row gap-3">
<view class="form-item flex-1">
<text class="label">短期周期</text>
<input
v-model="formData.shortPeriod"
type="number"
class="form-input"
placeholder="20"
/>
</view>
<view class="form-item flex-1">
<text class="label">长期周期</text>
<input
v-model="formData.longPeriod"
type="number"
class="form-input"
placeholder="60"
/>
</view>
</view>
<view class="form-item">
<text class="label">均线类型</text>
<picker :range="['SMA', 'EMA']" @change="onMaTypeChange">
<view class="picker-display">
<text>{{ formData.maType || 'SMA' }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
<view class="info-tag bg-blue-50">
<text class="text-blue-700 text-xs">规则:短期均线上穿长期均线买入,下穿卖出。</text>
</view>
</template>
<template v-if="currentType === 'risk_parity'">
<view class="form-item">
<text class="label">回看周期</text>
<input
v-model="formData.lookbackPeriod"
type="number"
class="form-input"
placeholder="60"
/>
</view>
<view class="form-item">
<text class="label">再平衡阈值</text>
<input
v-model="formData.rebalanceThreshold"
type="digit"
class="form-input"
placeholder="0.05"
/>
</view>
<view class="form-item">
<text class="label">资产配置</text>
<view class="assets-list">
<view class="asset-item" v-for="(asset, index) in formData.assets" :key="index">
<view class="asset-header">
<text class="asset-title">资产 #{{ index + 1 }}</text>
<uni-icons type="trash" size="18" color="#EF4444" @click="removeAsset(index)" v-if="formData.assets.length > 1"></uni-icons>
</view>
<view class="asset-inputs">
<view class="asset-input relative">
<text class="asset-label">代码</text>
<input
v-model="asset.symbol"
class="stock-input"
placeholder="如 AAPL"
@input="(e) => onStockInput(e, index)"
/>
<view class="search-dropdown" v-if="searchResults.length > 0 && activeAssetIndex === index">
<view
class="dropdown-item"
v-for="(result, idx) in searchResults.filter(r => r.assetIndex === 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="asset-input">
<text class="asset-label">目标权重</text>
<input
v-model="asset.targetWeight"
type="digit"
class="form-input-sm"
placeholder="0.6"
/>
</view>
</view>
</view>
<view class="add-asset-btn" @click="addAsset">
<uni-icons type="plus" size="16" color="#064E3B"></uni-icons>
<text class="add-asset-text">添加资产</text>
</view>
</view>
</view>
<view class="info-tag bg-green-50">
<text class="text-green-700 text-xs">当资产权重偏离目标超过此阈值时触发再平衡。</text>
</view>
</template>
<template v-if="currentType === 'chandelier_exit'">
<view class="form-item">
<text class="label">ATR 周期</text>
<input
v-model="formData.period"
type="number"
class="form-input"
placeholder="22"
/>
</view>
<view class="form-item">
<text class="label">ATR 倍数</text>
<input
v-model="formData.multiplier"
type="digit"
class="form-input"
placeholder="3.0"
/>
</view>
<view class="form-item">
<text class="label">使用收盘价</text>
<picker :range="['是', '否']" @change="onUseCloseChange">
<view class="picker-display">
<text>{{ formData.useClose ? '是' : '否' }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
<view class="info-tag bg-orange-50">
<text class="text-orange-700 text-xs">动态止损策略,随着价格上涨止损点上移。</text>
</view>
</template>
</view>
</view>
<view class="footer-bar">
<view class="btn-row" v-if="isEditMode">
<button class="btn-delete" @click="deleteStrategy">删除策略</button>
<button class="btn-save" @click="submit">更新策略配置</button>
</view>
<button v-else class="btn-save btn-full" @click="submit">保存策略配置</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { api } from '@/utils/api';
// 类型定义
interface StrategyTypeItem {
key: string;
name: string;
tag: string;
icon: string;
bgClass: string;
iconColor: string;
description: string;
}
interface AssetItem {
symbol: string;
targetWeight: string;
}
interface FormData {
name: string;
description: string;
riskLevel: string;
tags: string;
maType: string;
shortPeriod: string;
longPeriod: string;
lookbackPeriod: string;
rebalanceThreshold: string;
period: string;
multiplier: string;
useClose: boolean;
assets: AssetItem[];
}
interface SearchResult {
ticker: string;
name?: string;
exchange?: string;
assetType?: string;
assetIndex: number;
}
// 状态
const isEditMode = ref<boolean>(false);
const strategyId = ref<string>('');
const currentType = ref<string>('ma_trend');
const strategyTypes: StrategyTypeItem[] = [
{
key: 'ma_trend',
name: '双均线策略',
tag: '趋势跟踪',
icon: 'navigate-filled',
bgClass: 'bg-blue-100',
iconColor: '#2563EB',
description: '经典技术分析策略,通过短期和长期移动平均线的金叉死叉信号捕捉价格趋势。金叉买入,死叉卖出,过滤短期噪音。'
},
{
key: 'risk_parity',
name: '风险平价策略',
tag: '资产配置',
icon: 'pie-chart-filled',
bgClass: 'bg-green-100',
iconColor: '#059669',
description: '为投资组合中的每个资产设定固定的目标权重比例通过定期再平衡将资产配置恢复到目标权重。适合长期投资和多元化资产配置如60/40组合。'
},
{
key: 'chandelier_exit',
name: '吊灯止损策略',
tag: '风险控制',
icon: 'fire-filled',
bgClass: 'bg-orange-100',
iconColor: '#EA580C',
description: '结合移动平均线和ATR平均真实波幅生成的动态止损点。随着价格上涨止损点上移价格回撤触及止损点时离场有效锁住利润。'
}
];
const formData = ref<FormData>({
name: '',
description: '',
riskLevel: 'medium',
tags: '',
maType: 'SMA',
shortPeriod: '',
longPeriod: '',
lookbackPeriod: '',
rebalanceThreshold: '',
period: '',
multiplier: '',
useClose: false,
assets: [{ symbol: '', targetWeight: '' }]
});
const searchResults = ref<SearchResult[]>([]);
const activeAssetIndex = ref<number>(-1);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
const onStockInput = (e: { detail: { value: string } }, assetIndex: number): void => {
searchStock(e.detail.value, assetIndex);
};
const searchStock = async (keyword: string, assetIndex: number): Promise<void> => {
if (searchTimer) clearTimeout(searchTimer);
activeAssetIndex.value = assetIndex;
if (!keyword || keyword.length < 1) {
searchResults.value = [];
activeAssetIndex.value = -1;
return;
}
searchTimer = setTimeout(async () => {
try {
const res = await api.ticker.search(keyword);
if (res.code === 200) {
searchResults.value = res.data.map((item: any) => ({ ...item, assetIndex }));
activeAssetIndex.value = assetIndex;
}
} catch (err) {
searchResults.value = [];
activeAssetIndex.value = -1;
}
}, 300);
};
const selectStock = (result: SearchResult): void => {
const asset = formData.value.assets[result.assetIndex];
if (asset) asset.symbol = result.ticker;
searchResults.value = [];
activeAssetIndex.value = -1;
};
const currentStrategyInfo = computed((): StrategyTypeItem | undefined => {
return strategyTypes.find(item => item.key === currentType.value);
});
const selectType = (key: string): void => {
currentType.value = key;
};
const onMaTypeChange = (e: { detail: { value: number } }): void => {
formData.value.maType = ['SMA', 'EMA'][e.detail.value];
};
const onUseCloseChange = (e: { detail: { value: number } }): void => {
formData.value.useClose = e.detail.value === 0;
};
const onRiskLevelChange = (e: { detail: { value: number } }): void => {
formData.value.riskLevel = ['low', 'medium', 'high'][e.detail.value];
};
const addAsset = (): void => {
formData.value.assets.push({ symbol: '', targetWeight: '' });
};
const removeAsset = (index: number): void => {
formData.value.assets.splice(index, 1);
};
const validateRiskParityAssets = (): boolean => {
const assets = formData.value.assets;
if (assets.length < 2) {
uni.showToast({ title: '风险平价策略至少需要2个资产', icon: 'none' });
return false;
}
let totalWeight = 0;
for (const asset of assets) {
if (!asset.symbol || !asset.targetWeight) {
uni.showToast({ title: '请填写所有资产信息', icon: 'none' });
return false;
}
totalWeight += parseFloat(asset.targetWeight);
}
if (Math.abs(totalWeight - 1) > 0.01) {
uni.showToast({ title: `资产权重总和必须为100%,当前为${(totalWeight * 100).toFixed(0)}%`, icon: 'none' });
return false;
}
return true;
};
const submit = async (): Promise<void> => {
if (!formData.value.name) {
uni.showToast({ title: '请输入策略名称', icon: 'none' });
return;
}
const tags = formData.value.tags
? formData.value.tags.split(',').map(t => t.trim()).filter(t => t)
: currentStrategyInfo.value ? [currentStrategyInfo.value.tag] : [];
const parameters: Record<string, any> = {};
switch (currentType.value) {
case 'ma_trend':
if (!formData.value.shortPeriod || !formData.value.longPeriod) {
uni.showToast({ title: '请输入周期参数', icon: 'none' });
return;
}
parameters.maType = formData.value.maType;
parameters.shortPeriod = parseInt(formData.value.shortPeriod);
parameters.longPeriod = parseInt(formData.value.longPeriod);
break;
case 'risk_parity':
if (!formData.value.lookbackPeriod || !formData.value.rebalanceThreshold) {
uni.showToast({ title: '请输入参数', icon: 'none' });
return;
}
if (!validateRiskParityAssets()) return;
parameters.lookbackPeriod = parseInt(formData.value.lookbackPeriod);
parameters.rebalanceThreshold = parseFloat(formData.value.rebalanceThreshold);
parameters.assets = formData.value.assets.map(a => ({
symbol: a.symbol,
targetWeight: parseFloat(a.targetWeight)
}));
break;
case 'chandelier_exit':
if (!formData.value.period || !formData.value.multiplier) {
uni.showToast({ title: '请输入参数', icon: 'none' });
return;
}
parameters.period = parseInt(formData.value.period);
parameters.multiplier = parseFloat(formData.value.multiplier);
parameters.useClose = formData.value.useClose;
break;
}
uni.showLoading({ title: isEditMode.value ? '更新中' : '保存中' });
try {
if (isEditMode.value) {
await api.strategies.updateStrategy(strategyId.value, {
name: formData.value.name,
type: currentType.value,
description: formData.value.description || currentStrategyInfo.value?.description,
riskLevel: formData.value.riskLevel,
tags,
parameters
});
} else {
await api.strategies.createStrategy({
name: formData.value.name,
type: currentType.value,
description: formData.value.description || currentStrategyInfo.value?.description,
riskLevel: formData.value.riskLevel,
tags,
parameters
});
}
uni.hideLoading();
uni.showToast({ title: isEditMode.value ? '策略已更新' : '策略已保存', icon: 'success' });
setTimeout(() => uni.navigateBack(), 1500);
} catch (error) {
uni.hideLoading();
uni.showToast({ title: '操作失败,请重试', icon: 'none' });
}
};
const loadStrategyDetail = async (id: string): Promise<void> => {
try {
uni.showLoading({ title: '加载中' });
const response = await api.strategies.getStrategy(id);
uni.hideLoading();
if (response.code === 200) {
const data = response.data;
isEditMode.value = true;
strategyId.value = data.id || '';
currentType.value = data.type || 'ma_trend';
formData.value.name = data.name || '';
formData.value.description = data.description || '';
formData.value.riskLevel = data.riskLevel || 'medium';
formData.value.tags = data.tags?.join(', ') || '';
let params: Record<string, any> = {};
if ((data as any).config) {
try {
let config = typeof (data as any).config === 'string' ? JSON.parse((data as any).config) : (data as any).config;
if (typeof config === 'string') config = JSON.parse(config);
params = config;
} catch { params = {}; }
} else if ((data as any).parameters) {
params = (data as any).parameters;
}
switch (data.type) {
case 'ma_trend':
formData.value.maType = params.maType || 'SMA';
formData.value.shortPeriod = params.shortPeriod?.toString() || '';
formData.value.longPeriod = params.longPeriod?.toString() || '';
break;
case 'risk_parity':
formData.value.lookbackPeriod = params.lookbackPeriod?.toString() || '';
formData.value.rebalanceThreshold = params.rebalanceThreshold?.toString() || '';
if (params.assets?.length) {
formData.value.assets = params.assets.map((a: any) => ({
symbol: a.symbol || '',
targetWeight: a.targetWeight != null ? String(a.targetWeight) : ''
}));
}
break;
case 'chandelier_exit':
formData.value.period = params.period?.toString() || '';
formData.value.multiplier = params.multiplier?.toString() || '';
formData.value.useClose = params.useClose || false;
break;
}
}
} catch {
uni.hideLoading();
uni.showToast({ title: '加载失败', icon: 'none' });
}
};
const deleteStrategy = async (): Promise<void> => {
uni.showModal({
title: '确认删除',
content: '删除后无法恢复,确定要删除这个策略吗?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中' });
try {
await api.strategies.deleteStrategy(strategyId.value);
uni.hideLoading();
uni.showToast({ title: '删除成功', icon: 'success' });
setTimeout(() => uni.navigateBack(), 1500);
} catch {
uni.hideLoading();
uni.showToast({ title: '删除失败', icon: 'none' });
}
}
}
});
};
onMounted(() => {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1] as any;
if (currentPage.options?.id) loadStrategyDetail(currentPage.options.id);
});
</script>
<style scoped>
.page-container { min-height: 100vh; background-color: #F9FAFB; padding-bottom: 200rpx; }
.section-title { padding: 32rpx 32rpx 20rpx; font-size: 28rpx; font-weight: 700; color: #374151; }
.strategy-scroll { white-space: nowrap; width: 100%; padding-bottom: 20rpx; }
.strategy-row { display: flex; padding: 0 32rpx; gap: 24rpx; }
.strategy-card { width: 280rpx; height: 320rpx; background-color: #FFFFFF; border-radius: 32rpx; padding: 32rpx; display: inline-flex; flex-direction: column; justify-content: center; align-items: center; border: 2rpx solid transparent; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03); position: relative; transition: all 0.2s; }
.strategy-card.active { background-color: #064E3B; border-color: #064E3B; transform: translateY(-4rpx); box-shadow: 0 12rpx 24rpx rgba(6, 78, 59, 0.2); }
.icon-circle { width: 96rpx; height: 96rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 24rpx; }
.bg-green-100 { background-color: #ECFDF5; }
.bg-blue-100 { background-color: #EFF6FF; }
.bg-orange-100 { background-color: #FFF7ED; }
.bg-white { background-color: #FFFFFF; }
.st-name { font-size: 30rpx; font-weight: 700; color: #1F2937; margin-bottom: 8rpx; white-space: normal; text-align: center; }
.st-tag { font-size: 22rpx; color: #9CA3AF; }
.text-white { color: #fff !important; }
.text-green { color: #064E3B !important; }
.text-green-light { color: rgba(255,255,255,0.7) !important; }
.check-mark { position: absolute; top: 16rpx; right: 16rpx; width: 40rpx; height: 40rpx; background-color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.desc-box { margin: 10rpx 32rpx 30rpx; background-color: #ECFDF5; padding: 24rpx; border-radius: 20rpx; border: 1rpx solid #D1FAE5; }
.desc-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
.desc-title { font-size: 26rpx; font-weight: 700; color: #064E3B; }
.desc-content { font-size: 24rpx; color: #047857; line-height: 1.6; text-align: justify; }
.config-section { margin-top: 20rpx; }
.form-card { background-color: #fff; margin: 0 32rpx; padding: 32rpx; border-radius: 32rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.02); }
.form-item { margin-bottom: 32rpx; }
.label { font-size: 26rpx; font-weight: 600; color: #374151; margin-bottom: 16rpx; display: block; }
.picker-display { background-color: #F9FAFB; border: 2rpx solid #E5E7EB; border-radius: 20rpx; height: 88rpx; padding: 0 24rpx; display: flex; align-items: center; justify-content: space-between; font-size: 28rpx; color: #1F2937; }
.flex-row { display: flex; flex-direction: row; }
.flex-1 { flex: 1; }
.gap-3 { gap: 24rpx; }
.info-tag { padding: 16rpx; border-radius: 12rpx; margin-top: -10rpx; }
.bg-blue-50 { background-color: #EFF6FF; }
.bg-green-50 { background-color: #ECFDF5; }
.bg-orange-50 { background-color: #FFF7ED; }
.text-blue-700 { color: #1D4ED8; }
.text-green-700 { color: #047857; }
.text-orange-700 { color: #C2410C; }
.text-xs { font-size: 22rpx; }
.assets-list { margin-top: 16rpx; }
.asset-item { background-color: #F9FAFB; border-radius: 16rpx; padding: 20rpx; margin-bottom: 16rpx; }
.asset-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }
.asset-title { font-size: 24rpx; font-weight: 600; color: #374151; }
.asset-inputs { display: flex; gap: 16rpx; }
.asset-input { flex: 1; }
.asset-label { font-size: 22rpx; color: #6B7280; margin-bottom: 8rpx; display: block; }
.add-asset-btn { display: flex; align-items: center; justify-content: center; gap: 8rpx; background-color: #ECFDF5; border: 2rpx dashed #10B981; border-radius: 16rpx; padding: 20rpx; margin-top: 8rpx; }
.add-asset-btn:active { background-color: #D1FAFA; }
.add-asset-text { font-size: 24rpx; color: #064E3B; font-weight: 600; }
.stock-input { width: 100%; height: 72rpx; background-color: #F9FAFB; border: 2rpx solid #E5E7EB; border-radius: 16rpx; padding: 0 20rpx; font-size: 26rpx; color: #1F2937; box-sizing: border-box; }
.form-input { width: 100%; height: 88rpx; background-color: #F9FAFB; border: 2rpx solid #E5E7EB; border-radius: 20rpx; padding: 0 24rpx; font-size: 28rpx; color: #1F2937; box-sizing: border-box; }
.form-input-sm { width: 100%; height: 72rpx; background-color: #F9FAFB; border: 2rpx solid #E5E7EB; border-radius: 16rpx; padding: 0 20rpx; font-size: 26rpx; color: #1F2937; box-sizing: border-box; }
.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; }
.footer-bar { position: fixed; bottom: 0; left: 0; right: 0; background-color: #fff; padding: 20rpx 32rpx 50rpx 32rpx; box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.05); z-index: 99; }
.btn-row { display: flex; gap: 16rpx; }
.btn-save { flex: 1; background-color: #064E3B; color: #fff; font-weight: 700; border-radius: 24rpx; height: 96rpx; font-size: 30rpx; border: none; display: flex; align-items: center; justify-content: center; }
.btn-save::after { border: none; }
.btn-save:active { opacity: 0.9; }
.btn-full { width: 100%; }
.btn-delete { flex: 0 0 180rpx; background-color: #FEE2E2; color: #DC2626; font-weight: 700; border-radius: 24rpx; height: 96rpx; font-size: 24rpx; border: none; display: flex; align-items: center; justify-content: center; }
.btn-delete::after { border: none; }
.btn-delete:active { background-color: #FECACA; }
</style>