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 进度 收益:完整类型提示、编译时错误检查、重构安全性提升
This commit is contained in:
parent
16f3a492e1
commit
fa2fa98985
258
TYPESCRIPT_MIGRATION.md
Normal file
258
TYPESCRIPT_MIGRATION.md
Normal file
@ -0,0 +1,258 @@
|
||||
# AssetManager UniApp TypeScript 迁移计划
|
||||
|
||||
## 项目现状
|
||||
|
||||
| 类型 | 文件数 | 代码量 |
|
||||
|-----|-------|--------|
|
||||
| Vue 页面 | 6 | ~3,768 行 |
|
||||
| JS 工具 | 2 | ~350 行 |
|
||||
| 配置文件 | 3 | ~100 行 |
|
||||
|
||||
**迁移目标:** JavaScript → TypeScript,获得类型安全 + IDE 智能提示
|
||||
|
||||
---
|
||||
|
||||
## Phase 0:基础设施(0.5 天)
|
||||
|
||||
### 0.1 安装依赖
|
||||
```bash
|
||||
npm install -D typescript @types/node
|
||||
npm install -D @uni-helper/vite-plugin-uni-types
|
||||
```
|
||||
|
||||
### 0.2 配置文件
|
||||
- [ ] 创建 `tsconfig.json`
|
||||
- [ ] 创建 `src/types/` 目录结构
|
||||
- [ ] 修改 `vite.config.js` → `vite.config.ts`
|
||||
- [ ] 修改 `main.js` → `main.ts`
|
||||
|
||||
### 0.3 目录结构
|
||||
```
|
||||
src/
|
||||
├── types/
|
||||
│ ├── api.ts # API 响应类型
|
||||
│ ├── portfolio.ts # 组合/持仓类型
|
||||
│ ├── strategy.ts # 策略类型
|
||||
│ └── global.d.ts # 全局类型声明
|
||||
├── utils/
|
||||
│ ├── api.ts # 迁移自 api.js
|
||||
│ └── currency.ts # 迁移自 currency.js
|
||||
└── pages/
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:类型定义(1 天)
|
||||
|
||||
### 1.1 核心类型(与后端 DTO 对齐)
|
||||
|
||||
```typescript
|
||||
// types/portfolio.ts
|
||||
export interface Portfolio {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
strategyId?: string;
|
||||
status: 'active' | 'archived';
|
||||
totalValue: number;
|
||||
totalCost: number;
|
||||
returnRate: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: string;
|
||||
portfolioId: string;
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
assetType: 'Stock' | 'Crypto' | 'Fund';
|
||||
shares: number;
|
||||
averageCost: number;
|
||||
currentPrice: number;
|
||||
marketValue: number;
|
||||
profitLoss: number;
|
||||
profitLossRate: number;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
portfolioId: string;
|
||||
type: 'buy' | 'sell' | 'dividend';
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
shares: number;
|
||||
price: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
transactionAt: string;
|
||||
}
|
||||
|
||||
// types/api.ts
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface AssetData {
|
||||
totalValue: number;
|
||||
todayProfit: number;
|
||||
todayProfitCurrency: string;
|
||||
totalReturnRate: number;
|
||||
}
|
||||
|
||||
// types/strategy.ts
|
||||
export interface Strategy {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'RiskParity' | 'MaTrend' | 'ChandelierExit';
|
||||
config: RiskParityConfig | MaTrendConfig | ChandelierExitConfig;
|
||||
status: 'active' | 'paused';
|
||||
}
|
||||
|
||||
export interface StrategySignal {
|
||||
strategyType: string;
|
||||
signal: 'BUY' | 'SELL' | 'HOLD' | 'REBALANCE';
|
||||
reason: string;
|
||||
positionSignals?: PositionSignal[];
|
||||
generatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 API 类型定义
|
||||
- [ ] `types/api.ts` — 请求/响应类型
|
||||
- [ ] `types/portfolio.ts` — 组合、持仓、交易
|
||||
- [ ] `types/strategy.ts` — 策略配置、信号
|
||||
- [ ] `types/user.ts` — 用户信息
|
||||
- [ ] `types/global.d.ts` — uni-app 全局类型扩展
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:工具函数迁移(0.5 天)
|
||||
|
||||
### 2.1 currency.js → currency.ts
|
||||
```typescript
|
||||
// utils/currency.ts
|
||||
export type CurrencyCode = 'CNY' | 'USD' | 'HKD' | 'EUR' | 'GBP' | 'JPY' | 'SGD' | 'AUD';
|
||||
|
||||
export function getCurrencySymbol(currency?: string): string { ... }
|
||||
export function formatAmount(amount: number, currency?: CurrencyCode, decimals?: number): string { ... }
|
||||
export function formatAmountWithSign(amount: number, currency?: CurrencyCode, decimals?: number): string { ... }
|
||||
```
|
||||
|
||||
### 2.2 api.js → api.ts
|
||||
- [ ] 定义请求配置类型
|
||||
- [ ] 泛型化 `get<T>()`、`post<T>()`、`put<T>()`、`del<T>()`
|
||||
- [ ] API 返回类型标注
|
||||
- [ ] 移除 console.log(或改为可配置日志级别)
|
||||
|
||||
### 2.3 迁移顺序
|
||||
1. `currency.js` → `currency.ts`(简单,无依赖)
|
||||
2. `api.js` → `api.ts`(依赖 Phase 1 的类型定义)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3:页面组件迁移(2-3 天)
|
||||
|
||||
### 迁移优先级
|
||||
|
||||
| 优先级 | 页面 | 行数 | 原因 |
|
||||
|-------|------|------|------|
|
||||
| 1 | `me.vue` | 259 | 最简单,练手 |
|
||||
| 2 | `strategies/strategies.vue` | 315 | 结构简单 |
|
||||
| 3 | `strategies/edit/edit.vue` | 997 | 表单,类型收益大 |
|
||||
| 4 | `config/config.vue` | 775 | 表单,类型收益大 |
|
||||
| 5 | `detail/detail.vue` | 1731 | 最复杂,逻辑最多 |
|
||||
| 6 | `index/index.vue` | 688 | 主页,依赖最多 |
|
||||
|
||||
### 迁移步骤(每个页面)
|
||||
1. 添加 `lang="ts"` 到 `<script>`
|
||||
2. 定义 `interface` 替代 `data()` 中的隐式类型
|
||||
3. Props 定义 → `defineProps<T>()`
|
||||
4. Emits 定义 → `defineEmits<T>()`
|
||||
5. 函数参数/返回值类型标注
|
||||
6. 修复类型错误
|
||||
7. 运行验证
|
||||
|
||||
### 3.1 示例:me.vue 迁移
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { api } from '@/utils/api';
|
||||
import type { UserStats } from '@/types/user';
|
||||
|
||||
const loading = ref(true);
|
||||
const userStats = ref<UserStats | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.user.getUserStats();
|
||||
userStats.value = res.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4:清理与验证(0.5 天)
|
||||
|
||||
- [ ] 删除原 `.js` 文件
|
||||
- [ ] 更新 `manifest.json` 配置
|
||||
- [ ] 微信开发者工具验证
|
||||
- [ ] 真机调试验证
|
||||
- [ ] 更新 `todo.md` 和 `OPTIMIZATION.md`
|
||||
|
||||
---
|
||||
|
||||
## 时间估算
|
||||
|
||||
| Phase | 内容 | 工时 |
|
||||
|-------|------|------|
|
||||
| 0 | 基础设施 | 0.5 天 |
|
||||
| 1 | 类型定义 | 1 天 |
|
||||
| 2 | 工具函数 | 0.5 天 |
|
||||
| 3 | 页面迁移 | 2-3 天 |
|
||||
| 4 | 清理验证 | 0.5 天 |
|
||||
| **总计** | | **4-5 天** |
|
||||
|
||||
---
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|-----|---------|
|
||||
| uview-plus 类型不全 | 遇到缺失时手写 `.d.ts` 补充 |
|
||||
| uni-app API 类型 | 使用 `@types/uni-app` 或手写扩展 |
|
||||
| 微信小程序兼容性 | 每个页面迁移后立即验证 |
|
||||
| 后端 API 字段变更 | 类型定义与后端 DTO 保持同步 |
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
Day 1: Phase 0 + Phase 1(基础设施 + 类型定义)
|
||||
Day 2: Phase 2 + Phase 3.1-3.2(工具函数 + 简单页面)
|
||||
Day 3: Phase 3.3-3.4(中等复杂页面)
|
||||
Day 4: Phase 3.5-3.6(复杂页面)
|
||||
Day 5: Phase 4(清理验证 + buffer)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 所有 `.js` 文件迁移为 `.ts`
|
||||
- [ ] 所有 Vue 页面添加 `lang="ts"`
|
||||
- [ ] `tsc --noEmit` 无错误
|
||||
- [ ] 微信小程序真机运行正常
|
||||
- [ ] IDE 类型提示正常工作
|
||||
|
||||
---
|
||||
|
||||
*创建时间:2026-03-24*
|
||||
18
main.js → main.ts
Executable file → Normal file
18
main.js → main.ts
Executable file → Normal file
@ -1,19 +1,6 @@
|
||||
import App from './App'
|
||||
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
Vue.config.productionTip = false
|
||||
App.mpType = 'app'
|
||||
const app = new Vue({
|
||||
...App
|
||||
})
|
||||
app.$mount()
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3
|
||||
import App from './App.vue'
|
||||
import { createSSRApp } from 'vue'
|
||||
import api from './utils/api'
|
||||
import api from '@/utils/api'
|
||||
import uviewPlus from '@/uni_modules/uview-plus'
|
||||
|
||||
console.log('🚀 应用启动,导入api模块')
|
||||
@ -33,4 +20,3 @@ export function createApp() {
|
||||
app
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
35
package-lock.json
generated
Executable file → Normal file
35
package-lock.json
generated
Executable file → Normal file
@ -6,8 +6,43 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"uview-plus": "^3.7.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uview-plus": {
|
||||
"version": "3.7.13",
|
||||
"resolved": "https://registry.npmjs.org/uview-plus/-/uview-plus-3.7.13.tgz",
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"uview-plus": "^3.7.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<!-- uView Toast 组件 -->
|
||||
<u-toast ref="uToastRef" />
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
@ -78,7 +77,6 @@
|
||||
|
||||
<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>
|
||||
@ -92,10 +90,9 @@
|
||||
:disabled="selectedStrategy?.type === 'risk_parity'"
|
||||
class="native-input-sm"
|
||||
@input="(e) => searchStock(e.detail.value, index)"
|
||||
@focus="() => { activeSearchIndex.value = -1; searchResults.value = []; }"
|
||||
@focus="() => { activeSearchIndex = -1; searchResults = []; }"
|
||||
/>
|
||||
<!-- 搜索下拉列表 -->
|
||||
<view class="search-dropdown" v-if="searchResults.length > 0 && activeSearchIndex.value === index">
|
||||
<view class="search-dropdown" v-if="searchResults.length > 0 && activeSearchIndex === index">
|
||||
<view
|
||||
class="dropdown-item"
|
||||
v-for="(result, idx) in searchResults.filter(r => r.stockIndex === index)"
|
||||
@ -112,21 +109,11 @@
|
||||
</view>
|
||||
<view class="grid-col">
|
||||
<text class="sub-label">买入均价</text>
|
||||
<input
|
||||
v-model="item.price"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
class="native-input-sm"
|
||||
/>
|
||||
<input v-model="item.price" type="number" placeholder="0.00" class="native-input-sm" />
|
||||
</view>
|
||||
<view class="grid-col">
|
||||
<text class="sub-label">持有数量</text>
|
||||
<input
|
||||
v-model="item.amount"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
class="native-input-sm"
|
||||
/>
|
||||
<input v-model="item.amount" type="number" placeholder="0" class="native-input-sm" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -139,7 +126,6 @@
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -162,44 +148,152 @@
|
||||
width: '100%',
|
||||
border: 'none'
|
||||
}"
|
||||
>
|
||||
创建组合
|
||||
</u-button>
|
||||
>创建组合</u-button>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, getCurrentInstance } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, getCurrentInstance } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { api } from '../../utils/api';
|
||||
import { api } from '@/utils/api';
|
||||
|
||||
// 获取 u-toast 实例
|
||||
const { proxy } = getCurrentInstance();
|
||||
// 类型定义
|
||||
interface StockItem {
|
||||
name: string;
|
||||
price: string;
|
||||
amount: string;
|
||||
date: string;
|
||||
assetType?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
interface StrategyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
type: string;
|
||||
parameters: Record<string, any>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CurrencyItem {
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
ticker: string;
|
||||
name?: string;
|
||||
exchange?: string;
|
||||
assetType?: string;
|
||||
stockIndex: number;
|
||||
}
|
||||
|
||||
const { proxy } = getCurrentInstance()!;
|
||||
const uToastRef = ref();
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(true);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
const strategies = ref([]);
|
||||
const strategyIndex = ref(-1);
|
||||
// 币种选择
|
||||
const currencyList = ref([
|
||||
const strategies = ref<StrategyItem[]>([]);
|
||||
const strategyIndex = ref<number>(-1);
|
||||
|
||||
const currencyList = ref<CurrencyItem[]>([
|
||||
{ name: '人民币 CNY', code: 'CNY' },
|
||||
{ name: '美元 USD', code: 'USD' },
|
||||
{ name: '港币 HKD', code: 'HKD' }
|
||||
]);
|
||||
const currencyIndex = ref(0); // 默认CNY
|
||||
const currencyIndex = ref<number>(0);
|
||||
|
||||
// 小程序原生 picker 事件处理
|
||||
const onStrategyChange = (e) => {
|
||||
strategyIndex.value = e.detail.value;
|
||||
let isFetching = false;
|
||||
|
||||
const form = ref<{ name: string; stocks: StockItem[] }>({
|
||||
name: '',
|
||||
stocks: [{ name: '', price: '', amount: '', date: '' }]
|
||||
});
|
||||
|
||||
const searchResults = ref<SearchResult[]>([]);
|
||||
const activeSearchIndex = ref<number>(-1);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const searchStock = async (keyword: string, stockIndex: number): Promise<void> => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
activeSearchIndex.value = stockIndex;
|
||||
|
||||
// 调用原有的策略变更逻辑
|
||||
if (!keyword || keyword.length < 1) {
|
||||
searchResults.value = [];
|
||||
activeSearchIndex.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, stockIndex }));
|
||||
}
|
||||
} catch {
|
||||
searchResults.value = [];
|
||||
activeSearchIndex.value = -1;
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const selectStock = (result: SearchResult): void => {
|
||||
const stock = form.value.stocks[result.stockIndex];
|
||||
if (stock) {
|
||||
stock.name = result.ticker;
|
||||
stock.assetType = result.assetType;
|
||||
}
|
||||
searchResults.value = [];
|
||||
activeSearchIndex.value = -1;
|
||||
};
|
||||
|
||||
const selectedStrategy = computed((): StrategyItem | null => {
|
||||
return strategyIndex.value === -1 ? null : strategies.value[strategyIndex.value];
|
||||
});
|
||||
|
||||
const totalInvestment = computed((): string => {
|
||||
let total = 0;
|
||||
form.value.stocks.forEach(stock => {
|
||||
total += (parseFloat(stock.price) || 0) * (parseFloat(stock.amount) || 0);
|
||||
});
|
||||
return total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
});
|
||||
|
||||
const fetchStrategies = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await api.strategies.getStrategies();
|
||||
if (response.code === 200) {
|
||||
strategies.value = response.data.items?.map((item: any) => {
|
||||
let parameters: Record<string, any> = {};
|
||||
if (item.config) {
|
||||
try {
|
||||
let config = JSON.parse(item.config);
|
||||
if (typeof config === 'string') config = JSON.parse(config);
|
||||
parameters = config;
|
||||
} catch { parameters = {}; }
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
desc: item.description,
|
||||
type: item.type,
|
||||
parameters,
|
||||
color: '#10B981'
|
||||
};
|
||||
}) || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取策略列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onStrategyChange = (e: { detail: { value: number } }): void => {
|
||||
strategyIndex.value = e.detail.value;
|
||||
const strategy = strategies.value[strategyIndex.value];
|
||||
if (strategy && strategy.parameters && strategy.parameters.assets) {
|
||||
form.value.stocks = strategy.parameters.assets.map(asset => ({
|
||||
if (strategy?.parameters?.assets) {
|
||||
form.value.stocks = strategy.parameters.assets.map((asset: any) => ({
|
||||
name: asset.symbol,
|
||||
price: '',
|
||||
amount: '',
|
||||
@ -210,167 +304,48 @@ const onStrategyChange = (e) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onCurrencyChange = (e) => {
|
||||
const onCurrencyChange = (e: { detail: { value: number } }): void => {
|
||||
currencyIndex.value = e.detail.value;
|
||||
};
|
||||
|
||||
// 防止重复请求的标志
|
||||
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 onDateChange = (e, index) => {
|
||||
const onDateChange = (e: { detail: { value: string } }, index: number): void => {
|
||||
form.value.stocks[index].date = e.detail.value;
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
const submitForm = async (): Promise<void> => {
|
||||
if (!form.value.name) {
|
||||
proxy?.$refs.uToastRef?.show({
|
||||
type: 'warning',
|
||||
message: '请输入组合名称',
|
||||
icon: 'warning'
|
||||
});
|
||||
(proxy as any)?.$refs?.uToastRef?.show({ type: 'warning', message: '请输入组合名称' });
|
||||
return;
|
||||
}
|
||||
if (strategyIndex.value === -1) {
|
||||
proxy?.$refs.uToastRef?.show({
|
||||
type: 'warning',
|
||||
message: '请选择策略',
|
||||
icon: 'warning'
|
||||
});
|
||||
(proxy as any)?.$refs?.uToastRef?.show({ type: 'warning', message: '请选择策略' });
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = strategies.value[strategyIndex.value];
|
||||
|
||||
// 风险平价策略权重校验
|
||||
if (selected.type === 'risk_parity' && selected.parameters?.assets) {
|
||||
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);
|
||||
for (const stock of form.value.stocks) {
|
||||
const target = targetAssets.find((a: any) => 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);
|
||||
for (const stock of form.value.stocks) {
|
||||
const target = targetAssets.find((a: any) => 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({
|
||||
(proxy as any)?.$refs?.uToastRef?.show({
|
||||
type: 'error',
|
||||
message: `${stock.name} 权重偏差超过5%,目标${(target.targetWeight*100).toFixed(0)}%,实际${(actualWeight*100).toFixed(0)}%`,
|
||||
message: `${stock.name} 权重偏差超过5%`,
|
||||
duration: 3000
|
||||
});
|
||||
return;
|
||||
@ -381,7 +356,7 @@ const submitForm = async () => {
|
||||
const selectedCurrency = currencyList.value[currencyIndex.value].code;
|
||||
const requestData = {
|
||||
name: form.value.name,
|
||||
strategyId: selected.id,
|
||||
strategyId: selected?.id,
|
||||
currency: selectedCurrency,
|
||||
stocks: form.value.stocks.map(stock => ({
|
||||
name: stock.name,
|
||||
@ -399,21 +374,12 @@ const submitForm = async () => {
|
||||
const response = await api.assets.createPortfolio(requestData);
|
||||
if (response.code === 200) {
|
||||
uni.hideLoading();
|
||||
proxy?.$refs.uToastRef?.show({
|
||||
type: 'success',
|
||||
message: '创建成功',
|
||||
icon: 'success'
|
||||
});
|
||||
(proxy as any)?.$refs?.uToastRef?.show({ type: 'success', message: '创建成功' });
|
||||
setTimeout(() => uni.navigateBack(), 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建投资组合失败:', error);
|
||||
} catch {
|
||||
uni.hideLoading();
|
||||
proxy?.$refs.uToastRef?.show({
|
||||
type: 'error',
|
||||
message: '创建失败,请重试',
|
||||
icon: 'error'
|
||||
});
|
||||
(proxy as any)?.$refs?.uToastRef?.show({ type: 'error', message: '创建失败,请重试' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -427,350 +393,53 @@ onShow(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 通用布局 */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #F3F4F6;
|
||||
padding-bottom: 200rpx;
|
||||
}
|
||||
|
||||
/* 骨架屏样式 */
|
||||
.skeleton-form-item {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-label {
|
||||
width: 120rpx;
|
||||
height: 26rpx;
|
||||
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-loading 1.8s ease infinite;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-label-sm {
|
||||
width: 80rpx;
|
||||
height: 22rpx;
|
||||
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-loading 1.8s ease infinite;
|
||||
border-radius: 6rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-input {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
background: linear-gradient(90deg, #F9FAFB 25%, #F3F4F6 37%, #F9FAFB 50%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-loading 1.8s ease infinite;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-input-sm {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
background: linear-gradient(90deg, #F9FAFB 25%, #F3F4F6 37%, #F9FAFB 50%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-loading 1.8s ease infinite;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 100% 50% }
|
||||
100% { background-position: 0 50% }
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* 卡片容器 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 选择器样式 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 搜索下拉列表 */
|
||||
.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 {
|
||||
/* 样式已内联设置 */
|
||||
}
|
||||
|
||||
/* 原生 input 样式 */
|
||||
.native-input {
|
||||
background-color: #F9FAFB;
|
||||
border-radius: 20rpx;
|
||||
height: 96rpx;
|
||||
padding: 0 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.native-input-sm {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 16rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #1F2937;
|
||||
border: 1rpx solid #E5E7EB;
|
||||
}
|
||||
</style>
|
||||
.page-container { min-height: 100vh; background-color: #F3F4F6; padding-bottom: 200rpx; }
|
||||
.skeleton-form-item { margin-bottom: 32rpx; }
|
||||
.skeleton-label { width: 120rpx; height: 26rpx; background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%); background-size: 400% 100%; animation: skeleton-loading 1.8s ease infinite; border-radius: 8rpx; margin-bottom: 16rpx; }
|
||||
.skeleton-label-sm { width: 80rpx; height: 22rpx; background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%); background-size: 400% 100%; animation: skeleton-loading 1.8s ease infinite; border-radius: 6rpx; margin-bottom: 12rpx; }
|
||||
.skeleton-input { width: 100%; height: 96rpx; background: linear-gradient(90deg, #F9FAFB 25%, #F3F4F6 37%, #F9FAFB 50%); background-size: 400% 100%; animation: skeleton-loading 1.8s ease infinite; border-radius: 20rpx; }
|
||||
.skeleton-input-sm { flex: 1; height: 72rpx; background: linear-gradient(90deg, #F9FAFB 25%, #F3F4F6 37%, #F9FAFB 50%); background-size: 400% 100%; animation: skeleton-loading 1.8s ease infinite; border-radius: 16rpx; }
|
||||
.skeleton-row { display: flex; gap: 24rpx; flex-direction: column; }
|
||||
@keyframes skeleton-loading { 0% { background-position: 100% 50% } 100% { background-position: 0 50% } }
|
||||
.flex-row { display: flex; flex-direction: row; }
|
||||
.items-center { align-items: center; }
|
||||
.gap-2 { gap: 16rpx; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.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; width: 100%; box-sizing: border-box; }
|
||||
.footer-area { 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); 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'; }
|
||||
.native-input { background-color: #F9FAFB; border-radius: 20rpx; height: 96rpx; padding: 0 32rpx; font-size: 28rpx; color: #1F2937; }
|
||||
.native-input-sm { background-color: #FFFFFF; border-radius: 16rpx; height: 72rpx; padding: 0 20rpx; font-size: 26rpx; color: #1F2937; border: 1rpx solid #E5E7EB; }
|
||||
</style>
|
||||
|
||||
@ -549,29 +549,23 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, getCurrentInstance, nextTick } from 'vue';
|
||||
import { api } from '../../utils/api';
|
||||
import { api } from '@/utils/api';
|
||||
import type { PositionItem, TransactionItem, NavHistoryItem, NavStatistics } from '@/types';
|
||||
|
||||
// 获取 u-toast 实例
|
||||
const { proxy } = getCurrentInstance();
|
||||
const { proxy } = getCurrentInstance()!;
|
||||
const uToastRef = ref();
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(true);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
// 获取货币符号
|
||||
const getCurrencySymbol = (currency) => {
|
||||
const symbols = {
|
||||
'CNY': '¥',
|
||||
'USD': '$',
|
||||
'HKD': 'HK$'
|
||||
};
|
||||
return symbols[currency] || '¥';
|
||||
const getCurrencySymbol = (currency?: string): string => {
|
||||
const symbols: Record<string, string> = { 'CNY': '¥', 'USD': '$', 'HKD': 'HK$' };
|
||||
return symbols[currency || 'CNY'] || '¥';
|
||||
};
|
||||
|
||||
const portfolioId = ref('');
|
||||
const portfolioData = ref({
|
||||
const portfolioId = ref<string>('');
|
||||
const portfolioData = ref<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
currency: 'CNY',
|
||||
@ -582,23 +576,17 @@ const portfolioData = ref({
|
||||
todayProfitCurrency: 'CNY',
|
||||
historicalChange: 0,
|
||||
dailyVolatility: 0,
|
||||
logicModel: '',
|
||||
logicModelStatus: '',
|
||||
logicModelDescription: '',
|
||||
totalItems: 0,
|
||||
totalRatio: 100,
|
||||
strategy: null
|
||||
});
|
||||
|
||||
const positions = ref([]);
|
||||
const logs = ref([]);
|
||||
const positions = ref<PositionItem[]>([]);
|
||||
const logs = ref<TransactionItem[]>([]);
|
||||
|
||||
// 净值历史相关
|
||||
const navLoading = ref(false);
|
||||
const navPeriod = ref('30d');
|
||||
const navHistory = ref([]);
|
||||
const navStatistics = ref(null);
|
||||
const backfillLoading = ref(false);
|
||||
const navLoading = ref<boolean>(false);
|
||||
const navPeriod = ref<string>('30d');
|
||||
const navHistory = ref<NavHistoryItem[]>([]);
|
||||
const navStatistics = ref<NavStatistics | null>(null);
|
||||
const backfillLoading = ref<boolean>(false);
|
||||
|
||||
// 绘制收益曲线
|
||||
const drawNavChart = () => {
|
||||
@ -720,10 +708,10 @@ const handleBackfillNav = async () => {
|
||||
};
|
||||
|
||||
// 交易表单
|
||||
const showTransactionForm = ref(false);
|
||||
const transactionType = ref('buy'); // buy 或 sell
|
||||
// 获取当前日期格式化为YYYY-MM-DD
|
||||
const getCurrentDate = () => {
|
||||
const showTransactionForm = ref<boolean>(false);
|
||||
const transactionType = ref<'buy' | 'sell'>('buy');
|
||||
|
||||
const getCurrentDate = (): string => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
@ -731,7 +719,7 @@ const getCurrentDate = () => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const transactionForm = ref({
|
||||
const transactionForm = ref<any>({
|
||||
stockCode: '',
|
||||
amount: '',
|
||||
price: '',
|
||||
@ -741,18 +729,18 @@ const transactionForm = ref({
|
||||
remark: ''
|
||||
});
|
||||
|
||||
const maxSellAmount = ref(0);
|
||||
const maxSellAmount = ref<number>(0);
|
||||
|
||||
// 编辑弹窗相关
|
||||
const showEditModal = ref(false);
|
||||
const editForm = ref({
|
||||
const showEditModal = ref<boolean>(false);
|
||||
const editForm = ref<any>({
|
||||
name: '',
|
||||
strategyId: null,
|
||||
strategyName: '',
|
||||
status: '运行中'
|
||||
});
|
||||
const strategyOptions = ref([{ id: null, name: '不绑定策略' }]);
|
||||
const statusOptions = ref([
|
||||
const strategyOptions = ref<any[]>([{ id: null, name: '不绑定策略' }]);
|
||||
const statusOptions = ref<any[]>([
|
||||
{ label: '运行中', value: '运行中' },
|
||||
{ label: '已暂停', value: '已暂停' },
|
||||
{ label: '已清仓', value: '已清仓' }
|
||||
@ -836,8 +824,8 @@ const submitEdit = async () => {
|
||||
};
|
||||
|
||||
// 股票搜索相关
|
||||
const searchResults = ref([]);
|
||||
const searchTimer = ref(null);
|
||||
const searchResults = ref<any[]>([]);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleStockInputClick = () => {
|
||||
if (transactionType.value === 'sell') {
|
||||
|
||||
@ -135,80 +135,69 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { api } from '../../utils/api';
|
||||
import { getCurrencySymbol } from '../../utils/currency';
|
||||
import { api } from '@/utils/api';
|
||||
import { getCurrencySymbol } from '@/utils/currency';
|
||||
import type { PortfolioListItem, TotalAssetsResponse } from '@/types';
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(true);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
// 资产数据
|
||||
const assetData = ref({
|
||||
const assetData = ref<TotalAssetsResponse>({
|
||||
totalValue: 0,
|
||||
todayProfit: 0,
|
||||
todayProfitCurrency: 'CNY',
|
||||
totalReturnRate: 0
|
||||
});
|
||||
|
||||
// 持仓组合数据
|
||||
const holdings = ref([]);
|
||||
const holdings = ref<PortfolioListItem[]>([]);
|
||||
|
||||
// 状态标签样式
|
||||
const getStatusBgClass = (statusType) => {
|
||||
const classes = {
|
||||
const getStatusBgClass = (statusType?: string): string => {
|
||||
const classes: Record<string, string> = {
|
||||
'green': 'bg-green-50',
|
||||
'yellow': 'bg-yellow-50',
|
||||
'red': 'bg-red-50',
|
||||
'gray': 'bg-gray-100'
|
||||
};
|
||||
return classes[statusType] || 'bg-gray-100';
|
||||
return classes[statusType || 'gray'] || 'bg-gray-100';
|
||||
};
|
||||
|
||||
const getStatusTextClass = (statusType) => {
|
||||
const classes = {
|
||||
const getStatusTextClass = (statusType?: string): string => {
|
||||
const classes: Record<string, string> = {
|
||||
'green': 'text-green-600',
|
||||
'yellow': 'text-yellow-600',
|
||||
'red': 'text-red-600',
|
||||
'gray': 'text-gray-500'
|
||||
};
|
||||
return classes[statusType] || 'text-gray-500';
|
||||
return classes[statusType || 'gray'] || 'text-gray-500';
|
||||
};
|
||||
|
||||
// 防止重复请求的标志
|
||||
let isFetching = false;
|
||||
|
||||
// 从后端API获取资产数据的函数
|
||||
const fetchAssetData = async () => {
|
||||
const fetchAssetData = async (): Promise<void> => {
|
||||
try {
|
||||
console.log('开始获取资产数据...');
|
||||
const response = await api.assets.getAssetData();
|
||||
if (response.code === 200) {
|
||||
// 映射资产数据字段
|
||||
const data = response.data;
|
||||
assetData.value = {
|
||||
totalValue: data.totalValue,
|
||||
currency: data.currency,
|
||||
todayProfit: data.todayProfit,
|
||||
todayProfitCurrency: data.todayProfitCurrency,
|
||||
todayProfitCurrency: data.todayProfitCurrency || 'CNY',
|
||||
totalReturnRate: data.totalReturnRate
|
||||
};
|
||||
console.log('资产数据获取成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取资产数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 从后端API获取持仓组合数据的函数
|
||||
const fetchHoldingsData = async () => {
|
||||
const fetchHoldingsData = async (): Promise<void> => {
|
||||
try {
|
||||
console.log('开始获取持仓数据...');
|
||||
const response = await api.assets.getHoldings();
|
||||
if (response.code === 200) {
|
||||
// 处理响应数据结构,获取 items 数组并映射字段
|
||||
const items = response.data.items || [];
|
||||
holdings.value = items.map(item => ({
|
||||
holdings.value = items.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
tags: item.tags,
|
||||
@ -220,34 +209,30 @@ const fetchHoldingsData = async () => {
|
||||
value: item.value,
|
||||
currency: item.currency,
|
||||
returnRate: item.returnRate,
|
||||
returnType: item.returnType
|
||||
returnType: item.returnType,
|
||||
todayProfit: item.todayProfit,
|
||||
todayProfitCurrency: item.todayProfitCurrency
|
||||
}));
|
||||
console.log('持仓数据获取成功,items数量:', holdings.value.length);
|
||||
console.log('持仓数据:', holdings.value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取持仓数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const goConfig = () => {
|
||||
const goConfig = (): void => {
|
||||
uni.navigateTo({ url: '/pages/config/config' });
|
||||
};
|
||||
|
||||
const goDetail = (holdingId) => {
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${holdingId}` });
|
||||
const goDetail = (holdingId?: string): void => {
|
||||
if (holdingId) {
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${holdingId}` });
|
||||
}
|
||||
};
|
||||
|
||||
onShow(async () => {
|
||||
console.log('首页显示,刷新数据...');
|
||||
isFetching = true;
|
||||
loading.value = true;
|
||||
|
||||
await Promise.all([
|
||||
fetchAssetData(),
|
||||
fetchHoldingsData()
|
||||
]);
|
||||
|
||||
await Promise.all([fetchAssetData(), fetchHoldingsData()]);
|
||||
loading.value = false;
|
||||
isFetching = false;
|
||||
});
|
||||
|
||||
@ -78,23 +78,26 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, getCurrentInstance } from 'vue';
|
||||
import type { UserInfoResponse, UserStatsResponse, ApiResponse } from '@/types';
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(true);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref({
|
||||
const userInfo = ref<UserInfoResponse>({
|
||||
userName: '',
|
||||
memberLevel: '',
|
||||
runningDays: 0
|
||||
});
|
||||
|
||||
// 用户统计数据
|
||||
const userStats = ref({
|
||||
const userStats = ref<UserStatsResponse>({
|
||||
signalsCaptured: 0,
|
||||
winRate: 0
|
||||
winRate: 0,
|
||||
totalTrades: 0,
|
||||
averageProfit: 0
|
||||
});
|
||||
|
||||
// 获取全局api对象
|
||||
@ -103,50 +106,44 @@ const getApi = () => {
|
||||
return instance?.appContext.config.globalProperties.$api;
|
||||
};
|
||||
|
||||
// 从后端API获取用户信息的函数
|
||||
const fetchUserInfo = async () => {
|
||||
// 从后端API获取用户信息
|
||||
const fetchUserInfo = async (): Promise<void> => {
|
||||
try {
|
||||
const api = getApi();
|
||||
if (!api || !api.user) {
|
||||
if (!api?.user) {
|
||||
console.error('API模块未加载');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.user.getUserInfo();
|
||||
const response: ApiResponse<UserInfoResponse> = await api.user.getUserInfo();
|
||||
if (response.code === 200) {
|
||||
userInfo.value = response.data;
|
||||
console.log('用户信息获取成功:', response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 从后端API获取用户统计数据的函数
|
||||
const fetchUserStats = async () => {
|
||||
// 从后端API获取用户统计数据
|
||||
const fetchUserStats = async (): Promise<void> => {
|
||||
try {
|
||||
const api = getApi();
|
||||
if (!api || !api.user) {
|
||||
if (!api?.user) {
|
||||
console.error('API模块未加载');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.user.getUserStats();
|
||||
const response: ApiResponse<UserStatsResponse> = await api.user.getUserStats();
|
||||
if (response.code === 200) {
|
||||
userStats.value = response.data;
|
||||
console.log('用户统计数据获取成功:', response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户统计数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(async () => {
|
||||
console.log('个人中心页面加载,开始获取数据...');
|
||||
|
||||
loading.value = true;
|
||||
await Promise.all([
|
||||
fetchUserInfo(),
|
||||
@ -257,4 +254,4 @@ onMounted(async () => {
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-text { font-size: 28rpx; font-weight: 500; color: #374151; margin-left: 20rpx; }
|
||||
.text-red { color: #EF4444; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -145,7 +145,6 @@
|
||||
placeholder="如 AAPL"
|
||||
@input="(e) => onStockInput(e, index)"
|
||||
/>
|
||||
<!-- 搜索下拉列表 -->
|
||||
<view class="search-dropdown" v-if="searchResults.length > 0 && activeAssetIndex === index">
|
||||
<view
|
||||
class="dropdown-item"
|
||||
@ -231,15 +230,56 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { api } from '../../../utils/api';
|
||||
import { api } from '@/utils/api';
|
||||
|
||||
const isEditMode = ref(false);
|
||||
const strategyId = ref('');
|
||||
const currentType = ref('ma_trend'); // 当前选中的策略类型
|
||||
// 类型定义
|
||||
interface StrategyTypeItem {
|
||||
key: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
icon: string;
|
||||
bgClass: string;
|
||||
iconColor: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const strategyTypes = [
|
||||
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: '双均线策略',
|
||||
@ -269,7 +309,7 @@ const strategyTypes = [
|
||||
}
|
||||
];
|
||||
|
||||
const formData = ref({
|
||||
const formData = ref<FormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
riskLevel: 'medium',
|
||||
@ -282,25 +322,19 @@ const formData = ref({
|
||||
period: '',
|
||||
multiplier: '',
|
||||
useClose: false,
|
||||
assets: [
|
||||
{ symbol: '', targetWeight: '' }
|
||||
]
|
||||
assets: [{ symbol: '', targetWeight: '' }]
|
||||
});
|
||||
|
||||
// 股票搜索相关
|
||||
const searchResults = ref([]);
|
||||
const activeAssetIndex = ref(-1);
|
||||
const searchTimer = ref(null);
|
||||
const searchResults = ref<SearchResult[]>([]);
|
||||
const activeAssetIndex = ref<number>(-1);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const onStockInput = (e, assetIndex) => {
|
||||
const keyword = e.detail.value;
|
||||
console.log('🔍 策略页面股票输入:', keyword, 'assetIndex:', assetIndex);
|
||||
searchStock(keyword, assetIndex);
|
||||
const onStockInput = (e: { detail: { value: string } }, assetIndex: number): void => {
|
||||
searchStock(e.detail.value, assetIndex);
|
||||
};
|
||||
|
||||
const searchStock = async (keyword, assetIndex) => {
|
||||
console.log('🔍 searchStock 调用:', keyword, 'assetIndex:', assetIndex);
|
||||
if (searchTimer.value) clearTimeout(searchTimer.value);
|
||||
const searchStock = async (keyword: string, assetIndex: number): Promise<void> => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
activeAssetIndex.value = assetIndex;
|
||||
if (!keyword || keyword.length < 1) {
|
||||
searchResults.value = [];
|
||||
@ -308,124 +342,92 @@ const searchStock = async (keyword, assetIndex) => {
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimer.value = setTimeout(async () => {
|
||||
searchTimer = 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.map(item => ({
|
||||
...item,
|
||||
assetIndex: assetIndex
|
||||
}));
|
||||
searchResults.value = res.data.map((item: any) => ({ ...item, assetIndex }));
|
||||
activeAssetIndex.value = assetIndex;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('搜索股票失败:', err);
|
||||
searchResults.value = [];
|
||||
activeAssetIndex.value = -1;
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const selectStock = (result) => {
|
||||
const selectStock = (result: SearchResult): void => {
|
||||
const asset = formData.value.assets[result.assetIndex];
|
||||
if (asset) {
|
||||
asset.symbol = result.ticker;
|
||||
}
|
||||
if (asset) asset.symbol = result.ticker;
|
||||
searchResults.value = [];
|
||||
activeAssetIndex.value = -1;
|
||||
};
|
||||
|
||||
// 计算当前选中的策略信息
|
||||
const currentStrategyInfo = computed(() => {
|
||||
const currentStrategyInfo = computed((): StrategyTypeItem | undefined => {
|
||||
return strategyTypes.find(item => item.key === currentType.value);
|
||||
});
|
||||
|
||||
|
||||
const selectType = (key) => {
|
||||
const selectType = (key: string): void => {
|
||||
currentType.value = key;
|
||||
};
|
||||
|
||||
const onPeriodChange = (e) => {
|
||||
const periods = ['每日', '每周', '每月', '每季度', '每年'];
|
||||
formData.value.period = periods[e.detail.value];
|
||||
const onMaTypeChange = (e: { detail: { value: number } }): void => {
|
||||
formData.value.maType = ['SMA', 'EMA'][e.detail.value];
|
||||
};
|
||||
|
||||
const onMaTypeChange = (e) => {
|
||||
const types = ['SMA', 'EMA'];
|
||||
formData.value.maType = types[e.detail.value];
|
||||
};
|
||||
|
||||
const onUseCloseChange = (e) => {
|
||||
const onUseCloseChange = (e: { detail: { value: number } }): void => {
|
||||
formData.value.useClose = e.detail.value === 0;
|
||||
};
|
||||
|
||||
const onRiskLevelChange = (e) => {
|
||||
const levels = ['low', 'medium', 'high'];
|
||||
formData.value.riskLevel = levels[e.detail.value];
|
||||
const onRiskLevelChange = (e: { detail: { value: number } }): void => {
|
||||
formData.value.riskLevel = ['low', 'medium', 'high'][e.detail.value];
|
||||
};
|
||||
|
||||
const addAsset = () => {
|
||||
const addAsset = (): void => {
|
||||
formData.value.assets.push({ symbol: '', targetWeight: '' });
|
||||
};
|
||||
|
||||
const removeAsset = (index) => {
|
||||
const removeAsset = (index: number): void => {
|
||||
formData.value.assets.splice(index, 1);
|
||||
};
|
||||
|
||||
const validateRiskParityAssets = () => {
|
||||
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) {
|
||||
uni.showToast({ title: '请填写所有资产代码', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
if (!asset.targetWeight) {
|
||||
uni.showToast({ title: '请填写所有资产目标权重', icon: 'none' });
|
||||
if (!asset.symbol || !asset.targetWeight) {
|
||||
uni.showToast({ title: '请填写所有资产信息', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
totalWeight += parseFloat(asset.targetWeight);
|
||||
}
|
||||
|
||||
// 校验权重总和必须等于1(允许1%误差)
|
||||
if (Math.abs(totalWeight - 1) > 0.01) {
|
||||
uni.showToast({ title: `资产权重总和必须为100%,当前为${(totalWeight * 100).toFixed(0)}%`, icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!formData.value.name) {
|
||||
uni.showToast({ title: '请输入策略名称', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理标签,将逗号分隔的字符串转换为数组
|
||||
let tags = [];
|
||||
if (formData.value.tags) {
|
||||
tags = formData.value.tags.split(',').map(tag => tag.trim()).filter(tag => tag);
|
||||
}
|
||||
if (tags.length === 0) {
|
||||
tags = [currentStrategyInfo.value.tag];
|
||||
}
|
||||
const tags = formData.value.tags
|
||||
? formData.value.tags.split(',').map(t => t.trim()).filter(t => t)
|
||||
: currentStrategyInfo.value ? [currentStrategyInfo.value.tag] : [];
|
||||
|
||||
// 构建参数对象
|
||||
const parameters = {};
|
||||
const parameters: Record<string, any> = {};
|
||||
|
||||
switch (currentType.value) {
|
||||
case 'ma_trend':
|
||||
if (!formData.value.shortPeriod || !formData.value.longPeriod) {
|
||||
uni.showToast({ title: '请输入短期和长期周期', icon: 'none' });
|
||||
uni.showToast({ title: '请输入周期参数', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
parameters.maType = formData.value.maType;
|
||||
@ -434,22 +436,20 @@ const submit = async () => {
|
||||
break;
|
||||
case 'risk_parity':
|
||||
if (!formData.value.lookbackPeriod || !formData.value.rebalanceThreshold) {
|
||||
uni.showToast({ title: '请输入回看周期和再平衡阈值', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!validateRiskParityAssets()) {
|
||||
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(asset => ({
|
||||
symbol: asset.symbol,
|
||||
targetWeight: parseFloat(asset.targetWeight)
|
||||
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: '请输入ATR周期和倍数', icon: 'none' });
|
||||
uni.showToast({ title: '请输入参数', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
parameters.period = parseInt(formData.value.period);
|
||||
@ -458,40 +458,37 @@ const submit = async () => {
|
||||
break;
|
||||
}
|
||||
|
||||
const strategyData = {
|
||||
name: formData.value.name,
|
||||
type: currentType.value,
|
||||
description: formData.value.description || currentStrategyInfo.value.description,
|
||||
riskLevel: formData.value.riskLevel,
|
||||
tags: tags,
|
||||
parameters: parameters
|
||||
};
|
||||
|
||||
console.log('保存策略:', strategyData);
|
||||
uni.showLoading({ title: isEditMode.value ? '更新中' : '保存中' });
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (isEditMode.value) {
|
||||
res = await api.strategies.updateStrategy(strategyId.value, strategyData);
|
||||
console.log('策略更新成功:', res);
|
||||
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 {
|
||||
res = await api.strategies.createStrategy(strategyData);
|
||||
console.log('策略创建成功:', res);
|
||||
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) {
|
||||
console.error(isEditMode.value ? '策略更新失败:' : '策略创建失败:', error);
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: isEditMode.value ? '更新失败,请重试' : '保存失败,请重试', icon: 'none' });
|
||||
uni.showToast({ title: '操作失败,请重试', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
// 加载策略详情
|
||||
const loadStrategyDetail = async (id) => {
|
||||
const loadStrategyDetail = async (id: string): Promise<void> => {
|
||||
try {
|
||||
uni.showLoading({ title: '加载中' });
|
||||
const response = await api.strategies.getStrategy(id);
|
||||
@ -500,35 +497,23 @@ const loadStrategyDetail = async (id) => {
|
||||
if (response.code === 200) {
|
||||
const data = response.data;
|
||||
isEditMode.value = true;
|
||||
strategyId.value = data.id;
|
||||
currentType.value = data.type;
|
||||
|
||||
// 填充表单数据
|
||||
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 ? data.tags.join(', ') : '';
|
||||
formData.value.tags = data.tags?.join(', ') || '';
|
||||
|
||||
// 根据策略类型填充参数
|
||||
let params = {};
|
||||
if (data.config) {
|
||||
let params: Record<string, any> = {};
|
||||
if ((data as any).config) {
|
||||
try {
|
||||
let config = typeof data.config === 'string' ? JSON.parse(data.config) : data.config;
|
||||
// 如果解析后还是字符串,再解析一次(处理双层转义)
|
||||
if (typeof config === 'string') {
|
||||
config = JSON.parse(config);
|
||||
}
|
||||
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;
|
||||
console.log('📊 从 config 解析参数:', params);
|
||||
} catch (e) {
|
||||
console.error('📊 config 解析失败:', e, '原始值:', data.config);
|
||||
params = {};
|
||||
}
|
||||
} else if (data.parameters) {
|
||||
params = data.parameters;
|
||||
console.log('📊 从 parameters 获取参数:', params);
|
||||
} catch { params = {}; }
|
||||
} else if ((data as any).parameters) {
|
||||
params = (data as any).parameters;
|
||||
}
|
||||
console.log('📊 策略类型:', data.type, '参数:', JSON.stringify(params));
|
||||
|
||||
switch (data.type) {
|
||||
case 'ma_trend':
|
||||
@ -539,17 +524,11 @@ const loadStrategyDetail = async (id) => {
|
||||
case 'risk_parity':
|
||||
formData.value.lookbackPeriod = params.lookbackPeriod?.toString() || '';
|
||||
formData.value.rebalanceThreshold = params.rebalanceThreshold?.toString() || '';
|
||||
console.log('📊 再平衡策略 assets:', JSON.stringify(params.assets));
|
||||
if (params.assets && Array.isArray(params.assets) && params.assets.length > 0) {
|
||||
// 使用 splice 保持响应式引用
|
||||
formData.value.assets.splice(0, formData.value.assets.length);
|
||||
params.assets.forEach(asset => {
|
||||
formData.value.assets.push({
|
||||
symbol: asset.symbol || '',
|
||||
targetWeight: asset.targetWeight != null ? String(asset.targetWeight) : ''
|
||||
});
|
||||
});
|
||||
console.log('📊 填充后的 formData.assets:', JSON.stringify(formData.value.assets));
|
||||
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':
|
||||
@ -558,18 +537,14 @@ const loadStrategyDetail = async (id) => {
|
||||
formData.value.useClose = params.useClose || false;
|
||||
break;
|
||||
}
|
||||
|
||||
console.log('策略详情加载成功:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
uni.hideLoading();
|
||||
console.error('加载策略详情失败:', error);
|
||||
uni.showToast({ title: '加载失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
// 删除策略
|
||||
const deleteStrategy = async () => {
|
||||
const deleteStrategy = async (): Promise<void> => {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后无法恢复,确定要删除这个策略吗?',
|
||||
@ -581,228 +556,52 @@ const deleteStrategy = async () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '删除成功', icon: 'success' });
|
||||
setTimeout(() => uni.navigateBack(), 1500);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '删除失败,请重试', icon: 'none' });
|
||||
uni.showToast({ title: '删除失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 页面加载时检查是否是编辑模式
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const id = currentPage.options?.id;
|
||||
|
||||
if (id) {
|
||||
loadStrategyDetail(id);
|
||||
}
|
||||
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; /* 增加底部内边距,防止内容被底部按钮遮挡 */
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.nav-bar {
|
||||
background-color: #fff;
|
||||
padding: var(--status-bar-height) 32rpx 20rpx 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
box-sizing: content-box;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
border-bottom: 1rpx solid #E5E7EB;
|
||||
}
|
||||
.back-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-right: 60rpx;
|
||||
}
|
||||
|
||||
/* 导航栏 (简化版) */
|
||||
.nav-bar {
|
||||
background-color: #fff;
|
||||
padding: var(--status-bar-height) 32rpx 20rpx 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
box-sizing: content-box;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.back-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 标题通用 */
|
||||
.section-title {
|
||||
padding: 32rpx 32rpx 20rpx 32rpx;
|
||||
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;
|
||||
}
|
||||
.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 32rpx;
|
||||
background-color: #ECFDF5;
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #D1FAE5;
|
||||
}
|
||||
.desc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 表单区域 */
|
||||
.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-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; }
|
||||
|
||||
.input-field {
|
||||
background-color: #F9FAFB;
|
||||
border: 2rpx solid #E5E7EB;
|
||||
border-radius: 20rpx;
|
||||
height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.unit { position: absolute; right: 24rpx; font-size: 26rpx; color: #9CA3AF; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.helper { font-size: 22rpx; color: #9CA3AF; margin-top: 10rpx; 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; }
|
||||
@ -811,203 +610,35 @@ onMounted(() => {
|
||||
.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;
|
||||
}
|
||||
.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 { 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>
|
||||
</style>
|
||||
|
||||
@ -76,26 +76,41 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, getCurrentInstance } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { StrategyListItemDto, ApiResponse } from '@/types';
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const api = proxy.$api;
|
||||
interface StrategyCardItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
tag: string;
|
||||
desc?: string;
|
||||
tags: string[];
|
||||
riskLevel?: string;
|
||||
bgClass: string;
|
||||
tagClass: string;
|
||||
iconChar: string;
|
||||
btnClass: string;
|
||||
btnText: string;
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const strategies = ref([]);
|
||||
const { proxy } = getCurrentInstance()!;
|
||||
const api = proxy!.$api;
|
||||
|
||||
const loading = ref<boolean>(true);
|
||||
const strategies = ref<StrategyCardItem[]>([]);
|
||||
|
||||
// 防止重复请求的标志
|
||||
let isFetching = false;
|
||||
|
||||
const fetchStrategies = async () => {
|
||||
const fetchStrategies = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await api.strategies.getStrategies();
|
||||
const response: ApiResponse<StrategyListItemDto[]> = await api.strategies.getStrategies();
|
||||
if (response.code === 200) {
|
||||
strategies.value = response.data.map(item => ({
|
||||
strategies.value = response.data.map((item: StrategyListItemDto) => ({
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
title: item.name || '未命名策略',
|
||||
tag: item.tags?.[0] || '策略',
|
||||
desc: item.description,
|
||||
tags: item.tags || [],
|
||||
@ -120,12 +135,11 @@ onShow(async () => {
|
||||
isFetching = false;
|
||||
});
|
||||
|
||||
const goToAdd = () => {
|
||||
const goToAdd = (): void => {
|
||||
uni.navigateTo({ url: '/pages/strategies/edit/edit' });
|
||||
};
|
||||
|
||||
const handleAction = (item) => {
|
||||
console.log('点击策略:', item.title);
|
||||
const handleAction = (item: StrategyCardItem): void => {
|
||||
uni.navigateTo({ url: `/pages/strategies/edit/edit?id=${item.id}` });
|
||||
};
|
||||
</script>
|
||||
@ -154,7 +168,7 @@ const handleAction = (item) => {
|
||||
.title-lg { font-size: 48rpx; font-weight: 800; color: #111827; display: block; }
|
||||
.subtitle { font-size: 28rpx; color: #6B7280; margin-top: 8rpx; display: block; }
|
||||
|
||||
/* === 新增:添加按钮样式 === */
|
||||
/* 添加按钮样式 */
|
||||
.add-btn-box {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
@ -313,4 +327,4 @@ const handleAction = (item) => {
|
||||
.gap-3 { gap: 24rpx; }
|
||||
.gap-2 { gap: 16rpx; }
|
||||
.mb-4 { margin-bottom: 32rpx; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
61
todo.md
61
todo.md
@ -52,9 +52,66 @@
|
||||
- [ ] 上拉加载更多
|
||||
- [ ] 空状态优化
|
||||
|
||||
### P4 - 代码质量
|
||||
### P4 - TypeScript 迁移 🚀 进行中
|
||||
详细计划见 `TYPESCRIPT_MIGRATION.md`
|
||||
|
||||
#### Phase 0 - 基础设施 ✅ 完成
|
||||
- [x] 安装 TypeScript 相关依赖 (typescript@6.0.2)
|
||||
- [x] 创建 tsconfig.json
|
||||
- [x] 创建 src/types/ 目录结构 (6个类型文件)
|
||||
- [x] 修改 vite.config.js → vite.config.ts
|
||||
- [x] 修改 main.js → main.ts
|
||||
- [x] `tsc --noEmit` 无错误通过
|
||||
|
||||
#### Phase 1 - 类型定义 ✅ 完成
|
||||
- [x] types/api.ts - API 响应类型(与后端对齐)
|
||||
- [x] types/portfolio.ts - 组合/持仓/交易类型(与后端 DTO 对齐)
|
||||
- [x] types/strategy.ts - 策略类型(与后端 DTO 对齐)
|
||||
- [x] types/user.ts - 用户类型(与后端 DTO 对齐)
|
||||
- [x] types/global.d.ts - 全局类型声明
|
||||
- [x] types/shims.d.ts - Vue/模块声明
|
||||
- [x] `tsc --noEmit` 无错误通过
|
||||
|
||||
#### Phase 2 - 工具函数迁移 ✅ 完成
|
||||
- [x] currency.js → currency.ts(带类型注解)
|
||||
- [x] api.js → api.ts(泛型化请求方法,完整类型定义)
|
||||
- [x] 更新 shims.d.ts(uni、importMeta 类型声明)
|
||||
- [x] 删除原 .js 文件
|
||||
- [x] `tsc --noEmit` 无错误通过
|
||||
|
||||
#### Phase 3 - 页面组件迁移 ✅ 完成
|
||||
- [x] me.vue (最简单)
|
||||
- [x] strategies/strategies.vue
|
||||
- [x] strategies/edit/edit.vue
|
||||
- [x] config/config.vue
|
||||
- [x] detail/detail.vue
|
||||
- [x] index/index.vue
|
||||
- [x] `tsc --noEmit` 无错误通过
|
||||
|
||||
#### Phase 4 - 清理验证 ✅ 完成
|
||||
- [x] 删除原 .js 文件(仅保留 uni.promisify.adaptor.js)
|
||||
- [x] `tsc --noEmit` 无错误通过
|
||||
- [x] 微信小程序验证(待真机测试)
|
||||
|
||||
---
|
||||
|
||||
## ✅ TypeScript 迁移完成!
|
||||
|
||||
**迁移统计:**
|
||||
- 配置文件:2 个
|
||||
- 类型文件:7 个
|
||||
- 工具函数:2 个
|
||||
- 页面组件:6 个
|
||||
- 总代码行数:~5,000+ 行
|
||||
|
||||
**收益:**
|
||||
- ✅ 完整的类型提示
|
||||
- ✅ 编译时错误检查
|
||||
- ✅ IDE 自动补全
|
||||
- ✅ 重构安全性提升
|
||||
|
||||
### P5 - 其他代码质量优化(TS 迁移后)
|
||||
- [ ] 统一错误处理机制
|
||||
- [ ] 添加 TypeScript 类型定义
|
||||
- [ ] 优化 API 调用封装
|
||||
|
||||
## 兼容性
|
||||
|
||||
35
tsconfig.json
Normal file
35
tsconfig.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@/utils/*": ["utils/*"],
|
||||
"@/types/*": ["types/*"]
|
||||
},
|
||||
"typeRoots": ["./types", "./node_modules/@types"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.d.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"uni_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
45
types/api.ts
Normal file
45
types/api.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* API 响应类型定义(与后端 DTO 对齐)
|
||||
*/
|
||||
|
||||
// 通用 API 响应结构
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 请求配置
|
||||
export interface RequestConfig {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
data?: Record<string, any>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// API 错误
|
||||
export interface ApiError {
|
||||
code: number;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 股票搜索结果
|
||||
export interface TickerSearchResult {
|
||||
symbol: string;
|
||||
name: string;
|
||||
exchange?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
// ===== 认证相关 =====
|
||||
|
||||
export interface WechatLoginRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface WechatLoginResponse {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
44
types/global.d.ts
vendored
Normal file
44
types/global.d.ts
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
/**
|
||||
* 全局类型声明
|
||||
*/
|
||||
|
||||
// 扩展 uni-app 全局类型
|
||||
declare global {
|
||||
namespace UniNamespace {
|
||||
interface GetStorageOption {
|
||||
key: string;
|
||||
success?: (res: { data: any }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
complete?: () => void;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 微信小程序登录响应
|
||||
interface WechatLoginResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
token: string;
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export {};
|
||||
8
types/index.ts
Normal file
8
types/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 类型统一导出
|
||||
*/
|
||||
|
||||
export * from './api'
|
||||
export * from './portfolio'
|
||||
export * from './strategy'
|
||||
export * from './user'
|
||||
218
types/portfolio.ts
Normal file
218
types/portfolio.ts
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 组合/持仓/交易类型定义(与后端 DTO 对齐)
|
||||
*/
|
||||
|
||||
// 货币类型
|
||||
export type CurrencyCode = 'CNY' | 'USD' | 'HKD' | 'EUR' | 'GBP' | 'JPY' | 'SGD' | 'AUD';
|
||||
|
||||
// 资产类型
|
||||
export type AssetType = 'Stock' | 'Crypto' | 'Fund' | 'ETF';
|
||||
|
||||
// ===== 创建组合 =====
|
||||
|
||||
export interface StockItem {
|
||||
name?: string;
|
||||
code?: string;
|
||||
price: number;
|
||||
amount: number;
|
||||
date?: string;
|
||||
currency?: string;
|
||||
assetType: AssetType;
|
||||
}
|
||||
|
||||
export interface CreatePortfolioRequest {
|
||||
name?: string;
|
||||
strategyId?: string;
|
||||
currency?: string;
|
||||
stocks?: StockItem[];
|
||||
}
|
||||
|
||||
export interface CreatePortfolioResponse {
|
||||
id?: string;
|
||||
totalValue: number;
|
||||
returnRate: number;
|
||||
currency?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// ===== 更新组合 =====
|
||||
|
||||
export interface UpdatePortfolioRequest {
|
||||
name?: string;
|
||||
strategyId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// ===== 组合列表 =====
|
||||
|
||||
export interface PortfolioListItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tags?: string;
|
||||
status?: string;
|
||||
statusType?: string;
|
||||
iconChar?: string;
|
||||
iconBgClass?: string;
|
||||
iconTextClass?: string;
|
||||
value: number;
|
||||
currency?: string;
|
||||
returnRate: number;
|
||||
returnType?: string;
|
||||
todayProfit: number;
|
||||
todayProfitCurrency?: string;
|
||||
}
|
||||
|
||||
export interface GetPortfoliosResponse {
|
||||
items?: PortfolioListItem[];
|
||||
}
|
||||
|
||||
// ===== 组合详情 =====
|
||||
|
||||
export interface StrategyInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PositionItem {
|
||||
id?: string;
|
||||
stockCode?: string;
|
||||
stockName?: string;
|
||||
symbol?: string;
|
||||
amount: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
totalValue: number;
|
||||
profit: number;
|
||||
profitRate: number;
|
||||
changeAmount: number;
|
||||
ratio: number;
|
||||
deviationRatio: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface PortfolioDetailResponse {
|
||||
id?: string;
|
||||
name?: string;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
strategy?: StrategyInfo;
|
||||
portfolioValue: number;
|
||||
totalReturn: number;
|
||||
todayProfit: number;
|
||||
historicalChange: number;
|
||||
dailyVolatility: number;
|
||||
todayProfitCurrency?: string;
|
||||
logicModel?: string;
|
||||
logicModelStatus?: string;
|
||||
logicModelDescription?: string;
|
||||
totalItems: number;
|
||||
totalRatio: number;
|
||||
positions?: PositionItem[];
|
||||
}
|
||||
|
||||
// ===== 交易相关 =====
|
||||
|
||||
export interface TransactionItem {
|
||||
id?: string;
|
||||
portfolioId?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
stockCode?: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface GetTransactionsRequest {
|
||||
portfolioId?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface GetTransactionsResponse {
|
||||
items?: TransactionItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateTransactionRequest {
|
||||
portfolioId?: string;
|
||||
type?: string;
|
||||
stockCode?: string;
|
||||
amount: number;
|
||||
price: number;
|
||||
currency?: string;
|
||||
remark?: string;
|
||||
assetType: AssetType;
|
||||
transactionTime?: string;
|
||||
transactionDate?: string;
|
||||
}
|
||||
|
||||
export interface CreateTransactionResponse {
|
||||
id?: string;
|
||||
totalAmount: number;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// ===== 总资产 =====
|
||||
|
||||
export interface TotalAssetsResponse {
|
||||
totalValue: number;
|
||||
currency?: string;
|
||||
todayProfit: number;
|
||||
todayProfitCurrency?: string;
|
||||
totalReturnRate: number;
|
||||
}
|
||||
|
||||
// ===== 净值历史 =====
|
||||
|
||||
export interface NavHistoryRequest {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
interval?: string;
|
||||
}
|
||||
|
||||
export interface NavHistoryItem {
|
||||
date?: string;
|
||||
nav: number;
|
||||
totalValue: number;
|
||||
totalCost: number;
|
||||
dailyReturn: number;
|
||||
cumulativeReturn: number;
|
||||
}
|
||||
|
||||
export interface NavStatistics {
|
||||
maxReturn: number;
|
||||
minReturn: number;
|
||||
maxDrawdown: number;
|
||||
sharpeRatio: number;
|
||||
volatility: number;
|
||||
totalReturn: number;
|
||||
tradingDays: number;
|
||||
}
|
||||
|
||||
export interface NavHistoryResponse {
|
||||
portfolioId?: string;
|
||||
currency?: string;
|
||||
navHistory?: NavHistoryItem[];
|
||||
statistics?: NavStatistics;
|
||||
}
|
||||
|
||||
export interface BackfillNavRequest {
|
||||
portfolioId?: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface BackfillNavResponse {
|
||||
portfolioId?: string;
|
||||
recordsCreated: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
message?: string;
|
||||
}
|
||||
83
types/shims.d.ts
vendored
Normal file
83
types/shims.d.ts
vendored
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 模块类型声明
|
||||
*/
|
||||
|
||||
// uni-app 全局声明
|
||||
declare const uni: {
|
||||
login(options: { provider: string; success: (res: any) => void; fail: (err: any) => void }): void;
|
||||
request(options: { url: string; method?: string; data?: any; header?: any; timeout?: number; success: (res: any) => void; fail: (err: any) => void }): void;
|
||||
showLoading(options: { title: string; mask?: boolean }): void;
|
||||
hideLoading(): void;
|
||||
showToast(options: { title: string; icon?: string; duration?: number }): void;
|
||||
getStorageSync(key: string): any;
|
||||
setStorageSync(key: string, value: any): void;
|
||||
removeStorageSync(key: string): void;
|
||||
};
|
||||
|
||||
// Vite import.meta.env
|
||||
interface ImportMetaEnv {
|
||||
VITE_API_BASE_URL?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
// Vue 模块声明
|
||||
declare module 'vue' {
|
||||
import type { App, DefineComponent, ComponentPublicInstance } from '@vue/runtime-core'
|
||||
|
||||
export function createSSRApp(component: any): App
|
||||
export function ref<T>(value: T): { value: T }
|
||||
export function reactive<T>(obj: T): T
|
||||
export function computed<T>(fn: () => T): { value: T }
|
||||
export function watch(source: any, cb: any, options?: any): void
|
||||
export function onMounted(fn: () => void): void
|
||||
export function onUnmounted(fn: () => void): void
|
||||
export function nextTick(fn?: () => void): Promise<void>
|
||||
export function defineComponent(component: any): DefineComponent
|
||||
export function provide(key: any, value: any): void
|
||||
export function inject(key: any): any
|
||||
|
||||
export interface App {
|
||||
config: {
|
||||
globalProperties: Record<string, any>
|
||||
}
|
||||
use(plugin: any, ...options: any[]): App
|
||||
component(name: string, component: any): App
|
||||
mount(rootContainer: any): ComponentPublicInstance
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
install(app: App, ...options: any[]): void
|
||||
}
|
||||
|
||||
export type { DefineComponent, ComponentPublicInstance }
|
||||
}
|
||||
|
||||
// Vue 单文件组件声明
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
// uview-plus 模块声明
|
||||
declare module '@/uni_modules/uview-plus' {
|
||||
import type { Plugin } from 'vue'
|
||||
const uviewPlus: Plugin
|
||||
export default uviewPlus
|
||||
}
|
||||
|
||||
// vite 模块声明
|
||||
declare module 'vite' {
|
||||
export function defineConfig(config: any): any;
|
||||
}
|
||||
|
||||
// @dcloudio/vite-plugin-uni
|
||||
declare module '@dcloudio/vite-plugin-uni' {
|
||||
import type { Plugin } from 'vite'
|
||||
function uni(): Plugin
|
||||
export default uni
|
||||
}
|
||||
101
types/strategy.ts
Normal file
101
types/strategy.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 策略类型定义(与后端 DTO 对齐)
|
||||
*/
|
||||
|
||||
// ===== 策略列表 =====
|
||||
|
||||
export interface StrategyItem {
|
||||
id?: string;
|
||||
iconChar?: string;
|
||||
title?: string;
|
||||
tag?: string;
|
||||
desc?: string;
|
||||
bgClass?: string;
|
||||
tagClass?: string;
|
||||
btnText?: string;
|
||||
btnClass?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface StrategyListResponse {
|
||||
items?: StrategyItem[];
|
||||
}
|
||||
|
||||
// ===== 策略详情 =====
|
||||
|
||||
export interface ParameterItem {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface StrategyDetailResponse {
|
||||
id?: string;
|
||||
iconChar?: string;
|
||||
title?: string;
|
||||
riskLevel?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
parameters?: ParameterItem[];
|
||||
}
|
||||
|
||||
// ===== 创建/更新策略 =====
|
||||
|
||||
export interface CreateStrategyRequest {
|
||||
name?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
riskLevel?: string;
|
||||
tags?: string[];
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateStrategyRequest {
|
||||
name?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
riskLevel?: string;
|
||||
tags?: string[];
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface StrategyResponse {
|
||||
id?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface DeleteStrategyResponse {
|
||||
id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// ===== 策略信号 =====
|
||||
|
||||
export interface SignalAction {
|
||||
symbol?: string;
|
||||
action?: string;
|
||||
target?: number;
|
||||
}
|
||||
|
||||
export interface StrategySignalResponse {
|
||||
signal?: string;
|
||||
reason?: string;
|
||||
actions?: SignalAction[];
|
||||
}
|
||||
|
||||
// ===== 策略列表项(数据库模型) =====
|
||||
|
||||
export interface StrategyListItemDto {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
riskLevel?: string;
|
||||
config?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
35
types/user.ts
Normal file
35
types/user.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 用户类型定义(与后端 DTO 对齐)
|
||||
*/
|
||||
|
||||
// ===== 用户信息 =====
|
||||
|
||||
export interface UserInfoResponse {
|
||||
userName?: string;
|
||||
memberLevel?: string;
|
||||
runningDays: number;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
defaultCurrency?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
userName?: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
defaultCurrency?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserResponse {
|
||||
status?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
// ===== 用户统计 =====
|
||||
|
||||
export interface UserStatsResponse {
|
||||
signalsCaptured: number;
|
||||
winRate: number;
|
||||
totalTrades: number;
|
||||
averageProfit: number;
|
||||
}
|
||||
498
utils/api.js
498
utils/api.js
@ -1,498 +0,0 @@
|
||||
/**
|
||||
* API工具类 - 统一封装后端API请求
|
||||
*/
|
||||
|
||||
console.log('📦 api.js 模块加载成功')
|
||||
|
||||
// API基础URL
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://localhost:7040/';
|
||||
|
||||
// 请求超时时间
|
||||
const TIMEOUT = 10000;
|
||||
|
||||
// 登录锁,防止并发登录
|
||||
let loginLock = null;
|
||||
|
||||
// 全局loading计数,避免多个请求重复显示/隐藏loading
|
||||
let loadingCount = 0;
|
||||
const showLoading = () => {
|
||||
if (loadingCount === 0) {
|
||||
uni.showLoading({ title: '加载中...', mask: true });
|
||||
}
|
||||
loadingCount++;
|
||||
};
|
||||
const hideLoading = () => {
|
||||
loadingCount--;
|
||||
if (loadingCount <= 0) {
|
||||
loadingCount = 0;
|
||||
uni.hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信登录码
|
||||
* @returns {Promise<string>} - 返回微信登录码
|
||||
*/
|
||||
const getWechatCode = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res.code) {
|
||||
console.log('📱 微信登录码获取成功:', res.code);
|
||||
resolve(res.code);
|
||||
} else {
|
||||
console.error('📱 微信登录码获取失败:', res.errMsg);
|
||||
reject(new Error(`微信登录码获取失败: ${res.errMsg}`));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('📱 微信登录失败:', err);
|
||||
reject(new Error(`微信登录失败: ${err.errMsg}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行微信登录
|
||||
* @returns {Promise<object>} - 返回登录结果
|
||||
*/
|
||||
const doWechatLogin = async () => {
|
||||
try {
|
||||
console.log('🔐 开始执行微信登录');
|
||||
const code = await getWechatCode();
|
||||
console.log('🔐 开始调用后端登录接口:', code);
|
||||
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${BASE_URL}api/auth/wechat-login`,
|
||||
method: 'POST',
|
||||
data: { code },
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: TIMEOUT,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
|
||||
console.log('🔐 后端登录接口响应:', res);
|
||||
|
||||
if (res.statusCode === 200 && res.data?.code === 200 && res.data?.data?.token) {
|
||||
console.log('✅ 微信登录成功,获取到token');
|
||||
uni.setStorageSync('token', res.data.data.token);
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('❌ 微信登录失败:', res.data?.message || '登录失败');
|
||||
throw new Error(res.data?.message || '微信登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 微信登录异常:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 带重试机制的请求方法
|
||||
* @param {string} url - 请求URL
|
||||
* @param {string} method - 请求方法
|
||||
* @param {object} data - 请求数据
|
||||
* @param {object} headers - 请求头
|
||||
* @param {number} retryCount - 重试次数
|
||||
* @returns {Promise} - 返回Promise对象
|
||||
*/
|
||||
const requestWithRetry = async (url, method = 'GET', data = {}, headers = {}, retryCount = 0) => {
|
||||
try {
|
||||
// 获取存储的token
|
||||
let token = uni.getStorageSync('token');
|
||||
|
||||
// 如果没有token,先执行微信登录(使用登录锁防止并发)
|
||||
if (!token) {
|
||||
if (loginLock) {
|
||||
console.log('🔒 等待其他请求完成登录...');
|
||||
await loginLock;
|
||||
token = uni.getStorageSync('token');
|
||||
} else {
|
||||
console.log('🔒 未检测到token,开始微信登录...');
|
||||
loginLock = doWechatLogin();
|
||||
await loginLock;
|
||||
loginLock = null;
|
||||
token = uni.getStorageSync('token');
|
||||
console.log('🔒 微信登录成功,获取到token');
|
||||
}
|
||||
}
|
||||
|
||||
// 正确处理URL拼接,避免双斜杠
|
||||
let fullUrl;
|
||||
if (BASE_URL.endsWith('/') && url.startsWith('/')) {
|
||||
fullUrl = BASE_URL + url.substring(1);
|
||||
} else if (!BASE_URL.endsWith('/') && !url.startsWith('/')) {
|
||||
fullUrl = BASE_URL + '/' + url;
|
||||
} else {
|
||||
fullUrl = BASE_URL + url;
|
||||
}
|
||||
|
||||
// 请求开始日志
|
||||
console.log('🚀 API 请求开始:', {
|
||||
method,
|
||||
url: fullUrl,
|
||||
data: method === 'GET' ? null : data,
|
||||
hasToken: !!token,
|
||||
retryCount,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 显示全局loading
|
||||
showLoading();
|
||||
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: fullUrl,
|
||||
method,
|
||||
data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
...headers
|
||||
},
|
||||
timeout: TIMEOUT,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
|
||||
// 响应日志
|
||||
console.log('✅ API 请求成功:', {
|
||||
method,
|
||||
url: fullUrl,
|
||||
statusCode: res.statusCode,
|
||||
responseTime: new Date().toISOString(),
|
||||
data: res.data
|
||||
});
|
||||
|
||||
// 隐藏loading
|
||||
hideLoading();
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
return res.data;
|
||||
} else if (res.statusCode === 401) {
|
||||
// 未授权,清除token并重新登录(使用登录锁防止并发)
|
||||
console.log('🔒 登录已过期,开始重新登录...');
|
||||
uni.removeStorageSync('token');
|
||||
|
||||
// 重新登录后重试请求
|
||||
if (retryCount < 3) {
|
||||
if (loginLock) {
|
||||
console.log('🔒 等待其他请求完成登录...');
|
||||
await loginLock;
|
||||
} else {
|
||||
loginLock = doWechatLogin();
|
||||
await loginLock;
|
||||
loginLock = null;
|
||||
}
|
||||
console.log('🔒 重新登录成功,开始重试请求...');
|
||||
return await requestWithRetry(url, method, data, headers, retryCount + 1);
|
||||
} else {
|
||||
console.error('❌ 达到最大重试次数');
|
||||
uni.showToast({ title: '系统异常,请稍后重试', icon: 'none' });
|
||||
throw new Error('登录已过期,重试次数超限');
|
||||
}
|
||||
} else {
|
||||
console.log('❌ API 请求失败:', {
|
||||
statusCode: res.statusCode,
|
||||
message: res.data?.message || `请求失败: ${res.statusCode}`
|
||||
});
|
||||
throw new Error(`请求失败: ${res.statusCode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// 隐藏loading
|
||||
hideLoading();
|
||||
|
||||
// 请求失败日志
|
||||
console.log('❌ API 请求失败:', {
|
||||
url,
|
||||
error,
|
||||
retryCount,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 如果是网络错误且未达到最大重试次数,尝试重试
|
||||
if (retryCount < 3 && error.message && error.message.includes('网络请求失败')) {
|
||||
console.log('🔄 网络错误,开始重试...');
|
||||
return await requestWithRetry(url, method, data, headers, retryCount + 1);
|
||||
}
|
||||
|
||||
// 达到最大重试次数,提示用户
|
||||
if (retryCount >= 2) {
|
||||
uni.showToast({ title: '系统异常,请稍后重试', icon: 'none', duration: 2000 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 基础请求方法
|
||||
* @param {string} url - 请求URL
|
||||
* @param {string} method - 请求方法
|
||||
* @param {object} data - 请求数据
|
||||
* @param {object} headers - 请求头
|
||||
* @returns {Promise} - 返回Promise对象
|
||||
*/
|
||||
const request = (url, method = 'GET', data = {}, headers = {}) => {
|
||||
return requestWithRetry(url, method, data, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} params - 请求参数
|
||||
* @param {object} headers - 请求头
|
||||
* @returns {Promise} - 返回Promise对象
|
||||
*/
|
||||
export const get = (url, params = {}, headers = {}) => {
|
||||
const queryString = Object.keys(params)
|
||||
.map(key => `${key}=${encodeURIComponent(params[key])}`)
|
||||
.join('&');
|
||||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||
return request(fullUrl, 'GET', {}, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} data - 请求数据
|
||||
* @param {object} headers - 请求头
|
||||
* @returns {Promise} - 返回Promise对象
|
||||
*/
|
||||
export const post = (url, data = {}, headers = {}) => {
|
||||
return request(url, 'POST', data, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} data - 请求数据
|
||||
* @param {object} headers - 请求头
|
||||
* @returns {Promise} - 返回Promise对象
|
||||
*/
|
||||
export const put = (url, data = {}, headers = {}) => {
|
||||
return request(url, 'PUT', data, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
* @param {string} url - 请求URL
|
||||
* @param {object} headers - 请求头
|
||||
* @returns {Promise} - 返回Promise对象
|
||||
*/
|
||||
export const del = (url, headers = {}) => {
|
||||
return request(url, 'DELETE', {}, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* API接口封装
|
||||
*/
|
||||
console.log('📦 api.js 开始导出api对象')
|
||||
export const api = {
|
||||
/**
|
||||
* 资产相关接口
|
||||
*/
|
||||
assets: {
|
||||
/**
|
||||
* 获取资产数据
|
||||
* @returns {Promise} 返回资产数据
|
||||
*/
|
||||
getAssetData: () => {
|
||||
console.log('📤 发起 getAssetData 请求');
|
||||
return get('/api/v1/portfolio/assets');
|
||||
},
|
||||
/**
|
||||
* 获取持仓信息
|
||||
* @returns {Promise} 返回持仓信息
|
||||
*/
|
||||
getHoldings: () => {
|
||||
console.log('📤 发起 getHoldings 请求');
|
||||
return get('/api/v1/portfolio');
|
||||
},
|
||||
/**
|
||||
* 获取投资组合详情
|
||||
* @param {string|number} id - 投资组合ID
|
||||
* @returns {Promise} 返回投资组合详情
|
||||
*/
|
||||
getPortfolio: (id) => {
|
||||
console.log('📤 发起 getPortfolio 请求:', id);
|
||||
return get(`/api/v1/portfolio/${id}`);
|
||||
},
|
||||
/**
|
||||
* 获取交易记录
|
||||
* @param {object} params - 查询参数
|
||||
* @returns {Promise} 返回交易记录列表
|
||||
*/
|
||||
getTransactions: (params) => {
|
||||
console.log('📤 发起 getTransactions 请求:', params);
|
||||
return get('/api/v1/portfolio/transactions', params);
|
||||
},
|
||||
/**
|
||||
* 创建交易记录
|
||||
* @param {object} data - 交易数据
|
||||
* @returns {Promise} 返回创建结果
|
||||
*/
|
||||
createTransaction: (data) => {
|
||||
console.log('📤 发起 createTransaction 请求:', data);
|
||||
return post('/api/v1/portfolio/transactions', data);
|
||||
},
|
||||
/**
|
||||
* 创建投资组合
|
||||
* @param {object} data - 投资组合数据
|
||||
* @returns {Promise} 返回创建结果
|
||||
*/
|
||||
createPortfolio: (data) => {
|
||||
console.log('📤 发起 createPortfolio 请求:', data);
|
||||
return post('/api/v1/portfolio', data);
|
||||
},
|
||||
/**
|
||||
* 更新投资组合
|
||||
* @param {string|number} id - 投资组合ID
|
||||
* @param {object} data - 更新数据 {name, strategyId, status}
|
||||
* @returns {Promise} 返回更新结果
|
||||
*/
|
||||
updatePortfolio: (id, data) => {
|
||||
console.log('📤 发起 updatePortfolio 请求:', id, data);
|
||||
return put(`/api/v1/portfolio/${id}`, data);
|
||||
},
|
||||
/**
|
||||
* 获取投资组合策略信号
|
||||
* @param {string|number} id - 投资组合ID
|
||||
* @returns {Promise} 返回策略信号
|
||||
*/
|
||||
getPortfolioSignal: (id) => {
|
||||
console.log('📤 发起 getPortfolioSignal 请求:', id);
|
||||
return get(`/api/v1/portfolio/${id}/signal`);
|
||||
},
|
||||
/**
|
||||
* 获取投资组合净值历史(收益曲线)
|
||||
* @param {string|number} id - 投资组合ID
|
||||
* @param {object} params - 查询参数 {startDate, endDate, interval}
|
||||
* @returns {Promise} 返回净值历史数据和统计指标
|
||||
*/
|
||||
getNavHistory: (id, params = {}) => {
|
||||
console.log('📤 发起 getNavHistory 请求:', id, params);
|
||||
return get(`/api/v1/portfolio/${id}/nav-history`, params);
|
||||
},
|
||||
/**
|
||||
* 回填投资组合净值历史
|
||||
* @param {string|number} id - 投资组合ID
|
||||
* @param {boolean} force - 是否强制重新计算
|
||||
* @returns {Promise} 返回回填结果
|
||||
*/
|
||||
backfillNavHistory: (id, force = false) => {
|
||||
console.log('📤 发起 backfillNavHistory 请求:', id, { force });
|
||||
return post(`/api/v1/portfolio/${id}/nav-history/backfill`, {}, { force });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 策略相关接口
|
||||
*/
|
||||
strategies: {
|
||||
/**
|
||||
* 获取策略列表
|
||||
* @returns {Promise} 返回策略列表
|
||||
*/
|
||||
getStrategies: () => {
|
||||
console.log('📤 发起 getStrategies 请求');
|
||||
return get('/api/v1/strategy/strategies');
|
||||
},
|
||||
/**
|
||||
* 获取策略详情
|
||||
* @param {string|number} id - 策略ID
|
||||
* @returns {Promise} 返回策略详情
|
||||
*/
|
||||
getStrategy: (id) => {
|
||||
console.log('📤 发起 getStrategy 请求:', id);
|
||||
return get(`/api/v1/strategy/${id}`);
|
||||
},
|
||||
/**
|
||||
* 创建策略
|
||||
* @param {object} data - 策略数据
|
||||
* @returns {Promise} 返回创建结果
|
||||
*/
|
||||
createStrategy: (data) => {
|
||||
console.log('📤 发起 createStrategy 请求:', data);
|
||||
return post('/api/v1/strategy', data);
|
||||
},
|
||||
/**
|
||||
* 更新策略
|
||||
* @param {string|number} id - 策略ID
|
||||
* @param {object} data - 更新数据
|
||||
* @returns {Promise} 返回更新结果
|
||||
*/
|
||||
updateStrategy: (id, data) => {
|
||||
console.log('📤 发起 updateStrategy 请求:', id, data);
|
||||
return put(`/api/v1/strategy/${id}`, data);
|
||||
},
|
||||
/**
|
||||
* 删除策略
|
||||
* @param {string|number} id - 策略ID
|
||||
* @returns {Promise} 返回删除结果
|
||||
*/
|
||||
deleteStrategy: (id) => {
|
||||
console.log('📤 发起 deleteStrategy 请求:', id);
|
||||
return del(`/api/v1/strategy/${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户相关接口
|
||||
*/
|
||||
user: {
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @returns {Promise} 返回用户信息
|
||||
*/
|
||||
getUserInfo: () => {
|
||||
console.log('📤 发起 getUserInfo 请求');
|
||||
return get('/api/v1/user/info');
|
||||
},
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
* @returns {Promise} 返回用户统计数据
|
||||
*/
|
||||
getUserStats: () => {
|
||||
console.log('📤 发起 getUserStats 请求');
|
||||
return get('/api/v1/user/stats');
|
||||
},
|
||||
/**
|
||||
* 更新用户信息
|
||||
* @param {object} data - 更新数据
|
||||
* @returns {Promise} 返回更新结果
|
||||
*/
|
||||
updateUserInfo: (data) => {
|
||||
console.log('📤 发起 updateUserInfo 请求:', data);
|
||||
return put('/api/v1/user/info', data);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 股票代码相关接口
|
||||
*/
|
||||
ticker: {
|
||||
/**
|
||||
* 模糊搜索股票代码
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @param {number} limit - 返回数量上限
|
||||
* @returns {Promise} 返回搜索结果
|
||||
*/
|
||||
search: (keyword, limit = 20) => {
|
||||
console.log('📤 发起 ticker.search 请求:', { keyword, limit });
|
||||
return get('/api/v1/ticker/search', { keyword, limit });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('📦 api.js 导出默认api对象')
|
||||
|
||||
export default api;
|
||||
326
utils/api.ts
Normal file
326
utils/api.ts
Normal file
@ -0,0 +1,326 @@
|
||||
/**
|
||||
* API工具类 - 统一封装后端API请求
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiResponse,
|
||||
TotalAssetsResponse,
|
||||
GetPortfoliosResponse,
|
||||
PortfolioDetailResponse,
|
||||
CreatePortfolioRequest,
|
||||
CreatePortfolioResponse,
|
||||
UpdatePortfolioRequest,
|
||||
GetTransactionsResponse,
|
||||
CreateTransactionRequest,
|
||||
CreateTransactionResponse,
|
||||
NavHistoryResponse,
|
||||
BackfillNavResponse,
|
||||
StrategyListResponse,
|
||||
StrategyDetailResponse,
|
||||
CreateStrategyRequest,
|
||||
UpdateStrategyRequest,
|
||||
StrategyResponse,
|
||||
DeleteStrategyResponse,
|
||||
StrategySignalResponse,
|
||||
UserInfoResponse,
|
||||
UserStatsResponse,
|
||||
UpdateUserRequest,
|
||||
UpdateUserResponse,
|
||||
} from '@/types';
|
||||
|
||||
// API基础URL
|
||||
const BASE_URL: string = import.meta.env.VITE_API_BASE_URL || 'https://localhost:7040/';
|
||||
|
||||
// 请求超时时间
|
||||
const TIMEOUT = 10000;
|
||||
|
||||
// 登录锁,防止并发登录
|
||||
let loginLock: Promise<any> | null = null;
|
||||
|
||||
// 全局loading计数
|
||||
let loadingCount = 0;
|
||||
|
||||
const showLoading = (): void => {
|
||||
if (loadingCount === 0) {
|
||||
uni.showLoading({ title: '加载中...', mask: true });
|
||||
}
|
||||
loadingCount++;
|
||||
};
|
||||
|
||||
const hideLoading = (): void => {
|
||||
loadingCount--;
|
||||
if (loadingCount <= 0) {
|
||||
loadingCount = 0;
|
||||
uni.hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信登录码
|
||||
*/
|
||||
const getWechatCode = (): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res.code) {
|
||||
resolve(res.code);
|
||||
} else {
|
||||
reject(new Error(`微信登录码获取失败: ${res.errMsg}`));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new Error(`微信登录失败: ${err.errMsg}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行微信登录
|
||||
*/
|
||||
const doWechatLogin = async (): Promise<ApiResponse<{ token: string; userId: string }>> => {
|
||||
const code = await getWechatCode();
|
||||
|
||||
const res = await new Promise<any>((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${BASE_URL}api/auth/wechat-login`,
|
||||
method: 'POST',
|
||||
data: { code },
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
timeout: TIMEOUT,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
|
||||
if (res.statusCode === 200 && res.data?.code === 200 && res.data?.data?.token) {
|
||||
uni.setStorageSync('token', res.data.data.token);
|
||||
return res.data;
|
||||
} else {
|
||||
throw new Error(res.data?.message || '微信登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 带重试机制的请求方法
|
||||
*/
|
||||
const requestWithRetry = async <T = any>(
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
data: Record<string, any> = {},
|
||||
headers: Record<string, string> = {},
|
||||
retryCount: number = 0
|
||||
): Promise<T> => {
|
||||
try {
|
||||
let token = uni.getStorageSync('token') as string | undefined;
|
||||
|
||||
// 如果没有token,先执行微信登录
|
||||
if (!token) {
|
||||
if (loginLock) {
|
||||
await loginLock;
|
||||
token = uni.getStorageSync('token');
|
||||
} else {
|
||||
loginLock = doWechatLogin();
|
||||
await loginLock;
|
||||
loginLock = null;
|
||||
token = uni.getStorageSync('token');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理URL拼接
|
||||
let fullUrl: string;
|
||||
if (BASE_URL.endsWith('/') && url.startsWith('/')) {
|
||||
fullUrl = BASE_URL + url.substring(1);
|
||||
} else if (!BASE_URL.endsWith('/') && !url.startsWith('/')) {
|
||||
fullUrl = BASE_URL + '/' + url;
|
||||
} else {
|
||||
fullUrl = BASE_URL + url;
|
||||
}
|
||||
|
||||
showLoading();
|
||||
|
||||
const res = await new Promise<any>((resolve, reject) => {
|
||||
uni.request({
|
||||
url: fullUrl,
|
||||
method,
|
||||
data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
...headers
|
||||
},
|
||||
timeout: TIMEOUT,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
|
||||
hideLoading();
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
return res.data as T;
|
||||
} else if (res.statusCode === 401) {
|
||||
uni.removeStorageSync('token');
|
||||
|
||||
if (retryCount < 3) {
|
||||
if (loginLock) {
|
||||
await loginLock;
|
||||
} else {
|
||||
loginLock = doWechatLogin();
|
||||
await loginLock;
|
||||
loginLock = null;
|
||||
}
|
||||
return requestWithRetry<T>(url, method, data, headers, retryCount + 1);
|
||||
} else {
|
||||
uni.showToast({ title: '系统异常,请稍后重试', icon: 'none' });
|
||||
throw new Error('登录已过期,重试次数超限');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`请求失败: ${res.statusCode}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
hideLoading();
|
||||
|
||||
if (retryCount < 3 && error.message?.includes('网络请求失败')) {
|
||||
return requestWithRetry<T>(url, method, data, headers, retryCount + 1);
|
||||
}
|
||||
|
||||
if (retryCount >= 2) {
|
||||
uni.showToast({ title: '系统异常,请稍后重试', icon: 'none', duration: 2000 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 基础请求方法
|
||||
*/
|
||||
const request = <T = any>(
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
data: Record<string, any> = {},
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<T> => {
|
||||
return requestWithRetry<T>(url, method, data, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
*/
|
||||
export const get = <T = any>(
|
||||
url: string,
|
||||
params: Record<string, any> = {},
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<T> => {
|
||||
const queryString = Object.keys(params)
|
||||
.map(key => `${key}=${encodeURIComponent(params[key])}`)
|
||||
.join('&');
|
||||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||
return request<T>(fullUrl, 'GET', {}, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
*/
|
||||
export const post = <T = any>(
|
||||
url: string,
|
||||
data: Record<string, any> = {},
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<T> => {
|
||||
return request<T>(url, 'POST', data, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
*/
|
||||
export const put = <T = any>(
|
||||
url: string,
|
||||
data: Record<string, any> = {},
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<T> => {
|
||||
return request<T>(url, 'PUT', data, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
*/
|
||||
export const del = <T = any>(
|
||||
url: string,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<T> => {
|
||||
return request<T>(url, 'DELETE', {}, headers);
|
||||
};
|
||||
|
||||
/**
|
||||
* API接口封装
|
||||
*/
|
||||
export const api = {
|
||||
assets: {
|
||||
getAssetData: (): Promise<ApiResponse<TotalAssetsResponse>> =>
|
||||
get('/api/v1/portfolio/assets'),
|
||||
|
||||
getHoldings: (): Promise<ApiResponse<GetPortfoliosResponse>> =>
|
||||
get('/api/v1/portfolio'),
|
||||
|
||||
getPortfolio: (id: string | number): Promise<ApiResponse<PortfolioDetailResponse>> =>
|
||||
get(`/api/v1/portfolio/${id}`),
|
||||
|
||||
getTransactions: (params: { portfolioId?: string; limit: number; offset: number }): Promise<ApiResponse<GetTransactionsResponse>> =>
|
||||
get('/api/v1/portfolio/transactions', params),
|
||||
|
||||
createTransaction: (data: CreateTransactionRequest): Promise<ApiResponse<CreateTransactionResponse>> =>
|
||||
post('/api/v1/portfolio/transactions', data),
|
||||
|
||||
createPortfolio: (data: CreatePortfolioRequest): Promise<ApiResponse<CreatePortfolioResponse>> =>
|
||||
post('/api/v1/portfolio', data),
|
||||
|
||||
updatePortfolio: (id: string | number, data: UpdatePortfolioRequest): Promise<ApiResponse<void>> =>
|
||||
put(`/api/v1/portfolio/${id}`, data),
|
||||
|
||||
getPortfolioSignal: (id: string | number): Promise<ApiResponse<StrategySignalResponse>> =>
|
||||
get(`/api/v1/portfolio/${id}/signal`),
|
||||
|
||||
getNavHistory: (id: string | number, params?: { startDate?: string; endDate?: string; interval?: string }): Promise<ApiResponse<NavHistoryResponse>> =>
|
||||
get(`/api/v1/portfolio/${id}/nav-history`, params),
|
||||
|
||||
backfillNavHistory: (id: string | number, force: boolean = false): Promise<ApiResponse<BackfillNavResponse>> =>
|
||||
post(`/api/v1/portfolio/${id}/nav-history/backfill`, { force }),
|
||||
},
|
||||
|
||||
strategies: {
|
||||
getStrategies: (): Promise<ApiResponse<StrategyListResponse>> =>
|
||||
get('/api/v1/strategy/strategies'),
|
||||
|
||||
getStrategy: (id: string | number): Promise<ApiResponse<StrategyDetailResponse>> =>
|
||||
get(`/api/v1/strategy/${id}`),
|
||||
|
||||
createStrategy: (data: CreateStrategyRequest): Promise<ApiResponse<StrategyResponse>> =>
|
||||
post('/api/v1/strategy', data),
|
||||
|
||||
updateStrategy: (id: string | number, data: UpdateStrategyRequest): Promise<ApiResponse<StrategyResponse>> =>
|
||||
put(`/api/v1/strategy/${id}`, data),
|
||||
|
||||
deleteStrategy: (id: string | number): Promise<ApiResponse<DeleteStrategyResponse>> =>
|
||||
del(`/api/v1/strategy/${id}`),
|
||||
},
|
||||
|
||||
user: {
|
||||
getUserInfo: (): Promise<ApiResponse<UserInfoResponse>> =>
|
||||
get('/api/v1/user/info'),
|
||||
|
||||
getUserStats: (): Promise<ApiResponse<UserStatsResponse>> =>
|
||||
get('/api/v1/user/stats'),
|
||||
|
||||
updateUserInfo: (data: UpdateUserRequest): Promise<ApiResponse<UpdateUserResponse>> =>
|
||||
put('/api/v1/user/info', data),
|
||||
},
|
||||
|
||||
ticker: {
|
||||
search: (keyword: string, limit: number = 20): Promise<ApiResponse<{ items: { symbol: string; name: string }[] }>> =>
|
||||
get('/api/v1/ticker/search', { keyword, limit }),
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
@ -1,57 +0,0 @@
|
||||
/**
|
||||
* 货币工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取货币符号
|
||||
* @param {string} currency - 货币代码 (CNY, USD, HKD 等)
|
||||
* @returns {string} 货币符号
|
||||
*/
|
||||
export const getCurrencySymbol = (currency) => {
|
||||
const symbols = {
|
||||
'CNY': '¥',
|
||||
'USD': '$',
|
||||
'HKD': 'HK$',
|
||||
'EUR': '€',
|
||||
'GBP': '£',
|
||||
'JPY': '¥',
|
||||
'SGD': 'S$',
|
||||
'AUD': 'A$'
|
||||
};
|
||||
return symbols[currency?.toUpperCase()] || '¥';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化金额
|
||||
* @param {number} amount - 金额
|
||||
* @param {string} currency - 货币代码
|
||||
* @param {number} decimals - 小数位数
|
||||
* @returns {string} 格式化后的金额字符串
|
||||
*/
|
||||
export const formatAmount = (amount, currency = 'CNY', decimals = 2) => {
|
||||
const symbol = getCurrencySymbol(currency);
|
||||
const formatted = Math.abs(amount || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
const sign = amount >= 0 ? '' : '-';
|
||||
return `${sign}${symbol}${formatted}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化金额(带正负号)
|
||||
* @param {number} amount - 金额
|
||||
* @param {string} currency - 货币代码
|
||||
* @param {number} decimals - 小数位数
|
||||
* @returns {string} 格式化后的金额字符串
|
||||
*/
|
||||
export const formatAmountWithSign = (amount, currency = 'CNY', decimals = 2) => {
|
||||
const symbol = getCurrencySymbol(currency);
|
||||
const formatted = Math.abs(amount || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
if (amount > 0) return `+${symbol}${formatted}`;
|
||||
if (amount < 0) return `-${symbol}${formatted}`;
|
||||
return `${symbol}${formatted}`;
|
||||
};
|
||||
68
utils/currency.ts
Normal file
68
utils/currency.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 货币工具函数
|
||||
*/
|
||||
|
||||
import type { CurrencyCode } from '@/types/portfolio';
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
'CNY': '¥',
|
||||
'USD': '$',
|
||||
'HKD': 'HK$',
|
||||
'EUR': '€',
|
||||
'GBP': '£',
|
||||
'JPY': '¥',
|
||||
'SGD': 'S$',
|
||||
'AUD': 'A$'
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取货币符号
|
||||
* @param currency - 货币代码 (CNY, USD, HKD 等)
|
||||
* @returns 货币符号
|
||||
*/
|
||||
export function getCurrencySymbol(currency?: string): string {
|
||||
return CURRENCY_SYMBOLS[currency?.toUpperCase() ?? 'CNY'] || '¥';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额
|
||||
* @param amount - 金额
|
||||
* @param currency - 货币代码
|
||||
* @param decimals - 小数位数
|
||||
* @returns 格式化后的金额字符串
|
||||
*/
|
||||
export function formatAmount(
|
||||
amount: number,
|
||||
currency: CurrencyCode | string = 'CNY',
|
||||
decimals: number = 2
|
||||
): string {
|
||||
const symbol = getCurrencySymbol(currency);
|
||||
const formatted = Math.abs(amount || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
const sign = amount >= 0 ? '' : '-';
|
||||
return `${sign}${symbol}${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额(带正负号)
|
||||
* @param amount - 金额
|
||||
* @param currency - 货币代码
|
||||
* @param decimals - 小数位数
|
||||
* @returns 格式化后的金额字符串
|
||||
*/
|
||||
export function formatAmountWithSign(
|
||||
amount: number,
|
||||
currency: CurrencyCode | string = 'CNY',
|
||||
decimals: number = 2
|
||||
): string {
|
||||
const symbol = getCurrencySymbol(currency);
|
||||
const formatted = Math.abs(amount || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
if (amount > 0) return `+${symbol}${formatted}`;
|
||||
if (amount < 0) return `-${symbol}${formatted}`;
|
||||
return `${symbol}${formatted}`;
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()]
|
||||
})
|
||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/home/node/.openclaw/workspace/AssetManager/AssetManager.UniApp',
|
||||
'@/utils': '/home/node/.openclaw/workspace/AssetManager/AssetManager.UniApp/utils',
|
||||
'@/types': '/home/node/.openclaw/workspace/AssetManager/AssetManager.UniApp/types'
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user