feat(frontend): integrate Prisma and reports API/pages chore(config): add data_sources.yaml; update analysis-config.json docs: add 2025-11-03 dev log; update user guide scripts: enhance dev.sh; add tushare_legacy_client deps: update backend and frontend dependencies
1574 lines
78 KiB
TypeScript
1574 lines
78 KiB
TypeScript
'use client';
|
||
|
||
import { useParams, useSearchParams } from 'next/navigation';
|
||
import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis } from '@/hooks/useApi';
|
||
import { Spinner } from '@/components/ui/spinner';
|
||
import { Button } from '@/components/ui/button';
|
||
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table';
|
||
import { useEffect, useState, useRef } from 'react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import { TradingViewWidget } from '@/components/TradingViewWidget';
|
||
import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
|
||
import { useMemo } from 'react';
|
||
|
||
export default function ReportPage() {
|
||
const params = useParams();
|
||
const searchParams = useSearchParams();
|
||
const symbol = params.symbol as string;
|
||
const market = (searchParams.get('market') || '').toLowerCase();
|
||
|
||
const isChina = market === 'china' || market === 'cn';
|
||
|
||
// 规范化中国市场 ts_code:若为6位数字或无后缀,自动推断交易所
|
||
const normalizedTsCode = (() => {
|
||
if (!isChina) return symbol;
|
||
if (!symbol) return symbol;
|
||
if (symbol.includes('.')) return symbol.toUpperCase();
|
||
const onlyDigits = symbol.replace(/\D/g, '');
|
||
if (onlyDigits.length === 6) {
|
||
const first = onlyDigits[0];
|
||
if (first === '6') return `${onlyDigits}.SH`;
|
||
if (first === '0' || first === '3') return `${onlyDigits}.SZ`;
|
||
}
|
||
return symbol.toUpperCase();
|
||
})();
|
||
|
||
const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10);
|
||
const { data: financialConfig } = useFinancialConfig();
|
||
const { data: analysisConfig } = useAnalysisConfig();
|
||
|
||
// 分析类型列表(按顺序)
|
||
const analysisTypes = useMemo(() => {
|
||
if (!analysisConfig?.analysis_modules) return [];
|
||
return Object.keys(analysisConfig.analysis_modules);
|
||
}, [analysisConfig]);
|
||
|
||
// 分析状态管理
|
||
const [analysisStates, setAnalysisStates] = useState<Record<string, {
|
||
content: string;
|
||
loading: boolean;
|
||
error: string | null;
|
||
elapsed_ms?: number;
|
||
tokens?: {
|
||
prompt_tokens: number;
|
||
completion_tokens: number;
|
||
total_tokens: number;
|
||
};
|
||
}>>({});
|
||
|
||
const fullAnalysisTriggeredRef = useRef<boolean>(false);
|
||
const isAnalysisRunningRef = useRef<boolean>(false);
|
||
const analysisFetchedRefs = useRef<Record<string, boolean>>({});
|
||
const stopRequestedRef = useRef<boolean>(false);
|
||
const abortControllerRef = useRef<AbortController | null>(null);
|
||
const currentAnalysisTypeRef = useRef<string | null>(null);
|
||
const [manualRunKey, setManualRunKey] = useState(0);
|
||
|
||
// 当前正在执行的分析任务
|
||
const [currentAnalysisTask, setCurrentAnalysisTask] = useState<string | null>(null);
|
||
|
||
// 计时器状态
|
||
const [startTime, setStartTime] = useState<number | null>(null);
|
||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||
|
||
// 分析执行记录(用于执行详情)
|
||
const [analysisRecords, setAnalysisRecords] = useState<Array<{
|
||
type: string;
|
||
name: string;
|
||
status: 'pending' | 'running' | 'done' | 'error';
|
||
start_ts?: string;
|
||
end_ts?: string;
|
||
duration_ms?: number;
|
||
tokens?: {
|
||
prompt_tokens: number;
|
||
completion_tokens: number;
|
||
total_tokens: number;
|
||
};
|
||
error?: string;
|
||
}>>([]);
|
||
|
||
const [saving, setSaving] = useState(false)
|
||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||
|
||
const saveReport = async () => {
|
||
try {
|
||
setSaving(true)
|
||
setSaveMsg(null)
|
||
const content = {
|
||
market,
|
||
normalizedSymbol: normalizedTsCode,
|
||
financialsMeta: financials?.meta || null,
|
||
// 同步保存财务数据(用于报告详情页展示)
|
||
financials: financials
|
||
? {
|
||
ts_code: financials.ts_code,
|
||
name: (financials as any).name,
|
||
series: financials.series,
|
||
meta: financials.meta,
|
||
}
|
||
: null,
|
||
analyses: Object.fromEntries(
|
||
Object.entries(analysisStates).map(([k, v]) => [k, { content: v.content, error: v.error, elapsed_ms: v.elapsed_ms, tokens: v.tokens }])
|
||
)
|
||
}
|
||
const resp = await fetch('/api/reports', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ symbol: normalizedTsCode, content })
|
||
})
|
||
if (!resp.ok) {
|
||
const t = await resp.json().catch(() => ({}))
|
||
throw new Error(t?.error || `HTTP ${resp.status}`)
|
||
}
|
||
const data = await resp.json()
|
||
setSaveMsg('保存成功')
|
||
return data
|
||
} catch (e) {
|
||
setSaveMsg(e instanceof Error ? e.message : '保存失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const runFullAnalysis = async () => {
|
||
if (!isChina || !financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
|
||
return;
|
||
}
|
||
|
||
// 初始化/重置状态,准备顺序执行
|
||
stopRequestedRef.current = false;
|
||
abortControllerRef.current?.abort();
|
||
abortControllerRef.current = null;
|
||
analysisFetchedRefs.current = {};
|
||
setCurrentAnalysisTask('开始全量分析...');
|
||
setStartTime(Date.now());
|
||
setElapsedSeconds(0);
|
||
|
||
const initialStates: typeof analysisStates = {};
|
||
const initialRecords: typeof analysisRecords = [];
|
||
const analysisModuleKeys = Object.keys(analysisConfig.analysis_modules);
|
||
for (const type of analysisModuleKeys) {
|
||
initialStates[type] = { content: '', loading: false, error: null };
|
||
initialRecords.push({
|
||
type,
|
||
name: analysisConfig.analysis_modules[type]?.name || type,
|
||
status: 'pending',
|
||
});
|
||
}
|
||
setAnalysisStates(initialStates);
|
||
setAnalysisRecords(initialRecords);
|
||
|
||
// 触发顺序执行
|
||
setManualRunKey((k) => k + 1);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (financials && !fullAnalysisTriggeredRef.current) {
|
||
fullAnalysisTriggeredRef.current = true;
|
||
runFullAnalysis();
|
||
}
|
||
}, [financials]);
|
||
|
||
// 计算完成比例
|
||
const completionProgress = useMemo(() => {
|
||
const totalTasks = analysisRecords.length;
|
||
if (totalTasks === 0) return 0;
|
||
const completedTasks = analysisRecords.filter(r => r.status === 'done' || r.status === 'error').length;
|
||
return (completedTasks / totalTasks) * 100;
|
||
}, [analysisRecords]);
|
||
|
||
// 格式化耗时显示
|
||
const formatElapsedTime = (seconds: number): string => {
|
||
if (seconds < 60) {
|
||
return `${seconds}s`;
|
||
}
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = seconds % 60;
|
||
return `${minutes}m ${remainingSeconds}s`;
|
||
};
|
||
|
||
// 以 ms 为输入的格式化:>1000ms 显示为秒
|
||
const formatMs = (ms?: number | null): string => {
|
||
const v = typeof ms === 'number' ? ms : 0;
|
||
if (v >= 1000) {
|
||
const s = v / 1000;
|
||
return `${s.toFixed(2)} s`;
|
||
}
|
||
return `${v} ms`;
|
||
};
|
||
|
||
// 总耗时(ms):财务 + 所有分析任务
|
||
const totalElapsedMs = useMemo(() => {
|
||
const finMs = financials?.meta?.elapsed_ms || 0;
|
||
const analysesMs = analysisRecords.reduce((sum, r) => sum + (r.duration_ms || 0), 0);
|
||
return finMs + analysesMs;
|
||
}, [financials?.meta?.elapsed_ms, analysisRecords]);
|
||
|
||
// 创建 tushareParam 到 displayText 的映射
|
||
const metricDisplayMap = useMemo(() => {
|
||
if (!financialConfig?.api_groups) return {};
|
||
|
||
const map: Record<string, string> = {};
|
||
Object.values(financialConfig.api_groups).forEach(metrics => {
|
||
metrics.forEach(metric => {
|
||
if (metric.tushareParam && metric.displayText) {
|
||
map[metric.tushareParam] = metric.displayText;
|
||
}
|
||
});
|
||
});
|
||
return map;
|
||
}, [financialConfig]);
|
||
|
||
const metricGroupMap = useMemo(() => {
|
||
if (!financialConfig?.api_groups) return {} as Record<string, string>;
|
||
const map: Record<string, string> = {};
|
||
Object.entries(financialConfig.api_groups).forEach(([groupName, metrics]) => {
|
||
metrics.forEach((metric) => {
|
||
if (metric.tushareParam) {
|
||
map[metric.tushareParam] = groupName;
|
||
}
|
||
});
|
||
});
|
||
return map;
|
||
}, [financialConfig]);
|
||
|
||
const numberFormatter = useMemo(() => new Intl.NumberFormat('zh-CN', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
}), []);
|
||
|
||
const integerFormatter = useMemo(() => new Intl.NumberFormat('zh-CN', {
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: 0,
|
||
}), []);
|
||
|
||
const normalizeMarkdown = useMemo(() => {
|
||
return (content: string): string => {
|
||
if (!content) return content;
|
||
const lines = content.split(/\r?\n/);
|
||
const out: string[] = [];
|
||
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
let line = lines[i];
|
||
line = line.replace(/^(\s*)(\d+)[、,]\s*/u, '$1$2. ');
|
||
const onlyIndexMatch = line.match(/^\s*(\d+)\.[\s\u3000]*$/u);
|
||
if (onlyIndexMatch) {
|
||
const next = lines[i + 1] ?? '';
|
||
out.push(`${onlyIndexMatch[1]}. ${next}`);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
out.push(line);
|
||
}
|
||
|
||
let text = out.join('\n');
|
||
text = text.replace(/([^\n])\n(\s*\d+\.\s)/g, (_m, a, b) => `${a}\n\n${b}`);
|
||
return text;
|
||
};
|
||
}, []);
|
||
|
||
const hasRunningTask = useMemo(() => {
|
||
if (currentAnalysisTask !== null) return true;
|
||
if (analysisRecords.some(r => r.status === 'running')) return true;
|
||
return false;
|
||
}, [currentAnalysisTask, analysisRecords]);
|
||
|
||
// 全部任务是否完成(无运行中任务,且所有分析记录为 done 或 error)
|
||
const allTasksCompleted = useMemo(() => {
|
||
if (analysisRecords.length === 0) return false;
|
||
const allDoneOrErrored = analysisRecords.every(r => r.status === 'done' || r.status === 'error');
|
||
return allDoneOrErrored && !hasRunningTask && currentAnalysisTask === null;
|
||
}, [analysisRecords, hasRunningTask, currentAnalysisTask]);
|
||
|
||
// 所有任务完成时,停止计时器
|
||
useEffect(() => {
|
||
if (allTasksCompleted) {
|
||
setStartTime(null);
|
||
}
|
||
}, [allTasksCompleted]);
|
||
|
||
useEffect(() => {
|
||
if (!startTime) return;
|
||
const interval = setInterval(() => {
|
||
const now = Date.now();
|
||
const elapsed = Math.floor((now - startTime) / 1000);
|
||
setElapsedSeconds(elapsed);
|
||
}, 1000);
|
||
return () => clearInterval(interval);
|
||
}, [startTime]);
|
||
|
||
const retryAnalysis = async (analysisType: string) => {
|
||
if (!isChina || !financials || !analysisConfig?.analysis_modules) {
|
||
return;
|
||
}
|
||
analysisFetchedRefs.current[analysisType] = false;
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: { content: '', loading: true, error: null }
|
||
}));
|
||
setAnalysisRecords(prev => prev.filter(record => record.type !== analysisType));
|
||
const analysisName =
|
||
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||
const startTime = new Date().toISOString();
|
||
setCurrentAnalysisTask(analysisType);
|
||
setAnalysisRecords(prev => [...prev, {
|
||
type: analysisType,
|
||
name: analysisName,
|
||
status: 'running',
|
||
start_ts: startTime
|
||
}]);
|
||
|
||
try {
|
||
const startedMsLocal = Date.now();
|
||
const response = await fetch(
|
||
`/api/financials/china/${normalizedTsCode}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const reader = response.body?.getReader();
|
||
const decoder = new TextDecoder();
|
||
let aggregate = '';
|
||
if (reader) {
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
aggregate += chunk;
|
||
const snapshot = aggregate;
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: {
|
||
...prev[analysisType],
|
||
content: snapshot,
|
||
loading: true,
|
||
error: null,
|
||
}
|
||
}));
|
||
}
|
||
}
|
||
const endTime = new Date().toISOString();
|
||
const elapsedMs = Date.now() - startedMsLocal;
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: {
|
||
...prev[analysisType],
|
||
content: aggregate,
|
||
loading: false,
|
||
error: null,
|
||
elapsed_ms: elapsedMs,
|
||
}
|
||
}));
|
||
setAnalysisRecords(prev => prev.map(record =>
|
||
record.type === analysisType
|
||
? {
|
||
...record,
|
||
status: 'done',
|
||
end_ts: endTime,
|
||
duration_ms: elapsedMs,
|
||
}
|
||
: record
|
||
));
|
||
} catch (err) {
|
||
const errorMessage = err instanceof Error ? err.message : '加载失败';
|
||
const endTime = new Date().toISOString();
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: {
|
||
content: '',
|
||
loading: false,
|
||
error: errorMessage
|
||
}
|
||
}));
|
||
setAnalysisRecords(prev => prev.map(record =>
|
||
record.type === analysisType
|
||
? {
|
||
...record,
|
||
status: 'error',
|
||
end_ts: endTime,
|
||
error: errorMessage
|
||
}
|
||
: record
|
||
));
|
||
} finally {
|
||
setCurrentAnalysisTask(null);
|
||
analysisFetchedRefs.current[analysisType] = true;
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isChina || isLoading || error || !financials || !analysisConfig?.analysis_modules || analysisTypes.length === 0) {
|
||
return;
|
||
}
|
||
if (isAnalysisRunningRef.current) {
|
||
return;
|
||
}
|
||
const runAnalysesSequentially = async () => {
|
||
if (isAnalysisRunningRef.current) {
|
||
return;
|
||
}
|
||
isAnalysisRunningRef.current = true;
|
||
try {
|
||
if (!stopRequestedRef.current && !startTime) {
|
||
setStartTime(Date.now());
|
||
}
|
||
for (let i = 0; i < analysisTypes.length; i++) {
|
||
const analysisType = analysisTypes[i];
|
||
if (stopRequestedRef.current) {
|
||
break;
|
||
}
|
||
if (analysisFetchedRefs.current[analysisType]) {
|
||
continue;
|
||
}
|
||
if (!analysisFetchedRefs.current || !analysisConfig?.analysis_modules) {
|
||
console.error("分析配置或refs未初始化,无法进行分析。");
|
||
continue;
|
||
}
|
||
currentAnalysisTypeRef.current = analysisType;
|
||
const analysisName =
|
||
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||
const startTime = new Date().toISOString();
|
||
setCurrentAnalysisTask(analysisType);
|
||
setAnalysisRecords(prev => {
|
||
const next = [...prev];
|
||
const idx = next.findIndex(r => r.type === analysisType);
|
||
const updated = {
|
||
type: analysisType,
|
||
name: analysisName,
|
||
status: 'running' as const,
|
||
start_ts: startTime
|
||
};
|
||
if (idx >= 0) {
|
||
next[idx] = { ...next[idx], ...updated };
|
||
} else {
|
||
next.push(updated);
|
||
}
|
||
return next;
|
||
});
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: { content: '', loading: true, error: null }
|
||
}));
|
||
try {
|
||
abortControllerRef.current?.abort();
|
||
abortControllerRef.current = new AbortController();
|
||
const startedMsLocal = Date.now();
|
||
const response = await fetch(
|
||
`/api/financials/china/${normalizedTsCode}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`,
|
||
{ signal: abortControllerRef.current.signal }
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const reader = response.body?.getReader();
|
||
const decoder = new TextDecoder();
|
||
let aggregate = '';
|
||
if (reader) {
|
||
// 持续读取并追加到内容
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
aggregate += chunk;
|
||
const snapshot = aggregate;
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: {
|
||
...prev[analysisType],
|
||
content: snapshot,
|
||
loading: true,
|
||
error: null,
|
||
}
|
||
}));
|
||
}
|
||
}
|
||
const endTime = new Date().toISOString();
|
||
const elapsedMs = Date.now() - startedMsLocal;
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: {
|
||
...prev[analysisType],
|
||
content: aggregate,
|
||
loading: false,
|
||
error: null,
|
||
elapsed_ms: elapsedMs,
|
||
}
|
||
}));
|
||
setAnalysisRecords(prev => prev.map(record =>
|
||
record.type === analysisType
|
||
? {
|
||
...record,
|
||
status: 'done',
|
||
end_ts: endTime,
|
||
duration_ms: elapsedMs,
|
||
}
|
||
: record
|
||
));
|
||
} catch (err) {
|
||
if (err && typeof err === 'object' && (err as any).name === 'AbortError') {
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: { content: '', loading: false, error: null }
|
||
}));
|
||
setAnalysisRecords(prev => prev.map(record =>
|
||
record.type === analysisType
|
||
? { ...record, status: 'pending', start_ts: undefined }
|
||
: record
|
||
));
|
||
analysisFetchedRefs.current[analysisType] = false;
|
||
break;
|
||
}
|
||
const errorMessage = err instanceof Error ? err.message : '加载失败';
|
||
const endTime = new Date().toISOString();
|
||
setAnalysisStates(prev => ({
|
||
...prev,
|
||
[analysisType]: {
|
||
content: '',
|
||
loading: false,
|
||
error: errorMessage
|
||
}
|
||
}));
|
||
setAnalysisRecords(prev => prev.map(record =>
|
||
record.type === analysisType
|
||
? {
|
||
...record,
|
||
status: 'error',
|
||
end_ts: endTime,
|
||
error: errorMessage
|
||
}
|
||
: record
|
||
));
|
||
} finally {
|
||
setCurrentAnalysisTask(null);
|
||
currentAnalysisTypeRef.current = null;
|
||
analysisFetchedRefs.current[analysisType] = true;
|
||
}
|
||
}
|
||
} finally {
|
||
isAnalysisRunningRef.current = false;
|
||
}
|
||
};
|
||
runAnalysesSequentially();
|
||
}, [isChina, isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]);
|
||
|
||
const stopAll = () => {
|
||
stopRequestedRef.current = true;
|
||
abortControllerRef.current?.abort();
|
||
abortControllerRef.current = null;
|
||
isAnalysisRunningRef.current = false;
|
||
if (currentAnalysisTypeRef.current) {
|
||
analysisFetchedRefs.current[currentAnalysisTypeRef.current] = false;
|
||
}
|
||
setCurrentAnalysisTask(null);
|
||
setStartTime(null);
|
||
};
|
||
|
||
const continuePending = () => {
|
||
if (isAnalysisRunningRef.current) return;
|
||
stopRequestedRef.current = false;
|
||
setStartTime((prev) => (prev == null ? Date.now() - elapsedSeconds * 1000 : prev));
|
||
setManualRunKey((k) => k + 1);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-stretch justify-between gap-4">
|
||
<Card className="flex-1">
|
||
<CardHeader>
|
||
<CardTitle className="text-xl">报告页面</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted-foreground min-w-20">股票代码:</span>
|
||
<span className="font-medium">{normalizedTsCode}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted-foreground min-w-20">交易市场:</span>
|
||
<span className="font-medium">{market}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted-foreground min-w-20">公司名称:</span>
|
||
<span className="font-medium">
|
||
{isLoading ? (
|
||
<span className="flex items-center gap-1">
|
||
<Spinner className="size-3" />
|
||
<span className="text-muted-foreground">加载中...</span>
|
||
</span>
|
||
) : financials?.name ? (
|
||
financials.name
|
||
) : (
|
||
<span className="text-muted-foreground">-</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
</CardContent>
|
||
</Card>
|
||
{isChina && (
|
||
<Card className="w-40 flex-shrink-0">
|
||
<CardContent className="flex flex-col gap-2">
|
||
<Button onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}>
|
||
{isAnalysisRunningRef.current ? '正在分析…' : '开始分析'}
|
||
</Button>
|
||
<Button variant="destructive" onClick={stopAll} disabled={!hasRunningTask}>
|
||
停止
|
||
</Button>
|
||
<Button variant="outline" onClick={continuePending} disabled={isAnalysisRunningRef.current}>
|
||
继续
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
{isChina && (
|
||
<Card className="w-80">
|
||
<CardHeader className="flex flex-col space-y-2 pb-2">
|
||
<div className="flex flex-row items-center justify-between w-full">
|
||
<CardTitle className="text-xl">任务状态</CardTitle>
|
||
{startTime && (
|
||
<div className="text-sm font-medium text-muted-foreground ml-auto">
|
||
总耗时: {formatElapsedTime(elapsedSeconds)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="w-full bg-muted rounded-full h-2 mt-1">
|
||
<div
|
||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||
style={{ width: `${completionProgress}%` }}
|
||
/>
|
||
</div>
|
||
{allTasksCompleted && (
|
||
<div className="pt-2">
|
||
<Button onClick={saveReport} disabled={saving} variant="outline">
|
||
{saving ? '保存中...' : '保存报告'}
|
||
</Button>
|
||
{saveMsg && <span className="ml-2 text-xs text-muted-foreground">{saveMsg}</span>}
|
||
</div>
|
||
)}
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{currentAnalysisTask && analysisConfig && (
|
||
(() => {
|
||
const analysisName = analysisConfig.analysis_modules[currentAnalysisTask]?.name || currentAnalysisTask;
|
||
const modelName = analysisConfig.analysis_modules[currentAnalysisTask]?.model || 'AI';
|
||
return (
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Spinner className="size-4" />
|
||
<div>
|
||
<div className="font-medium">{analysisName}(来自 {modelName})</div>
|
||
<div className="text-xs text-muted-foreground">正在生成{analysisName}...</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{isChina && (
|
||
<Tabs defaultValue="chart" className="mt-4">
|
||
<TabsList className="flex-wrap">
|
||
<TabsTrigger value="chart">股价图表</TabsTrigger>
|
||
<TabsTrigger value="financial">财务数据</TabsTrigger>
|
||
{analysisTypes.map(type => (
|
||
<TabsTrigger key={type} value={type}>
|
||
{type === 'company_profile' ? '公司简介' : (analysisConfig?.analysis_modules[type]?.name || type)}
|
||
</TabsTrigger>
|
||
))}
|
||
<TabsTrigger value="execution">执行详情</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="chart" className="space-y-4">
|
||
<h2 className="text-lg font-medium">股价图表(来自 TradingView)</h2>
|
||
<div className="flex items-center gap-3 text-sm mb-4">
|
||
<CheckCircle className="size-4 text-green-600" />
|
||
<div className="text-muted-foreground">
|
||
实时股价图表 - {normalizedTsCode}
|
||
</div>
|
||
</div>
|
||
|
||
<TradingViewWidget
|
||
symbol={normalizedTsCode}
|
||
market={market}
|
||
height={500}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="financial" className="space-y-4">
|
||
<h2 className="text-lg font-medium">财务数据(来自 Tushare)</h2>
|
||
<div className="flex items-center gap-3 text-sm">
|
||
{isLoading ? (
|
||
<Spinner className="size-4" />
|
||
) : error ? (
|
||
<XCircle className="size-4 text-red-500" />
|
||
) : (
|
||
<CheckCircle className="size-4 text-green-600" />
|
||
)}
|
||
<div className="text-muted-foreground">
|
||
{isLoading
|
||
? '正在读取 Tushare 数据…'
|
||
: error
|
||
? '读取失败'
|
||
: '读取完成'}
|
||
</div>
|
||
</div>
|
||
{error && <p className="text-red-500">加载失败</p>}
|
||
|
||
{isLoading && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground">加载中</span>
|
||
<Spinner className="size-4" />
|
||
</div>
|
||
)}
|
||
|
||
|
||
|
||
{financials && (
|
||
<div className="overflow-x-auto">
|
||
{(() => {
|
||
const series = financials?.series ?? {};
|
||
const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }>;
|
||
const currentYearStr = String(new Date().getFullYear());
|
||
const years = Array
|
||
.from(new Set(allPoints.map((p) => p?.year).filter(Boolean) as string[]))
|
||
.sort((a, b) => Number(b) - Number(a)); // 最新年份在左
|
||
const getQuarter = (month: number | null | undefined) => {
|
||
if (month == null) return null;
|
||
return Math.floor((month - 1) / 3) + 1;
|
||
};
|
||
if (years.length === 0) {
|
||
return <p className="text-sm text-muted-foreground">暂无可展示的数据</p>;
|
||
}
|
||
return (
|
||
<Table className="min-w-full text-sm">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="text-left p-2">指标</TableHead>
|
||
{years.map((y) => {
|
||
const isCurrent = y === currentYearStr;
|
||
const yearData = allPoints.find(p => p.year === y);
|
||
const quarter = yearData?.month ? getQuarter(yearData.month) : null;
|
||
const label = isCurrent && quarter ? `${y} Q${quarter}` : y;
|
||
return (
|
||
<TableHead key={y} className="text-right p-2">{label}</TableHead>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{(() => {
|
||
// 指定显示顺序(tushareParam)
|
||
const ORDER: Array<{ key: string; label?: string; kind?: 'computed' }> = [
|
||
{ key: 'roe' },
|
||
{ key: 'roa' },
|
||
{ key: 'roic' },
|
||
{ key: 'grossprofit_margin' },
|
||
{ key: 'netprofit_margin' },
|
||
{ key: 'revenue' },
|
||
{ key: 'tr_yoy' },
|
||
{ key: 'n_income' },
|
||
{ key: 'dt_netprofit_yoy' },
|
||
{ key: 'n_cashflow_act' },
|
||
{ key: 'c_pay_acq_const_fiolta' },
|
||
{ key: '__free_cash_flow', label: '自由现金流', kind: 'computed' },
|
||
{ key: 'cash_div_tax', label: '分红' },
|
||
{ key: 'buyback', label: '回购' },
|
||
{ key: 'total_assets' },
|
||
{ key: 'total_hldr_eqy_exc_min_int' },
|
||
{ key: 'goodwill' },
|
||
];
|
||
|
||
// 在表格顶部插入"主要指标"行
|
||
const summaryRow = (
|
||
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">主要指标</TableCell>
|
||
{years.map((y) => (
|
||
<TableCell key={y} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
);
|
||
|
||
const PERCENT_KEYS = new Set([
|
||
'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy'
|
||
]);
|
||
const rows = ORDER.map(({ key, label, kind }) => {
|
||
// 自由现金流为计算项:经营现金流 - 资本开支
|
||
const isComputed = kind === 'computed' && key === '__free_cash_flow';
|
||
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const operating = series['n_cashflow_act'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const capex = series['c_pay_acq_const_fiolta'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
|
||
return (
|
||
<TableRow key={key} className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">
|
||
{label || metricDisplayMap[key] || key}
|
||
</TableCell>
|
||
{years.map((y) => {
|
||
let v: number | null | undefined = undefined;
|
||
if (isComputed) {
|
||
const op = operating?.find(p => p?.year === y)?.value ?? null;
|
||
const cp = capex?.find(p => p?.year === y)?.value ?? null;
|
||
if (op == null || cp == null) {
|
||
v = null;
|
||
} else {
|
||
v = (typeof op === 'number' ? op : Number(op)) - (typeof cp === 'number' ? cp : Number(cp));
|
||
}
|
||
} else {
|
||
v = points?.find(p => p?.year === y)?.value ?? null;
|
||
}
|
||
|
||
const groupName = metricGroupMap[key];
|
||
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
if (rawNum == null || Number.isNaN(rawNum)) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
if (PERCENT_KEYS.has(key)) {
|
||
const perc = Math.abs(rawNum) <= 1 ? rawNum * 100 : rawNum;
|
||
const text = Number.isFinite(perc) ? numberFormatter.format(perc) : '-';
|
||
const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy';
|
||
if (isGrowthRow) {
|
||
const isNeg = typeof perc === 'number' && perc < 0;
|
||
return (
|
||
<TableCell key={y} className="text-right p-2">
|
||
<span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span>
|
||
</TableCell>
|
||
);
|
||
}
|
||
// ROE > 12% 高亮浅绿色背景
|
||
if (key === 'roe') {
|
||
const highlight = typeof perc === 'number' && perc > 12;
|
||
return (
|
||
<TableCell key={y} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
|
||
);
|
||
}
|
||
// ROIC > 12% 高亮浅绿色背景
|
||
if (key === 'roic') {
|
||
const highlight = typeof perc === 'number' && perc > 12;
|
||
return (
|
||
<TableCell key={y} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
|
||
);
|
||
}
|
||
return (
|
||
<TableCell key={y} className="text-right p-2">{`${text}%`}</TableCell>
|
||
);
|
||
} else {
|
||
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow';
|
||
const scaled = key === 'total_mv'
|
||
? rawNum / 10000
|
||
: (isFinGroup || isComputed ? rawNum / 1e8 : rawNum);
|
||
const formatter = key === 'total_mv' ? integerFormatter : numberFormatter;
|
||
const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-';
|
||
if (key === '__free_cash_flow') {
|
||
const isNeg = typeof scaled === 'number' && scaled < 0;
|
||
return (
|
||
<TableCell key={y} className="text-right p-2">
|
||
{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}
|
||
</TableCell>
|
||
);
|
||
}
|
||
return (
|
||
<TableCell key={y} className="text-right p-2">{text}</TableCell>
|
||
);
|
||
}
|
||
})}
|
||
</TableRow>
|
||
);
|
||
});
|
||
|
||
// =========================
|
||
// 费用指标分组
|
||
// =========================
|
||
const feeHeaderRow = (
|
||
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">费用指标</TableCell>
|
||
{years.map((y) => (
|
||
<TableCell key={y} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
);
|
||
|
||
const getVal = (arr: Array<{ year?: string; value?: number | null }> | undefined, y: string) => {
|
||
const v = arr?.find(p => p?.year === y)?.value;
|
||
return typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
};
|
||
|
||
// 销售费用率 = sell_exp / revenue
|
||
const feeRows = [
|
||
{ key: '__sell_rate', label: '销售费用率', num: series['sell_exp'] as any, den: series['revenue'] as any },
|
||
{ key: '__admin_rate', label: '管理费用率', num: series['admin_exp'] as any, den: series['revenue'] as any },
|
||
{ key: '__rd_rate', label: '研发费用率', num: series['rd_exp'] as any, den: series['revenue'] as any },
|
||
{ key: '__other_fee_rate', label: '其他费用率', num: undefined, den: series['revenue'] as any },
|
||
// 所得税率:若有 tax_to_ebt,用该指标;否则按 income_tax / 税前利润 计算(暂无字段,留空)
|
||
{ key: '__tax_rate', label: '所得税率', num: series['tax_to_ebt'] as any, den: undefined },
|
||
// 折旧费用占比 = 折旧费用 / 收入
|
||
{ key: '__depr_ratio', label: '折旧费用占比', num: series['depr_fa_coga_dpba'] as any, den: series['revenue'] as any },
|
||
].map(({ key, label, num, den }) => (
|
||
<TableRow key={key} className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||
{years.map((y) => {
|
||
const numerator = getVal(num, y);
|
||
const denominator = getVal(den, y);
|
||
|
||
let rate: number | null = null;
|
||
if (key === '__tax_rate') {
|
||
// tax_to_ebt 有些接口为比例(0-1),有些可能已是百分数
|
||
if (numerator == null || Number.isNaN(numerator)) {
|
||
rate = null;
|
||
} else if (Math.abs(numerator) <= 1) {
|
||
rate = numerator * 100;
|
||
} else {
|
||
rate = numerator; // 认为已是百分比
|
||
}
|
||
} else if (key === '__other_fee_rate') {
|
||
// 其他费用率 = 毛利率 - 净利润率 - 销售费用率 - 管理费用率 - 研发费用率
|
||
const gpRaw = getVal(series['grossprofit_margin'] as any, y);
|
||
const npRaw = getVal(series['netprofit_margin'] as any, y);
|
||
const rev = getVal(series['revenue'] as any, y);
|
||
const sell = getVal(series['sell_exp'] as any, y);
|
||
const admin = getVal(series['admin_exp'] as any, y);
|
||
const rd = getVal(series['rd_exp'] as any, y);
|
||
if (
|
||
gpRaw == null || npRaw == null || rev == null || rev === 0 ||
|
||
sell == null || admin == null || rd == null
|
||
) {
|
||
rate = null;
|
||
} else {
|
||
// 将毛利率、净利润率标准化为百分比
|
||
const gp = Math.abs(gpRaw) <= 1 ? gpRaw * 100 : gpRaw;
|
||
const np = Math.abs(npRaw) <= 1 ? npRaw * 100 : npRaw;
|
||
const sellRate = (sell / rev) * 100;
|
||
const adminRate = (admin / rev) * 100;
|
||
const rdRate = (rd / rev) * 100;
|
||
rate = gp - np - sellRate - adminRate - rdRate;
|
||
}
|
||
} else {
|
||
if (numerator == null || denominator == null || denominator === 0) {
|
||
rate = null;
|
||
} else {
|
||
rate = (numerator / denominator) * 100;
|
||
}
|
||
}
|
||
|
||
if (rate == null || !Number.isFinite(rate)) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
const rateText = numberFormatter.format(rate);
|
||
const isNegative = rate < 0;
|
||
return (
|
||
<TableCell key={y} className="text-right p-2">
|
||
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`}
|
||
</TableCell>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
));
|
||
|
||
// =========================
|
||
// 资产占比分组
|
||
// =========================
|
||
const assetHeaderRow = (
|
||
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">资产占比</TableCell>
|
||
{years.map((y) => (
|
||
<TableCell key={y} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
);
|
||
|
||
const ratioCell = (value: number | null, y: string) => {
|
||
if (value == null || !Number.isFinite(value)) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
const text = numberFormatter.format(value);
|
||
const isNegative = value < 0;
|
||
return (
|
||
<TableCell key={y} className="text-right p-2">
|
||
{isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`}
|
||
</TableCell>
|
||
);
|
||
};
|
||
|
||
const assetRows = [
|
||
{
|
||
key: '__money_cap_ratio', label: '现金占比', calc: (y: string) => {
|
||
const num = getVal(series['money_cap'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__inventories_ratio', label: '库存占比', calc: (y: string) => {
|
||
const num = getVal(series['inventories'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__ar_ratio', label: '应收款占比', calc: (y: string) => {
|
||
const num = getVal(series['accounts_receiv_bill'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__prepay_ratio', label: '预付款占比', calc: (y: string) => {
|
||
const num = getVal(series['prepayment'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__fix_assets_ratio', label: '固定资产占比', calc: (y: string) => {
|
||
const num = getVal(series['fix_assets'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__lt_invest_ratio', label: '长期投资占比', calc: (y: string) => {
|
||
const num = getVal(series['lt_eqt_invest'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__goodwill_ratio', label: '商誉占比', calc: (y: string) => {
|
||
const num = getVal(series['goodwill'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__other_assets_ratio', label: '其他资产占比', calc: (y: string) => {
|
||
const total = getVal(series['total_assets'] as any, y);
|
||
if (total == null || total === 0) return null;
|
||
const parts = [
|
||
getVal(series['money_cap'] as any, y),
|
||
getVal(series['inventories'] as any, y),
|
||
getVal(series['accounts_receiv_bill'] as any, y),
|
||
getVal(series['prepayment'] as any, y),
|
||
getVal(series['fix_assets'] as any, y),
|
||
getVal(series['lt_eqt_invest'] as any, y),
|
||
getVal(series['goodwill'] as any, y),
|
||
].map(v => (typeof v === 'number' && Number.isFinite(v) ? v : 0));
|
||
const sumKnown = parts.reduce((acc: number, v: number) => acc + v, 0);
|
||
return ((total - sumKnown) / total) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__ap_ratio', label: '应付款占比', calc: (y: string) => {
|
||
const num = getVal(series['accounts_pay'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__adv_ratio', label: '预收款占比', calc: (y: string) => {
|
||
const adv = getVal(series['adv_receipts'] as any, y) || 0;
|
||
const contractLiab = getVal(series['contract_liab'] as any, y) || 0;
|
||
const num = adv + contractLiab;
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__st_borr_ratio', label: '短期借款占比', calc: (y: string) => {
|
||
const num = getVal(series['st_borr'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__lt_borr_ratio', label: '长期借款占比', calc: (y: string) => {
|
||
const num = getVal(series['lt_borr'] as any, y);
|
||
const den = getVal(series['total_assets'] as any, y);
|
||
return num == null || den == null || den === 0 ? null : (num / den) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__operating_assets_ratio', label: '运营资产占比', calc: (y: string) => {
|
||
const total = getVal(series['total_assets'] as any, y);
|
||
if (total == null || total === 0) return null;
|
||
const inv = getVal(series['inventories'] as any, y) || 0;
|
||
const ar = getVal(series['accounts_receiv_bill'] as any, y) || 0;
|
||
const pre = getVal(series['prepayment'] as any, y) || 0;
|
||
const ap = getVal(series['accounts_pay'] as any, y) || 0;
|
||
const adv = getVal(series['adv_receipts'] as any, y) || 0;
|
||
const contractLiab = getVal(series['contract_liab'] as any, y) || 0;
|
||
const operating = inv + ar + pre - ap - adv - contractLiab;
|
||
return (operating / total) * 100;
|
||
}
|
||
},
|
||
{
|
||
key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (y: string) => {
|
||
const total = getVal(series['total_assets'] as any, y);
|
||
if (total == null || total === 0) return null;
|
||
const st = getVal(series['st_borr'] as any, y) || 0;
|
||
const lt = getVal(series['lt_borr'] as any, y) || 0;
|
||
return ((st + lt) / total) * 100;
|
||
}
|
||
},
|
||
].map(({ key, label, calc }) => (
|
||
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
|
||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||
{years.map((y) => ratioCell(calc(y), y))}
|
||
</TableRow>
|
||
));
|
||
|
||
// =========================
|
||
// 周转能力分组
|
||
// =========================
|
||
const turnoverHeaderRow = (
|
||
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">周转能力</TableCell>
|
||
{years.map((y) => (
|
||
<TableCell key={y} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
);
|
||
|
||
const turnoverItems: Array<{ key: string; label: string }> = [
|
||
{ key: 'invturn_days', label: '存货周转天数' },
|
||
{ key: 'arturn_days', label: '应收款周转天数' },
|
||
{ key: 'payturn_days', label: '应付款周转天数' },
|
||
{ key: 'fa_turn', label: '固定资产周转率' },
|
||
{ key: 'assets_turn', label: '总资产周转率' },
|
||
];
|
||
|
||
const getYearNumber = (ys: string) => {
|
||
const n = Number(ys);
|
||
return Number.isFinite(n) ? n : null;
|
||
};
|
||
const getPoint = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => {
|
||
return arr?.find(p => p?.year === year)?.value ?? null;
|
||
};
|
||
const getAvg = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => {
|
||
const curr = getPoint(arr, year);
|
||
const yNum = getYearNumber(year);
|
||
const prevYear = yNum != null ? String(yNum - 1) : null;
|
||
const prev = prevYear ? getPoint(arr, prevYear) : null;
|
||
const c = typeof curr === 'number' ? curr : (curr == null ? null : Number(curr));
|
||
const p = typeof prev === 'number' ? prev : (prev == null ? null : Number(prev));
|
||
if (c == null) return null;
|
||
if (p == null) return c;
|
||
return (c + p) / 2;
|
||
};
|
||
const getMarginRatio = (year: string) => {
|
||
const gmRaw = getPoint(series['grossprofit_margin'] as any, year);
|
||
if (gmRaw == null) return null;
|
||
const gmNum = typeof gmRaw === 'number' ? gmRaw : Number(gmRaw);
|
||
if (!Number.isFinite(gmNum)) return null;
|
||
// 支持 0-1 或 百分数
|
||
return Math.abs(gmNum) <= 1 ? gmNum : gmNum / 100;
|
||
};
|
||
const getRevenue = (year: string) => {
|
||
const rev = getPoint(series['revenue'] as any, year);
|
||
const r = typeof rev === 'number' ? rev : (rev == null ? null : Number(rev));
|
||
return r;
|
||
};
|
||
const getCOGS = (year: string) => {
|
||
const rev = getRevenue(year);
|
||
const gm = getMarginRatio(year);
|
||
if (rev == null || gm == null) return null;
|
||
const cogs = rev * (1 - gm);
|
||
return Number.isFinite(cogs) ? cogs : null;
|
||
};
|
||
|
||
const turnoverRows = turnoverItems.map(({ key, label }) => (
|
||
<TableRow key={key} className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||
{years.map((y) => {
|
||
let value: number | null = null;
|
||
if (key === 'payturn_days') {
|
||
const avgAP = getAvg(series['accounts_pay'] as any, y);
|
||
const cogs = getCOGS(y);
|
||
value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs;
|
||
} else {
|
||
// 直接显示原值(从API读取:invturn_days、arturn_days、fa_turn、assets_turn)
|
||
const arr = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const v = arr?.find(p => p?.year === y)?.value ?? null;
|
||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
value = num == null || Number.isNaN(num) ? null : num;
|
||
}
|
||
if (value == null || !Number.isFinite(value)) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
const text = numberFormatter.format(value);
|
||
if (key === 'arturn_days' && value > 90) {
|
||
return (
|
||
<TableCell key={y} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell>
|
||
);
|
||
}
|
||
return <TableCell key={y} className="text-right p-2">{text}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
));
|
||
|
||
return [
|
||
summaryRow,
|
||
...rows,
|
||
feeHeaderRow,
|
||
...feeRows,
|
||
assetHeaderRow,
|
||
...assetRows,
|
||
turnoverHeaderRow,
|
||
...turnoverRows,
|
||
// =========================
|
||
// 人均效率分组
|
||
// =========================
|
||
(
|
||
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">人均效率</TableCell>
|
||
{years.map((y) => (
|
||
<TableCell key={y} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
),
|
||
// 员工人数(整数千分位)
|
||
(
|
||
<TableRow key="__employees_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">员工人数</TableCell>
|
||
{years.map((y) => {
|
||
const v = getVal(series['employees'] as any, y);
|
||
if (v == null || !Number.isFinite(v)) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// 人均创收 = 收入 / 员工人数(万元)
|
||
(
|
||
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">人均创收(万元)</TableCell>
|
||
{years.map((y) => {
|
||
const rev = getVal(series['revenue'] as any, y);
|
||
const emp = getVal(series['employees'] as any, y);
|
||
if (rev == null || emp == null || emp === 0) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
const val = (rev / emp) / 10000;
|
||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// 人均创利 = 净利润 / 员工人数(万元)
|
||
(
|
||
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">人均创利(万元)</TableCell>
|
||
{years.map((y) => {
|
||
const prof = getVal(series['n_income'] as any, y);
|
||
const emp = getVal(series['employees'] as any, y);
|
||
if (prof == null || emp == null || emp === 0) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
const val = (prof / emp) / 10000;
|
||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// 人均工资 = 支付给职工以及为职工支付的现金 / 员工人数(万元)
|
||
(
|
||
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">人均工资(万元)</TableCell>
|
||
{years.map((y) => {
|
||
const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, y);
|
||
const emp = getVal(series['employees'] as any, y);
|
||
if (salaryPaid == null || emp == null || emp === 0) {
|
||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
}
|
||
const val = (salaryPaid / emp) / 10000;
|
||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// =========================
|
||
// 市场表现分组
|
||
// =========================
|
||
(
|
||
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">市场表现</TableCell>
|
||
{years.map((y) => (
|
||
<TableCell key={y} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
),
|
||
// 股价(收盘价)
|
||
(
|
||
<TableRow key="__price_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">股价</TableCell>
|
||
{years.map((y) => {
|
||
const arr = series['close'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const v = arr?.find(p => p?.year === y)?.value ?? null;
|
||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// 市值(按亿为单位显示:乘以10000并整数千分位)
|
||
(
|
||
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">市值(亿元)</TableCell>
|
||
{years.map((y) => {
|
||
const arr = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const v = arr?.find(p => p?.year === y)?.value ?? null;
|
||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
const scaled = num / 10000; // 转为亿元
|
||
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// PE
|
||
(
|
||
<TableRow key="__pe_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">PE</TableCell>
|
||
{years.map((y) => {
|
||
const arr = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const v = arr?.find(p => p?.year === y)?.value ?? null;
|
||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// PB
|
||
(
|
||
<TableRow key="__pb_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
|
||
{years.map((y) => {
|
||
const arr = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const v = arr?.find(p => p?.year === y)?.value ?? null;
|
||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
// 股东户数
|
||
(
|
||
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">股东户数</TableCell>
|
||
{years.map((y) => {
|
||
const arr = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||
const v = arr?.find(p => p?.year === y)?.value ?? null;
|
||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell>;
|
||
})}
|
||
</TableRow>
|
||
),
|
||
];
|
||
})()}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
|
||
|
||
{/* 动态生成各个分析的TabsContent */}
|
||
{analysisTypes.map(analysisType => {
|
||
const state = analysisStates[analysisType] || { content: '', loading: false, error: null };
|
||
const normalizedContent = normalizeMarkdown(state.content);
|
||
const analysisName = analysisType === 'company_profile'
|
||
? '公司简介'
|
||
: (analysisConfig?.analysis_modules[analysisType]?.name || analysisType);
|
||
const modelName = analysisConfig?.analysis_modules[analysisType]?.model;
|
||
|
||
return (
|
||
<TabsContent key={analysisType} value={analysisType} className="space-y-4">
|
||
<h2 className="text-lg font-medium">{analysisName}(来自 {modelName || 'AI'})</h2>
|
||
|
||
{!financials && (
|
||
<p className="text-sm text-muted-foreground">请等待财务数据加载完成...</p>
|
||
)}
|
||
|
||
{financials && (
|
||
<>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3 text-sm">
|
||
{state.loading ? (
|
||
<Spinner className="size-4" />
|
||
) : state.error ? (
|
||
<XCircle className="size-4 text-red-500" />
|
||
) : state.content ? (
|
||
<CheckCircle className="size-4 text-green-600" />
|
||
) : null}
|
||
<div className="text-muted-foreground">
|
||
{state.loading
|
||
? `正在生成${analysisName}...`
|
||
: state.error
|
||
? '生成失败'
|
||
: state.content
|
||
? '生成完成'
|
||
: '待开始'}
|
||
</div>
|
||
</div>
|
||
{/* 始终可见的"重新生成分析"按钮 */}
|
||
{!state.loading && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => retryAnalysis(analysisType)}
|
||
disabled={currentAnalysisTask !== null}
|
||
>
|
||
<RotateCw className="size-4" />
|
||
重新生成分析
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{state.error && (
|
||
<p className="text-red-500">加载失败: {state.error}</p>
|
||
)}
|
||
|
||
{(state.loading || state.content) && (
|
||
<div className="space-y-4">
|
||
<div className="border rounded-lg p-6 bg-card">
|
||
<article className="markdown-body" style={{
|
||
boxSizing: 'border-box',
|
||
minWidth: '200px',
|
||
maxWidth: '980px',
|
||
margin: '0 auto',
|
||
padding: '0'
|
||
}}>
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
>
|
||
{normalizedContent}
|
||
</ReactMarkdown>
|
||
{state.loading && (
|
||
<span className="inline-flex items-center gap-2 mt-2 text-muted-foreground">
|
||
<Spinner className="size-3" />
|
||
<span className="text-sm">正在生成中...</span>
|
||
</span>
|
||
)}
|
||
</article>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
);
|
||
})}
|
||
|
||
<TabsContent value="execution" className="space-y-4">
|
||
<h2 className="text-lg font-medium">执行详情</h2>
|
||
|
||
{/* 执行概况卡片 */}
|
||
{financials && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">执行概况</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* 财务数据状态 */}
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
{isLoading ? (
|
||
<Spinner className="size-4" />
|
||
) : error ? (
|
||
<XCircle className="size-4 text-red-500" />
|
||
) : (
|
||
<CheckCircle className="size-4 text-green-600" />
|
||
)}
|
||
<span className="font-medium">财务数据</span>
|
||
</div>
|
||
{financials?.meta && (
|
||
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
||
<div>耗时: {formatMs(financials.meta.elapsed_ms)}</div>
|
||
<div>API调用: {financials.meta.api_calls_total} 次</div>
|
||
<div>开始时间: {financials.meta.started_at}</div>
|
||
{financials.meta.finished_at && (
|
||
<div>结束时间: {financials.meta.finished_at}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 分析任务状态 */}
|
||
{analysisRecords.length > 0 && (
|
||
<div className="pt-3 border-t">
|
||
<div className="font-medium mb-2">分析任务</div>
|
||
<div className="ml-6 text-sm text-muted-foreground space-y-2">
|
||
{analysisRecords.map((record, idx) => (
|
||
<div key={idx} className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
{record.status === 'running' && <Spinner className="size-3" />}
|
||
{record.status === 'done' && <CheckCircle className="size-3 text-green-600" />}
|
||
{record.status === 'error' && <XCircle className="size-3 text-red-500" />}
|
||
<span className="font-medium">{record.name}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{record.status === 'running' ? '运行中' : record.status === 'done' ? '已完成' : record.status === 'error' ? '失败' : '待继续'}
|
||
</span>
|
||
{record.status === 'error' && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 px-2 text-xs"
|
||
onClick={() => {
|
||
retryAnalysis(record.type);
|
||
}}
|
||
disabled={currentAnalysisTask !== null}
|
||
>
|
||
<RotateCw className="size-3" />
|
||
重试
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{record.duration_ms !== undefined && (
|
||
<div className="ml-5">耗时: {formatMs(record.duration_ms)}</div>
|
||
)}
|
||
{record.tokens && (
|
||
<div className="ml-5">
|
||
Token: {record.tokens.total_tokens}
|
||
(Prompt: {record.tokens.prompt_tokens},
|
||
Completion: {record.tokens.completion_tokens})
|
||
</div>
|
||
)}
|
||
{record.error && (
|
||
<div className="ml-5 text-red-500">错误: {record.error}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 总体统计 */}
|
||
<div className="pt-3 border-t">
|
||
<div className="font-medium mb-2">总体统计</div>
|
||
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
||
<div>总耗时: {formatMs(totalElapsedMs)}</div>
|
||
{financials?.meta?.steps && (
|
||
<div>财务数据完成步骤: {financials.meta.steps.filter(s => s.status === 'done').length}/{financials.meta.steps.length}</div>
|
||
)}
|
||
{analysisRecords.length > 0 && (
|
||
<>
|
||
<div>分析任务: {analysisRecords.filter(r => r.status === 'done').length}/{analysisRecords.length} 已完成</div>
|
||
<div>总Token消耗: {analysisRecords.reduce((sum, r) => sum + (r.tokens?.total_tokens || 0), 0)}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|