feat: 开发我的持仓列表

This commit is contained in:
R524809
2026-01-13 16:46:18 +08:00
parent 838a021ce5
commit b7972153cc
29 changed files with 1325 additions and 211 deletions

View File

@@ -150,7 +150,7 @@
/* 内容区域 */
.main-content {
padding: 24px;
background: #f9fafb;
background: #f3f4f6;
min-height: calc(100vh - 64px);
}

View File

@@ -10,6 +10,7 @@ import {
import type { MenuProps } from 'antd';
import { authService } from '@/services/auth';
import type { UserInfo } from '@/types/user';
import { useBrokerStore } from '@/stores/broker';
import SidebarMenu from './SidebarMenu';
import ErrorBoundary from '@/components/ErrorBoundary';
import { getPageInfo } from './menuConfig';
@@ -31,6 +32,11 @@ const MainLayout = () => {
setUser(currentUser);
}, []);
// 初始化券商数据
useEffect(() => {
useBrokerStore.getState().fetchBrokers();
}, []);
// 根据路由获取页面标题
const pageInfo = useMemo(() => {
return getPageInfo(location.pathname);

View File

@@ -64,15 +64,15 @@ export const routeMenuConfig: RouteMenuConfig[] = [
subtitle: '回顾过去是为了更好应对将来',
group: 'main',
},
{
path: '/user-info',
key: '/user-info',
icon: <UserOutlined />,
label: '个人资料',
title: '个人资料',
subtitle: '查看和编辑个人信息',
group: 'main',
},
// {
// path: '/user-info',
// key: '/user-info',
// icon: <UserOutlined />,
// label: '个人资料',
// title: '个人资料',
// subtitle: '查看和编辑个人信息',
// group: 'main',
// },
{
path: '/user',
key: '/user',

View File

@@ -1,113 +0,0 @@
.assets-page {
max-width: 1400px;
margin: 0 auto;
}
.stats-row {
margin-bottom: 24px;
}
.stat-change {
font-size: 12px;
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.stat-change.positive {
color: #ef4444;
}
.stat-change.negative {
color: #10b981;
}
.chart-card {
margin-bottom: 24px;
}
.chart-placeholder {
height: 400px;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
border-radius: 8px;
color: #6b7280;
}
.positions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.position-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s;
}
.position-item:hover {
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
}
.position-info {
flex: 1;
}
.position-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
color: #1f2937;
}
.position-code {
font-size: 12px;
color: #6b7280;
}
.position-stats {
text-align: right;
}
.position-value {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
color: #1f2937;
}
.position-profit {
font-size: 14px;
}
.position-profit.positive {
color: #ef4444;
}
.position-profit.negative {
color: #10b981;
}
@media (max-width: 768px) {
.position-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.position-stats {
text-align: left;
width: 100%;
}
}

View File

@@ -0,0 +1,201 @@
/* 持仓网格布局 - 响应式 */
.positions-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 768px) {
.positions-grid {
gap: 16px;
}
}
@media (min-width: 1024px) {
.positions-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 持仓卡片 */
.position-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
transition: all 0.2s;
}
.position-card:hover {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* 持仓卡片内容区域 */
.position-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
/* 左侧:基本信息 */
.position-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.position-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
line-height: 1.4;
}
.position-meta-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
color: #6b7280;
}
.position-symbol {
font-weight: 500;
}
.meta-tag {
padding: 2px 6px;
background: #f3f4f6;
border-radius: 4px;
font-size: 11px;
color: #6b7280;
}
.position-holding-info {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.position-holding-days {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
/* 右侧:价格和盈亏信息 */
.position-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
text-align: right;
}
.position-current-price {
font-size: 14px;
font-weight: 500;
color: #6b7280;
line-height: 1.4;
}
.position-market-value {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.position-value-text {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.position-profit {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
margin-top: 4px;
}
.position-profit-amount {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
}
.position-profit-percent {
font-size: 12px;
font-weight: 600;
line-height: 1.4;
}
/* 响应式调整 */
@media (min-width: 768px) {
.position-name {
font-size: 18px;
}
.position-current-price {
font-size: 16px;
}
.position-value-text {
font-size: 18px;
}
.position-profit-amount {
font-size: 18px;
}
.position-profit-percent {
font-size: 14px;
}
}
/* 分割线和更新按钮 */
.position-footer {
border-top: 1px solid #e5e7eb;
padding-top: 12px;
margin-top: 12px;
}
.position-update-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
color: #8b5cf6;
font-size: 12px;
padding: 0;
height: auto;
background: transparent;
border: none;
cursor: pointer;
transition: color 0.2s;
}
.position-update-btn:hover {
color: rgba(139, 92, 246, 0.8);
background: transparent;
}
.position-update-icon {
font-size: 12px;
}
@media (min-width: 768px) {
.position-update-btn {
font-size: 14px;
}
.position-update-icon {
font-size: 16px;
}
}

View File

@@ -1,9 +1,10 @@
import { Card, Row, Col, Statistic, Button } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { Card, Row, Col, Statistic } from 'antd';
import { ArrowUpOutlined } from '@ant-design/icons';
import PositionList from './components/PositionList';
import './AssetsPage.css';
const AssetsPage = () => {
// 写死的数据
// 写死的数据(占位)
const stats = {
totalAssets: 1234567,
totalProfit: 234567,
@@ -11,36 +12,6 @@ const AssetsPage = () => {
recordDays: 300,
};
const positions = [
{
name: '贵州茅台',
code: '600519',
market: '上海',
broker: '华泰证券',
value: 456789,
profit: 56789,
profitRate: 14.2,
},
{
name: '腾讯控股',
code: '00700',
market: '香港',
broker: '富途证券',
value: 345678,
profit: 45678,
profitRate: 15.2,
},
{
name: '苹果公司',
code: 'AAPL',
market: '美股',
broker: '盈透证券',
value: 432100,
profit: -12100,
profitRate: -2.7,
},
];
return (
<div className="assets-page">
{/* 统计卡片 */}
@@ -104,42 +75,7 @@ const AssetsPage = () => {
</Card>
{/* 持仓列表 */}
<Card
title="我的持仓"
extra={
<Button type="primary" size="small">
+
</Button>
}
>
<div className="positions-list">
{positions.map((position, index) => (
<div key={index} className="position-item">
<div className="position-info">
<div className="position-name">{position.name}</div>
<div className="position-code">
{position.code} · {position.market} · {position.broker}
</div>
</div>
<div className="position-stats">
<div className="position-value">
¥{position.value.toLocaleString()}
</div>
<div
className={`position-profit ${
position.profit >= 0 ? 'positive' : 'negative'
}`}
>
{position.profit >= 0 ? '+' : ''}¥
{Math.abs(position.profit).toLocaleString()} (
{position.profitRate >= 0 ? '+' : ''}
{position.profitRate}%)
</div>
</div>
</div>
))}
</div>
</Card>
<PositionList />
</div>
);
};

View File

@@ -0,0 +1,73 @@
/* 我的持仓容器 */
.position-list-container {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
/* 标题和按钮区域 */
.position-list-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.position-list-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #1f2937;
}
@media (min-width: 768px) {
.position-list-title {
font-size: 18px;
}
}
/* 添加资产按钮 - 完全圆形 */
.position-add-btn {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border: 1px solid #8b5cf6 !important;
background: transparent !important;
color: #8b5cf6 !important;
border-radius: 50% !important;
cursor: pointer;
transition: all 0.2s;
}
.position-add-btn:hover,
.position-add-btn:focus {
background: #8b5cf6 !important;
color: #fff !important;
border-color: #8b5cf6 !important;
}
.position-add-btn:active {
background: #7c3aed !important;
border-color: #7c3aed !important;
color: #fff !important;
}
.position-add-btn .anticon {
font-size: 16px;
}
@media (min-width: 768px) {
.position-add-btn {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
}
.position-add-btn .anticon {
font-size: 18px;
}
}

View File

@@ -0,0 +1,160 @@
import { useState, useEffect } from 'react';
import { Card, Button, App, Spin } from 'antd';
import { PlusOutlined, RightOutlined } from '@ant-design/icons';
import { positionService } from '@/services/position';
import type { PositionResponse } from '@/types/position';
import { useBrokerStore } from '@/stores/broker';
import { useMarketStore } from '@/stores/market';
import '../AssetsPage.css';
import './PositionList.css';
const PositionList = () => {
const { message: messageApi } = App.useApp();
const [loading, setLoading] = useState(false);
const [positions, setPositions] = useState<PositionResponse[]>([]);
const getBrokerName = useBrokerStore((state) => state.getBrokerName);
const getMarketName = useMarketStore((state) => state.getMarketName);
// 加载持仓数据
const loadPositions = async () => {
setLoading(true);
try {
const positionData = await positionService.getPositionsByUserId();
if (positionData && positionData.code === 0) {
const positionList = positionData.data;
setPositions(positionList || []);
} else {
setPositions([]);
messageApi.error(positionData.message || '加载持仓数据失败');
}
} catch (error: any) {
messageApi.error(error.message || '加载持仓数据失败');
setPositions([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPositions();
}, []);
// 格式化价格显示(根据涨跌显示颜色)
const formatPrice = (currentPrice?: number, previousPrice?: number) => {
if (!currentPrice) return { text: '--', color: '#1f2937' };
const price = currentPrice.toFixed(2);
if (!previousPrice) return { text: price, color: '#1f2937' };
if (currentPrice > previousPrice) {
return { text: price, color: '#ef4444' }; // 红色(上涨)
} else if (currentPrice < previousPrice) {
return { text: price, color: '#10b981' }; // 绿色(下跌)
}
return { text: price, color: '#1f2937' };
};
// 格式化盈亏显示
const formatProfit = (profit: number, profitPercent: number) => {
const isPositive = profit >= 0;
const profitText = `${isPositive ? '+' : ''}${Math.abs(profit).toLocaleString()}`;
const percentText = `${isPositive ? '+' : ''}${profitPercent.toFixed(2)}%`;
const color = isPositive ? '#ef4444' : '#10b981';
return { profitText, percentText, color };
};
return (
<div className="position-list-container">
<div className="position-list-header">
<h2 className="position-list-title"></h2>
<Button
type="default"
shape="circle"
icon={<PlusOutlined />}
className="position-add-btn"
/>
</div>
<Spin spinning={loading}>
<div className="positions-grid">
{positions &&
positions.map((position) => {
const priceInfo = formatPrice(
position.currentPrice,
position.previousPrice
);
const profitInfo = formatProfit(
position.profit,
position.profitPercent
);
const brokerName = getBrokerName(position.brokerId);
const marketText = getMarketName(position.market);
return (
<Card key={position.positionId} className="position-card">
<div className="position-content">
{/* 左侧:基本信息 */}
<div className="position-left">
<div className="position-name">{position.name}</div>
<div className="position-meta-info">
<span className="position-symbol">
{position.symbol}
</span>
{marketText && (
<span className="meta-tag">{marketText}</span>
)}
{brokerName && (
<span className="meta-tag">{brokerName}</span>
)}
</div>
<div className="position-holding-info">
<span>
{position.shares.toLocaleString()}
</span>
</div>
<div className="position-holding-days">
<span> {position.holdingDays} </span>
</div>
</div>
{/* 右侧:价格和盈亏信息 */}
<div className="position-right">
<div
className="position-current-price"
style={{ color: priceInfo.color }}
>
{priceInfo.text}
</div>
<div className="position-market-value">
<span className="position-value-text">
{position.marketValue.toLocaleString()}
</span>
</div>
<div
className="position-profit"
style={{ color: profitInfo.color }}
>
<span className="position-profit-amount">
{profitInfo.profitText}
</span>
<span className="position-profit-percent">
{profitInfo.percentText}
</span>
</div>
</div>
</div>
{/* 分割线和更新按钮 */}
<div className="position-footer">
<button className="position-update-btn">
<span></span>
<RightOutlined className="position-update-icon" />
</button>
</div>
</Card>
);
})}
</div>
</Spin>
</div>
);
};
export default PositionList;

View File

@@ -0,0 +1 @@
export { default } from './AssetsPage';

View File

@@ -4,7 +4,7 @@ import MainLayout from '../layouts/MainLayout';
import ProtectedRoute from '../components/ProtectedRoute';
import ErrorPage from '../components/ErrorPage';
import LoginPage from '../pages/LoginPage';
const AssetsPage = lazy(() => import('../pages/AssetsPage'));
const AssetsPage = lazy(() => import('../pages/assets'));
const PlansPage = lazy(() => import('../pages/PlansPage'));
const ReviewPage = lazy(() => import('../pages/ReviewPage'));
const BrokerPage = lazy(() => import('../pages/broker'));

View File

@@ -0,0 +1,45 @@
import { api } from './api';
import type { ApiResponse } from '@/types/common';
import type {
PositionResponse,
CreatePositionRequest,
UpdatePositionRequest,
} from '@/types/position';
/**
* 持仓服务
*/
class PositionService {
/**
* 查询用户的所有持仓(不分页)
*/
async getPositionsByUserId(): Promise<ApiResponse<PositionResponse[]>> {
return await api.get<ApiResponse<PositionResponse[]>>('/position');
}
/**
* 创建持仓
*/
async createPosition(data: CreatePositionRequest): Promise<ApiResponse<PositionResponse>> {
return await api.post<ApiResponse<PositionResponse>>('/position', data);
}
/**
* 更新持仓
*/
async updatePosition(
id: number,
data: UpdatePositionRequest
): Promise<ApiResponse<PositionResponse>> {
return await api.patch<ApiResponse<PositionResponse>>(`/position/${id}`, data);
}
/**
* 删除持仓
*/
async deletePosition(id: number): Promise<void> {
await api.delete(`/position/${id}`);
}
}
export const positionService = new PositionService();

View File

@@ -0,0 +1,54 @@
import { create } from 'zustand';
import { brokerService } from '@/services/broker';
import type { Broker } from '@/types/broker';
interface BrokerStore {
brokers: Broker[];
loading: boolean;
initialized: boolean;
fetchBrokers: () => Promise<void>;
getBrokerName: (brokerId: number) => string;
}
export const useBrokerStore = create<BrokerStore>((set, get) => ({
brokers: [],
loading: false,
initialized: false,
/**
* 获取券商列表
*/
fetchBrokers: async () => {
const { initialized } = get();
// 如果已经初始化过,不重复加载
if (initialized) {
return;
}
set({ loading: true });
try {
const response = await brokerService.getBrokerList({
page: 1,
limit: 100,
isActive: true,
});
set({
brokers: response.list || [],
initialized: true,
loading: false,
});
} catch (error) {
console.error('获取券商列表失败:', error);
set({ loading: false });
}
},
/**
* 通过券商ID获取券商名称
*/
getBrokerName: (brokerId: number) => {
const { brokers } = get();
const broker = brokers.find((b) => b.brokerId === brokerId);
return broker?.brokerName || '';
},
}));

View File

@@ -0,0 +1,50 @@
import { create } from 'zustand';
export interface MarketItem {
code: string;
name: string;
}
interface MarketStore {
marketMap: Record<string, string>;
getMarketName: (marketCode?: string) => string;
getAllMarkets: () => MarketItem[];
}
// 市场简称和名称的映射
const MARKET_MAP: Record<string, string> = {
sh: '上海',
sz: '深圳',
bj: '北京',
hk: '香港',
us: '美股',
jp: '日股',
kr: '韩国股市',
eu: '欧洲市场',
sea: '东南亚',
other: '其他',
};
export const useMarketStore = create<MarketStore>((_set, get) => ({
marketMap: MARKET_MAP,
/**
* 通过市场代码获取市场名称
*/
getMarketName: (marketCode?: string) => {
if (!marketCode) return '';
const { marketMap } = get();
return marketMap[marketCode] || marketCode;
},
/**
* 获取所有市场列表
*/
getAllMarkets: () => {
const { marketMap } = get();
return Object.entries(marketMap).map(([code, name]) => ({
code,
name,
}));
},
}));

View File

@@ -4,6 +4,6 @@
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
data?: T;
timestamp?: string;
}

View File

@@ -0,0 +1,82 @@
/**
* 分页信息
*/
export interface PaginationInfo {
total: number;
total_page: number;
page_size: number;
current_page: number;
}
/**
* 持仓信息
*/
export interface Position {
positionId: number;
userId: number;
brokerId: number;
assetType: string;
symbol: string;
name: string;
market?: string;
shares: number;
costPrice: number;
currentPrice?: number;
previousPrice?: number;
currency: string;
exchangeRate?: number;
autoPriceUpdate: boolean;
status: string;
createdAt: Date;
updatedAt: Date;
}
/**
* 持仓响应数据(包含计算字段)
*/
export interface PositionResponse extends Position {
costValue: number; // 持仓成本
marketValue: number; // 持仓市值
profit: number; // 持仓盈亏
profitPercent: number; // 持仓盈利比例(%
holdingDays: number; // 持仓天数
assetPercent: number; // 占用户总资产百分比(%
}
/**
* 分页响应
*/
export interface PaginatedPositionResponse {
list: PositionResponse[];
pagination: PaginationInfo;
}
/**
* 创建持仓请求
*/
export interface CreatePositionRequest {
brokerId: number;
assetType: string;
symbol: string;
name: string;
market?: string;
shares: number;
costPrice: number;
currentPrice?: number;
currency?: string;
exchangeRate?: number;
autoPriceUpdate?: boolean;
status?: string;
}
/**
* 更新持仓请求
*/
export interface UpdatePositionRequest {
brokerId?: number;
costPrice?: number;
currentPrice?: number;
currency?: string;
exchangeRate?: number;
status?: string;
}