Initial commit
This commit is contained in:
2093
src/views/Company/CompanyOverview.js
Normal file
2093
src/views/Company/CompanyOverview.js
Normal file
File diff suppressed because it is too large
Load Diff
2109
src/views/Company/FinancialPanorama.js
Normal file
2109
src/views/Company/FinancialPanorama.js
Normal file
File diff suppressed because it is too large
Load Diff
161
src/views/Company/ForecastReport.js
Normal file
161
src/views/Company/ForecastReport.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// 简易版公司盈利预测报表视图
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack } from '@chakra-ui/react';
|
||||
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { stockService } from '../../services/eventService';
|
||||
|
||||
const ForecastReport = ({ stockCode: propStockCode }) => {
|
||||
const [code, setCode] = useState(propStockCode || '600000');
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!code) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await stockService.getForecastReport(code);
|
||||
if (resp && resp.success) setData(resp.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听props中的stockCode变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== code) {
|
||||
setCode(propStockCode);
|
||||
}
|
||||
}, [propStockCode]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
load();
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
const years = data?.detail_table?.years || [];
|
||||
|
||||
const colors = ['#805AD5', '#38B2AC', '#F6AD55', '#63B3ED', '#E53E3E', '#10B981'];
|
||||
|
||||
const incomeProfitOption = data ? {
|
||||
color: [colors[0], colors[4]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
|
||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.income_profit_trend.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '收入(百万元)' },
|
||||
{ type: 'value', name: '利润(百万元)' }
|
||||
],
|
||||
series: [
|
||||
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.08 } },
|
||||
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true, lineStyle: { width: 2 } }
|
||||
]
|
||||
} : {};
|
||||
|
||||
const growthOption = data ? {
|
||||
color: [colors[2]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.growth_bars.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
|
||||
series: [ {
|
||||
name: '营收增长率(%)',
|
||||
type: 'bar',
|
||||
data: data.growth_bars.revenue_growth_pct,
|
||||
itemStyle: { color: (params) => params.value >= 0 ? '#E53E3E' : '#10B981' }
|
||||
} ]
|
||||
} : {};
|
||||
|
||||
const epsOption = data ? {
|
||||
color: [colors[3]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.eps_trend.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: { type: 'value', name: '元/股' },
|
||||
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true, areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 } } ]
|
||||
} : {};
|
||||
|
||||
const pePegOption = data ? {
|
||||
color: [colors[0], colors[1]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['PE', 'PEG'] },
|
||||
grid: { left: 40, right: 40, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.pe_peg_axes.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
|
||||
series: [
|
||||
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
|
||||
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
|
||||
]
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<Box p={4}>
|
||||
<HStack align="center" justify="space-between" mb={4}>
|
||||
<Heading size="md">盈利预测报表</Heading>
|
||||
<Button
|
||||
leftIcon={<RepeatIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={load}
|
||||
isLoading={loading}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{loading && !data && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{[1,2,3,4].map(i => (
|
||||
<Card key={i}>
|
||||
<CardHeader><Skeleton height="18px" width="140px" /></CardHeader>
|
||||
<CardBody>
|
||||
<Skeleton height="320px" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
<Card><CardHeader><Heading size="sm">PE 与 PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<Card mt={4}>
|
||||
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
|
||||
<CardBody>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>关键指标</Th>
|
||||
{years.map(y => <Th key={y}>{y}</Th>)}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{data.detail_table.rows.map((row, idx) => (
|
||||
<Tr key={idx}>
|
||||
<Td><Tag>{row['指标']}</Tag></Td>
|
||||
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastReport;
|
||||
|
||||
|
||||
1999
src/views/Company/MarketDataView.js
Normal file
1999
src/views/Company/MarketDataView.js
Normal file
File diff suppressed because it is too large
Load Diff
301
src/views/Company/index.js
Normal file
301
src/views/Company/index.js
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Card,
|
||||
CardBody,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
HStack,
|
||||
VStack,
|
||||
Input,
|
||||
Button,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Text,
|
||||
Badge,
|
||||
Divider,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useColorMode,
|
||||
IconButton,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
|
||||
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import FinancialPanorama from './FinancialPanorama';
|
||||
import ForecastReport from './ForecastReport';
|
||||
import MarketDataView from './MarketDataView';
|
||||
import CompanyOverview from './CompanyOverview';
|
||||
|
||||
const CompanyIndex = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
|
||||
const [inputCode, setInputCode] = useState(stockCode);
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const toast = useToast();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const tabBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const activeBg = useColorModeValue('blue.500', 'blue.400');
|
||||
|
||||
const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001'));
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
|
||||
|
||||
const loadWatchlistStatus = useCallback(async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + '/api/account/watchlist', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setIsInWatchlist(false);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const list = Array.isArray(data?.data) ? data.data : [];
|
||||
const codes = new Set(list.map((item) => item.stock_code));
|
||||
setIsInWatchlist(codes.has(stockCode));
|
||||
} catch (e) {
|
||||
setIsInWatchlist(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
// 当URL参数变化时更新股票代码
|
||||
useEffect(() => {
|
||||
const scode = searchParams.get('scode');
|
||||
if (scode && scode !== stockCode) {
|
||||
setStockCode(scode);
|
||||
setInputCode(scode);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchlistStatus();
|
||||
}, [loadWatchlistStatus]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (inputCode && inputCode !== stockCode) {
|
||||
setStockCode(inputCode);
|
||||
setSearchParams({ scode: inputCode });
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWatchlistToggle = async () => {
|
||||
if (!stockCode) {
|
||||
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsWatchlistLoading(true);
|
||||
const base = getApiBase();
|
||||
if (isInWatchlist) {
|
||||
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!resp.ok) throw new Error('删除失败');
|
||||
setIsInWatchlist(false);
|
||||
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
|
||||
} else {
|
||||
const resp = await fetch(base + '/api/account/watchlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ stock_code: stockCode })
|
||||
});
|
||||
if (!resp.ok) throw new Error('添加失败');
|
||||
setIsInWatchlist(true);
|
||||
toast({ title: '已加入自选', status: 'success', duration: 1500 });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
||||
} finally {
|
||||
setIsWatchlistLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HomeNavbar />
|
||||
<Container maxW="container.xl" py={5}>
|
||||
{/* 页面标题和股票搜索 */}
|
||||
<VStack align="stretch" spacing={5}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardBody>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="lg">个股信息分析</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
查看股票实时行情、财务数据和盈利预测
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<InputGroup size="lg" maxW="300px">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="输入股票代码"
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
borderRadius="md"
|
||||
_focus={{
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 1px #3182ce'
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
onClick={handleSearch}
|
||||
leftIcon={<SearchIcon />}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={isInWatchlist ? 'yellow' : 'teal'}
|
||||
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||
size="lg"
|
||||
onClick={handleWatchlistToggle}
|
||||
leftIcon={<StarIcon />}
|
||||
isLoading={isWatchlistLoading}
|
||||
>
|
||||
{isInWatchlist ? '已在自选' : '加入自选'}
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
variant="outline"
|
||||
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
|
||||
size="lg"
|
||||
aria-label="Toggle color mode"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 当前股票信息 */}
|
||||
<HStack mt={4} spacing={4}>
|
||||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||
股票代码: {stockCode}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
更新时间: {new Date().toLocaleString()}
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据展示区域 */}
|
||||
<Card bg={bgColor} shadow="lg">
|
||||
<CardBody p={0}>
|
||||
<Tabs variant="soft-rounded" colorScheme="blue" size="lg">
|
||||
<TabList p={4} bg={tabBg}>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
mr={2}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaInfoCircle} />
|
||||
<Text>公司概览</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
mr={2}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaChartLine} />
|
||||
<Text>股票行情</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
mr={2}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaMoneyBillWave} />
|
||||
<Text>财务全景</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
_selected={{
|
||||
bg: activeBg,
|
||||
color: 'white',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaChartBar} />
|
||||
<Text>盈利预测</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel p={6}>
|
||||
<CompanyOverview stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
<TabPanel p={6}>
|
||||
<MarketDataView stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
<TabPanel p={6}>
|
||||
<FinancialPanorama stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
<TabPanel p={6}>
|
||||
<ForecastReport stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyIndex;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user