feat: 开发持仓、股票信息相关接口

This commit is contained in:
R524809
2026-01-12 17:38:55 +08:00
parent 67e4dc6382
commit 838a021ce5
46 changed files with 4407 additions and 12 deletions

View File

@@ -63,7 +63,7 @@ const MainLayout = () => {
key: 'profile',
icon: <UserOutlined />,
label: '个人资料',
disabled: true, // 暂时禁用
onClick: () => navigate('/user-info'),
},
{
type: 'divider',

View File

@@ -6,6 +6,8 @@ import {
DashboardOutlined,
BankOutlined,
UserOutlined,
StockOutlined,
LineChartOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
@@ -62,6 +64,15 @@ export const routeMenuConfig: RouteMenuConfig[] = [
subtitle: '回顾过去是为了更好应对将来',
group: 'main',
},
{
path: '/user-info',
key: '/user-info',
icon: <UserOutlined />,
label: '个人资料',
title: '个人资料',
subtitle: '查看和编辑个人信息',
group: 'main',
},
{
path: '/user',
key: '/user',
@@ -82,6 +93,26 @@ export const routeMenuConfig: RouteMenuConfig[] = [
group: 'admin',
requireAdmin: true,
},
{
path: '/stock-info',
key: '/stock-info',
icon: <StockOutlined />,
label: '股票信息',
title: '股票信息',
subtitle: '管理股票基本信息',
group: 'admin',
requireAdmin: true,
},
{
path: '/stock-daily-price',
key: '/stock-daily-price',
icon: <LineChartOutlined />,
label: '股票价格',
title: '股票价格',
subtitle: '查看股票每日价格数据',
group: 'admin',
requireAdmin: true,
},
{
path: '/seo',
key: '/seo',

View File

@@ -0,0 +1,28 @@
.stock-daily-price-page {
padding: 0;
}
.stock-daily-price-search-form {
margin-bottom: 16px;
}
.stock-daily-price-search-form .ant-form-item {
margin-bottom: 16px;
}
/* 表格样式优化 */
.stock-daily-price-page .ant-table {
background: #fff;
}
.stock-daily-price-page .ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
}
/* 响应式 */
@media (max-width: 768px) {
.stock-daily-price-search-form .ant-form-item {
margin-bottom: 12px;
}
}

View File

@@ -0,0 +1,384 @@
import { useState, useEffect, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Space,
Card,
Form,
Row,
Col,
App as AntdApp,
Tag,
DatePicker,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { stockDailyPriceService } from '@/services/stock-daily-price';
import type { StockDailyPrice, QueryStockDailyPriceRequest } from '@/types/stock-daily-price';
import { MARKET_OPTIONS, getMarketText } from '@/types/stock-daily-price';
import dayjs from 'dayjs';
import './StockDailyPricePage.css';
const { Option } = Select;
const { RangePicker } = DatePicker;
const StockDailyPricePage = () => {
const { message: messageApi } = AntdApp.useApp();
const [prices, setPrices] = useState<StockDailyPrice[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [form] = Form.useForm();
const formRef = useRef<QueryStockDailyPriceRequest>({});
// 初始化默认查询最近7天
useEffect(() => {
const endDate = dayjs();
const startDate = endDate.subtract(6, 'day'); // 最近7天包含今天
form.setFieldsValue({
dateRange: [startDate, endDate],
});
formRef.current = {
startDate: startDate.format('YYYY-MM-DD'),
endDate: endDate.format('YYYY-MM-DD'),
};
loadData(formRef.current, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 加载数据
const loadData = async (params?: QueryStockDailyPriceRequest, resetPage = false) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : pagination.current;
const pageSize = pagination.pageSize;
const queryParams: QueryStockDailyPriceRequest = {
page: currentPage,
limit: pageSize,
sortBy: 'tradeDate',
sortOrder: 'DESC',
...formRef.current,
...params,
};
const response = await stockDailyPriceService.getStockDailyPriceList(queryParams);
setPrices(response.list);
setPagination((prev) => ({
...prev,
current: response.pagination.current_page,
pageSize: response.pagination.page_size,
total: response.pagination.total,
}));
} catch (error: any) {
messageApi.error(error.message || '加载股票价格列表失败');
} finally {
setLoading(false);
}
};
// 当分页改变时,重新加载数据
useEffect(() => {
if (pagination.current > 0 && pagination.pageSize > 0) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.current, pagination.pageSize]);
// 查询
const handleSearch = () => {
const values = form.getFieldsValue();
const queryParams: QueryStockDailyPriceRequest = {
stockCode: values.stockCode || undefined,
stockName: values.stockName || undefined,
market: values.market || undefined,
};
// 处理日期范围
if (values.dateRange && values.dateRange.length === 2) {
queryParams.startDate = values.dateRange[0].format('YYYY-MM-DD');
queryParams.endDate = values.dateRange[1].format('YYYY-MM-DD');
}
formRef.current = queryParams;
loadData(queryParams, true);
};
// 重置
const handleReset = () => {
form.resetFields();
// 重置为默认的最近7天
const endDate = dayjs();
const startDate = endDate.subtract(6, 'day');
form.setFieldsValue({
dateRange: [startDate, endDate],
});
formRef.current = {
startDate: startDate.format('YYYY-MM-DD'),
endDate: endDate.format('YYYY-MM-DD'),
};
loadData(formRef.current, true);
};
// 格式化价格
const formatPrice = (price?: number) => {
if (price === null || price === undefined) return '-';
return price.toFixed(2);
};
// 格式化金额
const formatAmount = (amount?: number) => {
if (amount === null || amount === undefined) return '-';
if (amount >= 100000000) {
return `${(amount / 100000000).toFixed(2)}亿`;
}
if (amount >= 10000) {
return `${(amount / 10000).toFixed(2)}`;
}
return amount.toFixed(2);
};
// 格式化成交量
const formatVolume = (volume?: number) => {
if (volume === null || volume === undefined) return '-';
if (volume >= 10000) {
return `${(volume / 10000).toFixed(2)}万手`;
}
return `${volume}`;
};
// 表格列定义
const columns: ColumnsType<StockDailyPrice> = [
{
title: '股票代码',
dataIndex: 'stockCode',
key: 'stockCode',
width: 120,
fixed: 'left',
},
{
title: '股票名称',
dataIndex: 'stockName',
key: 'stockName',
width: 150,
fixed: 'left',
},
{
title: '市场',
dataIndex: 'market',
key: 'market',
width: 100,
render: (market: string) => <Tag color="blue">{getMarketText(market)}</Tag>,
},
{
title: '交易日期',
dataIndex: 'tradeDate',
key: 'tradeDate',
width: 120,
render: (date: Date) => dayjs(date).format('YYYY-MM-DD'),
},
{
title: '开盘价',
dataIndex: 'openPrice',
key: 'openPrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '收盘价',
dataIndex: 'closePrice',
key: 'closePrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '最高价',
dataIndex: 'highPrice',
key: 'highPrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '最低价',
dataIndex: 'lowPrice',
key: 'lowPrice',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '涨跌额',
dataIndex: 'changeAmount',
key: 'changeAmount',
width: 100,
align: 'right',
render: (amount?: number) => {
if (amount === null || amount === undefined) return '-';
const color = amount >= 0 ? '#ff4d4f' : '#52c41a';
return <span style={{ color }}>{formatPrice(amount)}</span>;
},
},
{
title: '涨跌幅',
dataIndex: 'changePercent',
key: 'changePercent',
width: 100,
align: 'right',
render: (percent?: number) => {
if (percent === null || percent === undefined) return '-';
const color = percent >= 0 ? '#ff4d4f' : '#52c41a';
return (
<span style={{ color }}>
{percent >= 0 ? '+' : ''}
{percent.toFixed(2)}%
</span>
);
},
},
{
title: '成交量',
dataIndex: 'volume',
key: 'volume',
width: 120,
align: 'right',
render: formatVolume,
},
{
title: '成交额',
dataIndex: 'amount',
key: 'amount',
width: 150,
align: 'right',
render: formatAmount,
},
{
title: '换手率',
dataIndex: 'turnoverRate',
key: 'turnoverRate',
width: 100,
align: 'right',
render: (rate?: number) => {
if (rate === null || rate === undefined) return '-';
return `${rate.toFixed(2)}%`;
},
},
{
title: '市盈率',
dataIndex: 'peRatio',
key: 'peRatio',
width: 100,
align: 'right',
render: formatPrice,
},
{
title: '市净率',
dataIndex: 'pbRatio',
key: 'pbRatio',
width: 100,
align: 'right',
render: formatPrice,
},
];
return (
<div className="stock-daily-price-page">
<Card>
{/* 查询表单 */}
<Form form={form} layout="inline" className="stock-daily-price-search-form">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="stockCode" label="股票代码">
<Input
placeholder="请输入股票代码"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="stockName" label="股票名称">
<Input
placeholder="请输入股票名称"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="market" label="市场">
<Select
placeholder="请选择市场"
allowClear
style={{ width: '100%' }}
>
{MARKET_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="dateRange" label="日期范围" layout="horizontal">
<RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
</Form.Item>
</Col>
<Col span={18}>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
{/* 表格 */}
<Table
columns={columns}
dataSource={prices}
rowKey="id"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize: pageSize || 10,
}));
},
}}
scroll={{ x: 1600 }}
/>
</Card>
</div>
);
};
export default StockDailyPricePage;

View File

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

View File

@@ -0,0 +1,28 @@
.stock-info-page {
padding: 0;
}
.stock-info-search-form {
margin-bottom: 16px;
}
.stock-info-search-form .ant-form-item {
margin-bottom: 16px;
}
/* 表格样式优化 */
.stock-info-page .ant-table {
background: #fff;
}
.stock-info-page .ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
}
/* 响应式 */
@media (max-width: 768px) {
.stock-info-search-form .ant-form-item {
margin-bottom: 12px;
}
}

View File

@@ -0,0 +1,250 @@
import { useState, useEffect, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Space,
Card,
Form,
Row,
Col,
App as AntdApp,
Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { stockInfoService } from '@/services/stock-info';
import type { StockInfo, QueryStockInfoRequest } from '@/types/stock-info';
import { MARKET_OPTIONS, getMarketText, STATUS_OPTIONS, getStatusText } from '@/types/stock-info';
import dayjs from 'dayjs';
import './StockInfoPage.css';
const { Option } = Select;
const StockInfoPage = () => {
const { message: messageApi } = AntdApp.useApp();
const [stocks, setStocks] = useState<StockInfo[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [form] = Form.useForm();
const formRef = useRef<QueryStockInfoRequest>({});
// 加载数据
const loadData = async (params?: QueryStockInfoRequest, resetPage = false) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : pagination.current;
const pageSize = pagination.pageSize;
const queryParams: QueryStockInfoRequest = {
page: currentPage,
limit: pageSize,
sortBy: 'createdAt',
sortOrder: 'DESC',
...formRef.current,
...params,
};
const response = await stockInfoService.getStockInfoList(queryParams);
setStocks(response.list);
setPagination((prev) => ({
...prev,
current: response.pagination.current_page,
pageSize: response.pagination.page_size,
total: response.pagination.total,
}));
} catch (error: any) {
messageApi.error(error.message || '加载股票信息列表失败');
} finally {
setLoading(false);
}
};
// 初始加载
useEffect(() => {
loadData({}, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 当分页改变时,重新加载数据
useEffect(() => {
if (pagination.current > 0 && pagination.pageSize > 0) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.current, pagination.pageSize]);
// 查询
const handleSearch = () => {
const values = form.getFieldsValue();
formRef.current = {
stockCode: values.stockCode || undefined,
stockName: values.stockName || undefined,
market: values.market || undefined,
};
loadData(formRef.current, true);
};
// 重置
const handleReset = () => {
form.resetFields();
formRef.current = {};
loadData({}, true);
};
// 表格列定义
const columns: ColumnsType<StockInfo> = [
{
title: '股票代码',
dataIndex: 'stockCode',
key: 'stockCode',
width: 120,
},
{
title: '股票名称',
dataIndex: 'stockName',
key: 'stockName',
width: 200,
},
{
title: '市场',
dataIndex: 'market',
key: 'market',
width: 100,
render: (market: string) => <Tag color="blue">{getMarketText(market)}</Tag>,
},
{
title: '公司全称',
dataIndex: 'fullName',
key: 'fullName',
width: 300,
ellipsis: true,
},
{
title: '所属行业',
dataIndex: 'industry',
key: 'industry',
width: 150,
},
{
title: '上市日期',
dataIndex: 'listingDate',
key: 'listingDate',
width: 120,
render: (date: Date) => (date ? dayjs(date).format('YYYY-MM-DD') : '-'),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const colorMap: Record<string, string> = {
active: 'success',
suspended: 'warning',
delisted: 'error',
};
return <Tag color={colorMap[status] || 'default'}>{getStatusText(status)}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (date: Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<div className="stock-info-page">
<Card>
{/* 查询表单 */}
<Form form={form} layout="inline" className="stock-info-search-form">
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="stockCode" label="股票代码">
<Input
placeholder="请输入股票代码"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="stockName" label="股票名称">
<Input
placeholder="请输入股票名称"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="market" label="市场">
<Select
placeholder="请选择市场"
allowClear
style={{ width: '100%' }}
>
{MARKET_OPTIONS.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item>
<Space style={{ float: 'right' }}>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
{/* 表格 */}
<Table
columns={columns}
dataSource={stocks}
rowKey="id"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPagination((prev) => ({
...prev,
current: page,
pageSize: pageSize || 10,
}));
},
}}
scroll={{ x: 1200 }}
/>
</Card>
</div>
);
};
export default StockInfoPage;

View File

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

View File

@@ -0,0 +1,15 @@
.user-info-content {
padding: 24px 0;
}
.avatar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.info-field-text {
font-size: 14px;
line-height: 1.5715;
color: rgba(0, 0, 0, 0.88);
}

View File

@@ -0,0 +1,479 @@
import { useState, useEffect, useRef } from 'react';
import {
Tabs,
Card,
Avatar,
Form,
Input,
Button,
Upload,
Image,
Space,
App as AntdApp,
Modal,
} from 'antd';
import type { TabsProps } from 'antd';
import { UserOutlined, UploadOutlined } from '@ant-design/icons';
import { authService } from '@/services/auth';
import { userService } from '@/services/user';
import { storageService } from '@/services/storage';
import type { UserInfo } from '@/types/user';
import type { UploadFile } from 'antd/es/upload';
import type { RcFile } from 'antd/es/upload';
import dayjs from 'dayjs';
import './UserInfoPage.css';
/**
* 计算使用天数
* @param createdAt 创建时间
* @returns 使用天数字符串,如 "365天" 或 "1年30天"
*/
const calculateUsageDays = (createdAt: Date | string): string => {
const created = dayjs(createdAt);
const now = dayjs();
const days = now.diff(created, 'day');
if (days < 365) {
return `${days}`;
}
const years = Math.floor(days / 365);
const remainingDays = days % 365;
return `${years}${remainingDays}`;
};
const UserInfoPage = () => {
const { message: messageApi } = AntdApp.useApp();
const [user, setUser] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [editing, setEditing] = useState(false);
const [avatarFileList, setAvatarFileList] = useState<UploadFile[]>([]);
const [passwordForm] = Form.useForm();
const [infoForm] = Form.useForm();
const isLoadingRef = useRef(false);
// 加载用户信息
const loadUserInfo = async () => {
// 防止重复请求
if (isLoadingRef.current) {
return;
}
const currentUser = authService.getUser();
if (!currentUser) {
messageApi.error('未找到用户信息');
return;
}
isLoadingRef.current = true;
setLoading(true);
try {
const userData = await userService.getUserById(currentUser.userId);
console.log('userData:', userData);
setUser(userData);
infoForm.setFieldsValue({
nickname: userData.nickname || '',
phone: userData.phone || '',
email: userData.email || '',
});
// 设置头像文件列表
if (userData.avatarUrl) {
setAvatarFileList([
{
uid: '-1',
name: 'avatar',
status: 'done',
url: userData.avatarUrl,
},
]);
}
} catch (error: any) {
messageApi.error(error.message || '加载用户信息失败');
} finally {
setLoading(false);
isLoadingRef.current = false;
}
};
useEffect(() => {
loadUserInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 上传头像
const handleAvatarUpload = async (
file: RcFile,
onSuccess?: (response: any) => void,
onError?: (error: any) => void
) => {
setUploading(true);
try {
const response = await storageService.uploadAvatar(file);
// 更新用户头像
if (user) {
await userService.updateUser(user.userId, {
avatarUrl: response.url,
});
messageApi.success('头像上传成功');
await loadUserInfo();
onSuccess?.(response);
}
} catch (error: any) {
messageApi.error(error.message || '头像上传失败');
onError?.(error);
} finally {
setUploading(false);
}
};
// 头像上传配置
const avatarUploadProps = {
name: 'file',
listType: 'picture' as const,
maxCount: 1,
fileList: avatarFileList,
accept: 'image/*',
customRequest: async (options: any) => {
const { file, onSuccess, onError } = options;
await handleAvatarUpload(file as RcFile, onSuccess, onError);
},
beforeUpload: (file: File) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
messageApi.error('只能上传图片文件!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
messageApi.error('图片大小不能超过 2MB');
return false;
}
return true;
},
onRemove: () => {
setAvatarFileList([]);
},
onChange: ({ fileList: newFileList }: { fileList: UploadFile[] }) => {
setAvatarFileList(newFileList);
},
};
// 进入编辑模式
const handleEdit = () => {
if (user) {
infoForm.setFieldsValue({
nickname: user.nickname || '',
phone: user.phone || '',
email: user.email || '',
});
setEditing(true);
}
};
// 取消编辑
const handleCancelEdit = () => {
setEditing(false);
infoForm.resetFields();
};
// 处理更新按钮点击(先验证表单,再显示确认框)
const handleUpdateClick = async () => {
try {
// 先验证表单
const values = await infoForm.validateFields();
if (!user) return;
// 验证通过,显示确认框
Modal.confirm({
title: '确认更新',
content: '确定要更新个人信息吗?',
onOk: async () => {
try {
await userService.updateUser(user.userId, {
nickname: values.nickname || undefined,
phone: values.phone || undefined,
email: values.email || undefined,
});
messageApi.success('个人信息更新成功');
setEditing(false);
await loadUserInfo();
} catch (error: any) {
messageApi.error(error.message || '更新失败');
}
},
});
} catch (error: any) {
// 验证失败,不显示确认框
if (error.errorFields) {
return;
}
}
};
// 修改密码
const handleChangePassword = async () => {
try {
const values = await passwordForm.validateFields();
if (!user) return;
await userService.changePassword(user.userId, {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
messageApi.success('密码修改成功');
passwordForm.resetFields();
} catch (error: any) {
if (error.errorFields) {
return;
}
messageApi.error(error.message || '密码修改失败');
}
};
if (!user) {
return <Card loading={loading}>...</Card>;
}
// Tab配置
const tabItems: TabsProps['items'] = [
{
key: 'info',
label: '个人信息',
children: (
<div className="user-info-content">
<Form
form={infoForm}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
style={{ maxWidth: 600 }}
>
{/* 头像 */}
<Form.Item wrapperCol={{ span: 18, offset: 6 }}>
<div className="avatar-wrapper">
{user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt="头像"
width={100}
height={100}
style={{ borderRadius: 8 }}
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f0f0f0'/%3E%3Cpath d='M50 33c9.4 0 17 7.6 17 17s-7.6 17-17 17-17-7.6-17-17 7.6-17 17-17zm0 40c11 0 33.5 5.5 33.5 16.5v8H16.5v-8C16.5 78.5 39 73 50 73z' fill='%23999'/%3E%3C/svg%3E"
/>
) : (
<Avatar size={100} icon={<UserOutlined />} />
)}
<div
style={{
marginTop: 12,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{editing ? (
<Upload {...avatarUploadProps}>
<Button
icon={<UploadOutlined />}
loading={uploading}
size="small"
>
</Button>
</Upload>
) : (
<span
style={{ fontSize: 14, color: 'rgba(0, 0, 0, 0.65)' }}
>
</span>
)}
</div>
</div>
</Form.Item>
{/* 昵称 */}
<Form.Item
label="昵称"
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
>
{editing ? (
<Form.Item
name="nickname"
noStyle
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
>
<Input placeholder="请输入昵称" />
</Form.Item>
) : (
<span className="info-field-text">{user.nickname || '-'}</span>
)}
</Form.Item>
{/* 用户名(不可编辑) */}
<Form.Item label="用户名">
<span className="info-field-text">{user.username}</span>
</Form.Item>
{/* 电话 */}
<Form.Item
label="电话"
rules={[
{
pattern: /^[0-9+\-() ]+$/,
message: '电话号码格式不正确',
},
{ max: 20, message: '电话不能超过20个字符' },
]}
>
{editing ? (
<Form.Item
name="phone"
noStyle
rules={[
{
pattern: /^[0-9+\-() ]+$/,
message: '电话号码格式不正确',
},
{ max: 20, message: '电话不能超过20个字符' },
]}
>
<Input placeholder="请输入电话" />
</Form.Item>
) : (
<span className="info-field-text">{user.phone || '-'}</span>
)}
</Form.Item>
{/* 邮箱 */}
<Form.Item
label="邮箱"
rules={[
{ type: 'email', message: '邮箱格式不正确' },
{ max: 100, message: '邮箱不能超过100个字符' },
]}
>
{editing ? (
<Form.Item
name="email"
noStyle
rules={[
{ type: 'email', message: '邮箱格式不正确' },
{ max: 100, message: '邮箱不能超过100个字符' },
{ required: true, message: '请输入邮箱' },
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
) : (
<span className="info-field-text">{user.email}</span>
)}
</Form.Item>
{/* 注册时间(不可编辑) */}
<Form.Item label="注册时间">
<span className="info-field-text">
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</span>
</Form.Item>
{/* 使用天数(不可编辑) */}
<Form.Item label="使用天数">
<span className="info-field-text">
{calculateUsageDays(user.createdAt)}
</span>
</Form.Item>
{/* 操作按钮 */}
<Form.Item wrapperCol={{ offset: 6, span: 18 }} style={{ marginTop: 24 }}>
{editing ? (
<Space>
<Button type="primary" onClick={handleUpdateClick}>
</Button>
<Button onClick={handleCancelEdit}></Button>
</Space>
) : (
<Button type="primary" onClick={handleEdit}>
</Button>
)}
</Form.Item>
</Form>
</div>
),
},
{
key: 'password',
label: '修改密码',
children: (
<Form
form={passwordForm}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
style={{ maxWidth: 600 }}
onFinish={handleChangePassword}
>
<Form.Item
name="oldPassword"
label="原密码"
rules={[{ required: true, message: '请输入原密码' }]}
>
<Input.Password placeholder="请输入原密码" />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
{ max: 100, message: '密码长度不能超过100位' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
),
},
];
return (
<div className="user-info-page">
<Card>
<Tabs defaultActiveKey="info" items={tabItems} />
</Card>
</div>
);
};
export default UserInfoPage;

View File

@@ -1 +1,2 @@
export { default } from './UserPage';
export { default as UserPage } from './UserPage';
export { default as UserInfoPage } from './UserInfoPage';

View File

@@ -8,7 +8,10 @@ const AssetsPage = lazy(() => import('../pages/AssetsPage'));
const PlansPage = lazy(() => import('../pages/PlansPage'));
const ReviewPage = lazy(() => import('../pages/ReviewPage'));
const BrokerPage = lazy(() => import('../pages/broker'));
const UserPage = lazy(() => import('@/pages/user'));
const UserPage = lazy(() => import('@/pages/user/UserPage'));
const UserInfoPage = lazy(() => import('@/pages/user/UserInfoPage'));
const StockInfoPage = lazy(() => import('@/pages/stock-info'));
const StockDailyPricePage = lazy(() => import('@/pages/stock-daily-price'));
export const router = createBrowserRouter([
{
@@ -49,6 +52,18 @@ export const router = createBrowserRouter([
path: 'user',
element: <UserPage />,
},
{
path: 'user-info',
element: <UserInfoPage />,
},
{
path: 'stock-info',
element: <StockInfoPage />,
},
{
path: 'stock-daily-price',
element: <StockDailyPricePage />,
},
],
},
{

View File

@@ -0,0 +1,37 @@
import { api } from './api';
import type {
StockDailyPrice,
QueryStockDailyPriceRequest,
PaginatedStockDailyPriceResponse,
ApiResponse,
} from '@/types/stock-daily-price';
/**
* 股票每日价格服务
*/
class StockDailyPriceService {
/**
* 查询股票每日价格列表(分页)
*/
async getStockDailyPriceList(
params: QueryStockDailyPriceRequest
): Promise<PaginatedStockDailyPriceResponse> {
const response = await api.get<ApiResponse<PaginatedStockDailyPriceResponse>>(
'/stock-daily-price',
{ params }
);
if ('list' in response && 'pagination' in response) {
return response as PaginatedStockDailyPriceResponse;
}
return (response as ApiResponse<PaginatedStockDailyPriceResponse>).data;
}
/**
* 根据ID查询股票每日价格
*/
async getStockDailyPriceById(id: number): Promise<StockDailyPrice> {
return await api.get<StockDailyPrice>(`/stock-daily-price/${id}`);
}
}
export const stockDailyPriceService = new StockDailyPriceService();

View File

@@ -0,0 +1,34 @@
import { api } from './api';
import type {
StockInfo,
QueryStockInfoRequest,
PaginatedStockInfoResponse,
ApiResponse,
} from '@/types/stock-info';
/**
* 股票信息服务
*/
class StockInfoService {
/**
* 查询股票信息列表(分页)
*/
async getStockInfoList(params: QueryStockInfoRequest): Promise<PaginatedStockInfoResponse> {
const response = await api.get<ApiResponse<PaginatedStockInfoResponse>>('/stock-info', {
params,
});
if ('list' in response && 'pagination' in response) {
return response as PaginatedStockInfoResponse;
}
return (response as ApiResponse<PaginatedStockInfoResponse>).data;
}
/**
* 根据ID查询股票信息
*/
async getStockInfoById(id: number): Promise<StockInfo> {
return await api.get<StockInfo>(`/stock-info/${id}`);
}
}
export const stockInfoService = new StockInfoService();

View File

@@ -25,7 +25,11 @@ class UserService {
* 根据ID查询用户
*/
async getUserById(id: number): Promise<User> {
return await api.get<User>(`/user/${id}`);
const response = await api.get<ApiResponse<User>>(`/user/${id}`);
if (response.code === 0 && response.data) {
return response.data as User;
}
throw new Error(response.message || '获取用户信息失败');
}
/**
@@ -41,6 +45,31 @@ class UserService {
async deleteUser(id: number): Promise<void> {
await api.delete(`/user/${id}`);
}
/**
* 更新用户信息
*/
async updateUser(
id: number,
data: {
nickname?: string;
phone?: string;
avatarUrl?: string;
email?: string;
}
): Promise<User> {
return await api.patch<User>(`/user/${id}`, data);
}
/**
* 修改密码
*/
async changePassword(
id: number,
data: { oldPassword: string; newPassword: string }
): Promise<void> {
await api.patch(`/user/${id}/password`, data);
}
}
export const userService = new UserService();

View File

@@ -0,0 +1,79 @@
/**
* 股票每日价格接口
*/
export interface StockDailyPrice {
id: number;
stockCode: string;
stockName: string;
market: string;
tradeDate: Date;
openPrice?: number;
closePrice: number;
highPrice?: number;
lowPrice?: number;
volume?: number;
amount?: number;
changeAmount?: number;
changePercent?: number;
turnoverRate?: number;
peRatio?: number;
pbRatio?: number;
marketCap?: number;
createdAt: Date;
}
/**
* 查询股票每日价格请求参数
*/
export interface QueryStockDailyPriceRequest {
stockCode?: string;
stockName?: string;
market?: string;
tradeDate?: string;
startDate?: string;
endDate?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
/**
* 分页响应数据
*/
export interface PaginatedStockDailyPriceResponse {
list: StockDailyPrice[];
pagination: {
total: number;
total_page: number;
page_size: number;
current_page: number;
};
}
/**
* API 响应格式
*/
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
/**
* 市场选项
*/
export const MARKET_OPTIONS = [
{ label: '上海', value: 'sh' },
{ label: '深圳', value: 'sz' },
{ label: '北京', value: 'bj' },
{ label: '香港', value: 'hk' },
] as const;
/**
* 获取市场显示文本
*/
export const getMarketText = (market: string): string => {
const option = MARKET_OPTIONS.find((opt) => opt.value === market);
return option ? option.label : market;
};

View File

@@ -0,0 +1,85 @@
/**
* 股票基本信息接口
*/
export interface StockInfo {
id: number;
stockCode: string;
stockName: string;
market: string;
fullName?: string;
industry?: string;
listingDate?: Date;
status: string;
createdAt: Date;
updatedAt: Date;
}
/**
* 查询股票信息请求参数
*/
export interface QueryStockInfoRequest {
stockCode?: string;
stockName?: string;
market?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
/**
* 分页响应数据
*/
export interface PaginatedStockInfoResponse {
list: StockInfo[];
pagination: {
total: number;
total_page: number;
page_size: number;
current_page: number;
};
}
/**
* API 响应格式
*/
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
/**
* 市场选项
*/
export const MARKET_OPTIONS = [
{ label: '上海', value: 'sh' },
{ label: '深圳', value: 'sz' },
{ label: '北京', value: 'bj' },
{ label: '香港', value: 'hk' },
] as const;
/**
* 状态选项
*/
export const STATUS_OPTIONS = [
{ label: '正常', value: 'active' },
{ label: '停牌', value: 'suspended' },
{ label: '退市', value: 'delisted' },
] as const;
/**
* 获取市场显示文本
*/
export const getMarketText = (market: string): string => {
const option = MARKET_OPTIONS.find((opt) => opt.value === market);
return option ? option.label : market;
};
/**
* 获取状态显示文本
*/
export const getStatusText = (status: string): string => {
const option = STATUS_OPTIONS.find((opt) => opt.value === status);
return option ? option.label : status;
};