refactor: 拆分 ReportPage 为组件和 Hooks

- 将庞大的 page.tsx 拆分为多个独立组件 (components/)
- 提取业务逻辑到 Hooks (hooks/)
- 提取工具函数到 utils.ts
- 优化代码结构和可维护性
This commit is contained in:
Lv, Qi 2025-11-19 06:51:46 +08:00
parent e699cda81e
commit 4fef6bf35b
10 changed files with 1758 additions and 1495 deletions

View File

@ -0,0 +1,115 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
import { normalizeMarkdown, removeTitleFromContent } from '../utils';
interface AnalysisContentProps {
analysisType: string;
state: {
content: string;
loading: boolean;
error: string | null;
};
financials: any;
analysisConfig: any;
retryAnalysis: (type: string) => void;
currentAnalysisTask: string | null;
}
export function AnalysisContent({
analysisType,
state,
financials,
analysisConfig,
retryAnalysis,
currentAnalysisTask,
}: AnalysisContentProps) {
const analysisName = analysisType === 'company_profile'
? '公司简介'
: (analysisConfig?.analysis_modules?.[analysisType]?.name || analysisType);
const modelName = analysisConfig?.analysis_modules?.[analysisType]?.model;
// Process content
const contentWithoutTitle = removeTitleFromContent(state.content, analysisName);
const normalizedContent = normalizeMarkdown(contentWithoutTitle);
return (
<div 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>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,147 @@
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
import { formatMs } from '../utils';
interface AnalysisRecord {
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;
}
interface ExecutionDetailsProps {
financials: any;
isLoading: boolean;
error: any;
analysisRecords: AnalysisRecord[];
currentAnalysisTask: string | null;
totalElapsedMs: number;
retryAnalysis: (type: string) => void;
}
export function ExecutionDetails({
financials,
isLoading,
error,
analysisRecords,
currentAnalysisTask,
totalElapsedMs,
retryAnalysis,
}: ExecutionDetailsProps) {
return (
<div 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 as any[]).filter((s: any) => s?.status === 'done').length}/{(financials.meta.steps as any[]).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>
)}
</div>
);
}

View File

@ -0,0 +1,569 @@
import { useMemo } from 'react';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle, XCircle } from 'lucide-react';
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table';
import { formatReportPeriod } from '@/lib/financial-utils';
import { numberFormatter, integerFormatter } from '../utils';
interface FinancialTableProps {
financials: any;
isLoading: boolean;
error: any;
financialConfig: any;
}
export function FinancialTable({ financials, isLoading, error, financialConfig }: FinancialTableProps) {
// 创建 tushareParam 到 displayText 的映射
const metricDisplayMap = useMemo(() => {
if (!financialConfig?.api_groups) return {};
const map: Record<string, string> = {};
const groups = Object.values((financialConfig as any).api_groups || {}) as any[][];
groups.forEach((metrics) => {
(metrics || []).forEach((metric: any) => {
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> = {};
const entries = Object.entries((financialConfig as any).api_groups || {}) as [string, any[]][];
entries.forEach(([groupName, metrics]) => {
(metrics || []).forEach((metric: any) => {
if (metric.tushareParam) {
map[metric.tushareParam] = groupName;
}
});
});
return map;
}, [financialConfig]);
return (
<div className="space-y-4">
<h2 className="text-lg font-medium"></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 ? '正在读取数据…' : 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 ?? {};
// 统一 period优先 p.period若仅有 year 则映射到 `${year}1231`
const toPeriod = (p: any): string | null => {
if (!p) return null;
if (p.period) return String(p.period);
if (p.year) return `${p.year}1231`;
return null;
};
const displayedKeys = [
'roe', 'roa', 'roic', 'grossprofit_margin', 'netprofit_margin', 'revenue', 'tr_yoy', 'n_income',
'dt_netprofit_yoy', 'n_cashflow_act', 'c_pay_acq_const_fiolta', '__free_cash_flow',
'dividend_amount', 'repurchase_amount', 'total_assets', 'total_hldr_eqy_exc_min_int', 'goodwill',
'__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio',
'__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', '__fix_assets_ratio',
'__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', '__ap_ratio', '__adv_ratio',
'__st_borr_ratio', '__lt_borr_ratio', '__operating_assets_ratio', '__interest_bearing_debt_ratio',
'invturn_days', 'arturn_days', 'payturn_days', 'fa_turn', 'assets_turn',
'employees', '__rev_per_emp', '__profit_per_emp', '__salary_per_emp',
'close', 'total_mv', 'pe', 'pb', 'holder_num'
];
const displayedSeries = Object.entries(series)
.filter(([key]) => displayedKeys.includes(key))
.map(([, value]) => value);
const allPeriods = Array.from(
new Set(
(displayedSeries.flat() as any[])
.map((p) => toPeriod(p))
.filter((v): v is string => Boolean(v))
)
).sort((a, b) => b.localeCompare(a)); // 最新在左(按 YYYYMMDD 排序)
if (allPeriods.length === 0) {
return <p className="text-sm text-muted-foreground"></p>;
}
const periods = allPeriods.slice(0, 10);
const getValueByPeriod = (points: any[] | undefined, period: string): number | null => {
if (!points) return null;
const hit = points.find((pp) => toPeriod(pp) === period);
const v = hit?.value;
if (v == null) return null;
const num = typeof v === 'number' ? v : Number(v);
return Number.isFinite(num) ? num : null;
};
return (
<Table className="min-w-full text-sm">
<TableHeader>
<TableRow>
<TableHead className="text-left p-2"></TableHead>
{periods.map((p) => (
<TableHead key={p} className="text-right p-2">{formatReportPeriod(p)}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{(() => {
// 指定显示顺序tushareParam
const ORDER: Array<{ key: string; label?: string }> = [
{ 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: '自由现金流' },
{ key: 'dividend_amount', label: '分红' },
{ key: 'repurchase_amount', 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>
{periods.map((p) => (
<TableCell key={p} className="p-2"></TableCell>
))}
</TableRow>
);
const PERCENT_KEYS = new Set([
'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy',
// Add all calculated percentage rows
'__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio',
'__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio',
'__fix_assets_ratio', '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio',
'__ap_ratio', '__adv_ratio', '__st_borr_ratio', '__lt_borr_ratio',
'__operating_assets_ratio', '__interest_bearing_debt_ratio'
]);
const rows = ORDER.map(({ key, label }) => {
const points = series[key] as any[] | undefined;
return (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">
{label || metricDisplayMap[key] || key}
</TableCell>
{periods.map((p) => {
const v = getValueByPeriod(points, p);
const groupName = metricGroupMap[key];
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (rawNum == null || Number.isNaN(rawNum)) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
if (PERCENT_KEYS.has(key)) {
const perc = Math.abs(rawNum) <= 1 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? 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;
const isHighGrowth = typeof perc === 'number' && perc > 30;
let content = `${text}%`;
if (key === 'dt_netprofit_yoy' && typeof perc === 'number' && perc > 1000) {
content = `${(perc / 100).toFixed(1)}x`;
}
let tableCellClassName = 'text-right p-2';
let spanClassName = 'italic';
if (isNeg) {
tableCellClassName += ' bg-red-100';
spanClassName += ' text-red-600';
} else if (isHighGrowth) {
tableCellClassName += ' bg-green-100';
spanClassName += ' text-green-800 font-bold';
} else {
spanClassName += ' text-blue-600';
}
return (
<TableCell key={p} className={tableCellClassName}>
<span className={spanClassName}>{content}</span>
</TableCell>
);
}
const isHighlighted = (key === 'roe' && typeof perc === 'number' && perc > 12.5) ||
(key === 'grossprofit_margin' && typeof perc === 'number' && perc > 35) ||
(key === 'netprofit_margin' && typeof perc === 'number' && perc > 15);
if (isHighlighted) {
return (
<TableCell key={p} className="text-right p-2 bg-green-100 text-green-800 font-bold">
{`${text}%`}
</TableCell>
);
}
return (
<TableCell key={p} className="text-right p-2">{`${text}%`}</TableCell>
);
} else {
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow';
const scaled = key === 'total_mv'
? rawNum / 10000
: (isFinGroup || key === '__free_cash_flow' || key === 'repurchase_amount' ? 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={p} className="text-right p-2">
{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}
</TableCell>
);
}
return (
<TableCell key={p} 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>
{periods.map((p) => (
<TableCell key={p} className="p-2"></TableCell>
))}
</TableRow>
);
const feeRows = [
{ key: '__sell_rate', label: '销售费用率' },
{ key: '__admin_rate', label: '管理费用率' },
{ key: '__rd_rate', label: '研发费用率' },
{ key: '__other_fee_rate', label: '其他费用率' },
{ key: '__tax_rate', label: '所得税率' },
{ key: '__depr_ratio', label: '折旧费用占比' },
].map(({ key, label }) => (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{periods.map((p) => {
const points = series[key] as any[] | undefined;
const v = getValueByPeriod(points, p);
if (v == null || !Number.isFinite(v)) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
const rateText = numberFormatter.format(v);
const isNegative = v < 0;
return (
<TableCell key={p} 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>
{periods.map((p) => (
<TableCell key={p} className="p-2"></TableCell>
))}
</TableRow>
);
const ratioCell = (value: number | null, keyStr: string) => {
if (value == null || !Number.isFinite(value)) {
return <TableCell key={keyStr} className="text-right p-2">-</TableCell>;
}
const text = numberFormatter.format(value);
const isNegative = value < 0;
const isHighRatio = value > 30;
let cellClassName = "text-right p-2";
if (isHighRatio) {
cellClassName += " bg-red-100";
} else if (isNegative) {
cellClassName += " bg-red-100";
}
return (
<TableCell key={keyStr} className={cellClassName}>
{isNegative ? <span className="text-red-600">{text}%</span> : `${text}%`}
</TableCell>
);
};
const assetRows = [
{ key: '__money_cap_ratio', label: '现金占比' },
{ key: '__inventories_ratio', label: '库存占比' },
{ key: '__ar_ratio', label: '应收款占比' },
{ key: '__prepay_ratio', label: '预付款占比' },
{ key: '__fix_assets_ratio', label: '固定资产占比' },
{ key: '__lt_invest_ratio', label: '长期投资占比' },
{ key: '__goodwill_ratio', label: '商誉占比' },
{ key: '__other_assets_ratio', label: '其他资产占比' },
{ key: '__ap_ratio', label: '应付款占比' },
{ key: '__adv_ratio', label: '预收款占比' },
{ key: '__st_borr_ratio', label: '短期借款占比' },
{ key: '__lt_borr_ratio', label: '长期借款占比' },
{ key: '__operating_assets_ratio', label: '运营资产占比' },
{ key: '__interest_bearing_debt_ratio', label: '有息负债率' },
].map(({ key, label }) => (
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{periods.map((p) => {
const points = series[key] as any[] | undefined;
const v = getValueByPeriod(points, p);
return ratioCell(v, p);
})}
</TableRow>
));
// =========================
// 周转能力分组
// =========================
const turnoverHeaderRow = (
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell>
{periods.map((p) => (
<TableCell key={p} 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 turnoverRows = turnoverItems.map(({ key, label }) => (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{periods.map((p) => {
const points = series[key] as any[] | undefined;
const v = getValueByPeriod(points, p);
const value = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (value == null || !Number.isFinite(value)) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
const text = numberFormatter.format(value);
if (key === 'arturn_days' && value > 90) {
return (
<TableCell key={p} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell>
);
}
return <TableCell key={p} 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>
{periods.map((p) => (
<TableCell key={p} className="p-2"></TableCell>
))}
</TableRow>
),
// 员工人数(整数千分位)
(
<TableRow key="__employees_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{periods.map((p) => {
const points = series['employees'] as any[] | undefined;
const v = getValueByPeriod(points, p);
if (v == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} 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>
{periods.map((p) => {
const points = series['__rev_per_emp'] as any[] | undefined;
const val = getValueByPeriod(points, p);
if (val == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} 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>
{periods.map((p) => {
const points = series['__profit_per_emp'] as any[] | undefined;
const val = getValueByPeriod(points, p);
if (val == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} 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>
{periods.map((p) => {
const points = series['__salary_per_emp'] as any[] | undefined;
const val = getValueByPeriod(points, p);
if (val == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} 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>
{periods.map((p) => (
<TableCell key={p} className="p-2"></TableCell>
))}
</TableRow>
),
// 股价(收盘价)
(
<TableRow key="__price_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{periods.map((p) => {
const points = series['close'] as any[] | undefined;
const v = getValueByPeriod(points, p);
if (v == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})}
</TableRow>
),
// 市值按亿为单位显示乘以10000并整数千分位
(
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">亿</TableCell>
{periods.map((p) => {
const points = series['total_mv'] as any[] | undefined;
const v = getValueByPeriod(points, p);
if (v == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
const scaled = v / 10000; // 转为亿元
return <TableCell key={p} 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>
{periods.map((p) => {
const points = series['pe'] as any[] | undefined;
const v = getValueByPeriod(points, p);
if (v == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})}
</TableRow>
),
// PB
(
<TableRow key="__pb_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
{periods.map((p) => {
const points = series['pb'] as any[] | undefined;
const v = getValueByPeriod(points, p);
if (v == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})}
</TableRow>
),
// 股东户数
(
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{periods.map((p) => {
const points = series['holder_num'] as any[] | undefined;
const v = getValueByPeriod(points, p);
if (v == null) {
return <TableCell key={p} className="text-right p-2">-</TableCell>;
}
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
})}
</TableRow>
),
];
})()}
</TableBody>
</Table>
);
})()}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,146 @@
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
interface ReportHeaderProps {
unifiedSymbol: string;
displayMarket: string;
isLoading: boolean;
financials: any;
snapshot: any;
snapshotLoading: boolean;
triggering: boolean;
hasRunningTask: boolean;
isAnalysisRunning: boolean;
onStartAnalysis: () => void;
onStopAnalysis: () => void;
onContinueAnalysis: () => void;
}
export function ReportHeader({
unifiedSymbol,
displayMarket,
isLoading,
financials,
snapshot,
snapshotLoading,
triggering,
hasRunningTask,
isAnalysisRunning,
onStartAnalysis,
onStopAnalysis,
onContinueAnalysis,
}: ReportHeaderProps) {
return (
<>
<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">{unifiedSymbol}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">{displayMarket}</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>
<Card className="w-80 flex-shrink-0">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<SnapshotItem
label="日期"
value={snapshot?.trade_date ? `${snapshot.trade_date.slice(0,4)}-${snapshot.trade_date.slice(4,6)}-${snapshot.trade_date.slice(6,8)}` : undefined}
loading={snapshotLoading}
/>
<SnapshotItem
label="PB"
value={snapshot?.pb != null ? `${Number(snapshot.pb).toFixed(2)}` : undefined}
loading={snapshotLoading}
/>
<SnapshotItem
label="股价"
value={snapshot?.close != null ? `${Number(snapshot.close).toFixed(2)}` : undefined}
loading={snapshotLoading}
/>
<SnapshotItem
label="PE"
value={snapshot?.pe != null ? `${Number(snapshot.pe).toFixed(2)}` : undefined}
loading={snapshotLoading}
/>
<SnapshotItem
label="市值"
value={snapshot?.total_mv != null ? `${Math.round((snapshot.total_mv as number) / 10000).toLocaleString('zh-CN')} 亿元` : undefined}
loading={snapshotLoading}
/>
<SnapshotItem
label="股息率"
value={snapshot?.dv_ratio != null ? `${Number(snapshot.dv_ratio).toFixed(2)}%` : undefined}
loading={snapshotLoading}
/>
</div>
</CardContent>
</Card>
<Card className="w-40 flex-shrink-0">
<CardContent className="flex flex-col gap-2 pt-6">
<Button
onClick={onStartAnalysis}
disabled={triggering}
>
{triggering ? '触发中…' : '触发分析'}
</Button>
<Button variant="destructive" onClick={onStopAnalysis} disabled={!hasRunningTask}>
</Button>
<Button variant="outline" onClick={onContinueAnalysis} disabled={isAnalysisRunning}>
</Button>
</CardContent>
</Card>
</>
);
}
function SnapshotItem({ label, value, loading }: { label: string; value?: string; loading: boolean }) {
return (
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-8">{label}:</span>
<span className="font-medium">
{loading ? (
<span className="flex items-center gap-1">
{label === '日期' && <Spinner className="size-3" />}
<span className="text-muted-foreground">{label === '日期' ? '加载中...' : '-'}</span>
</span>
) : value ? (
value
) : (
<span className="text-muted-foreground">-</span>
)}
</span>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { CheckCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { TradingViewWidget } from '@/components/TradingViewWidget';
interface StockChartProps {
unifiedSymbol: string;
marketParam: string;
realtime: any;
realtimeLoading: boolean;
realtimeError: any;
}
export function StockChart({
unifiedSymbol,
marketParam,
realtime,
realtimeLoading,
realtimeError,
}: StockChartProps) {
return (
<div className="space-y-4">
<h2 className="text-lg font-medium"> TradingView</h2>
<div className="flex items-center justify-between text-sm mb-4">
<div className="flex items-center gap-3">
<CheckCircle className="size-4 text-green-600" />
<div className="text-muted-foreground">
- {unifiedSymbol}
</div>
</div>
<div className="text-xs text-muted-foreground">
{realtimeLoading ? (
<span className="inline-flex items-center gap-2"><Spinner className="size-3" /> </span>
) : realtimeError ? (
<span className="text-red-500"></span>
) : (() => {
const priceRaw = realtime?.price;
const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(priceRaw);
const tsRaw = realtime?.ts;
const tsDate = tsRaw == null ? null : new Date(typeof tsRaw === 'number' ? tsRaw : String(tsRaw));
const tsText = tsDate && !isNaN(tsDate.getTime()) ? `${tsDate.toLocaleString()}` : '';
if (Number.isFinite(priceNum)) {
return <span> {priceNum.toLocaleString()} {tsText}</span>;
}
return <span></span>;
})()}
</div>
</div>
<TradingViewWidget
symbol={unifiedSymbol}
market={marketParam}
height={500}
/>
</div>
);
}

View File

@ -0,0 +1,77 @@
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { formatElapsedTime } from '../utils';
interface TaskStatusProps {
requestId: string | null;
taskProgress: any;
startTime: number | null;
elapsedSeconds: number;
completionProgress: number;
currentAnalysisTask: string | null;
analysisConfig: any;
}
export function TaskStatus({
requestId,
taskProgress,
startTime,
elapsedSeconds,
completionProgress,
currentAnalysisTask,
analysisConfig,
}: TaskStatusProps) {
return (
<>
<Card className="w-80">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
{requestId ? (
<pre>{JSON.stringify(taskProgress || {}, null, 2)}</pre>
) : (
<div></div>
)}
</CardContent>
</Card>
<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>
</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>
</>
);
}

View File

@ -0,0 +1,414 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { useDataRequest, useTaskProgress } from '@/hooks/useApi';
interface AnalysisState {
content: string;
loading: boolean;
error: string | null;
elapsed_ms?: number;
}
interface AnalysisRecord {
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;
}
export function useAnalysisRunner(
financials: any,
analysisConfig: any,
normalizedMarket: string,
unifiedSymbol: string,
isLoading: boolean,
error: any
) {
// 分析类型列表
const analysisTypes = useMemo(() => {
if (!analysisConfig?.analysis_modules) return [];
return Object.keys(analysisConfig.analysis_modules);
}, [analysisConfig]);
// 分析状态管理
const [analysisStates, setAnalysisStates] = useState<Record<string, AnalysisState>>({});
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<AnalysisRecord[]>([]);
// 新架构:触发分析与查看任务进度
const { trigger: triggerAnalysisRequest, isMutating: triggering } = useDataRequest();
const [requestId, setRequestId] = useState<string | null>(null);
const { progress: taskProgress } = useTaskProgress(requestId);
// 计算完成比例
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]);
// 总耗时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]);
const hasRunningTask = useMemo(() => {
if (currentAnalysisTask !== null) return true;
if (analysisRecords.some(r => r.status === 'running')) return true;
return false;
}, [currentAnalysisTask, analysisRecords]);
// 全部任务是否完成
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 (!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 startTimeISO = new Date().toISOString();
setCurrentAnalysisTask(analysisType);
setAnalysisRecords(prev => [...prev, {
type: analysisType,
name: analysisName,
status: 'running',
start_ts: startTimeISO
}]);
try {
const startedMsLocal = Date.now();
const response = await fetch(
`/api/financials/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`
);
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 (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 startTimeISO = new Date().toISOString();
setCurrentAnalysisTask(analysisType);
setAnalysisRecords(prev => {
const next = [...prev];
const idx = next.findIndex(r => r.type === analysisType);
const updated: AnalysisRecord = {
type: analysisType,
name: analysisName,
status: 'running' as const,
start_ts: startTimeISO
};
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/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`,
{ 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();
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedMarket, unifiedSymbol, startTime, 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);
};
const triggerAnalysis = async () => {
const reqId = await triggerAnalysisRequest(unifiedSymbol, normalizedMarket || '');
if (reqId) setRequestId(reqId);
};
return {
analysisTypes,
analysisStates,
analysisRecords,
currentAnalysisTask,
triggerAnalysis,
triggering,
requestId,
setRequestId,
taskProgress,
startTime,
elapsedSeconds,
completionProgress,
totalElapsedMs,
stopAll,
continuePending,
retryAnalysis,
hasRunningTask,
isAnalysisRunning: isAnalysisRunningRef.current, // 注意:这里返回的是 ref.current可能不是响应式的。通常需要 state。
// 但原代码中 isAnalysisRunningRef 仅用于内部逻辑控制,没有直接用于 UI 展示(除了 disabled 状态,但这通常依赖 state 变化触发重渲染)。
// 在原 UI 中 `disabled={isAnalysisRunningRef.current}` 可能会有问题,如果仅仅 Ref 变了组件不一定会刷新。
// 不过原代码就是这样写的。
// 我建议可以用 hasRunningTask 来作为替代。
};
}

View File

@ -0,0 +1,65 @@
import { useParams, useSearchParams } from 'next/navigation';
import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisTemplateSets, useSnapshot, useRealtimeQuote } from '@/hooks/useApi';
export function useReportData() {
const params = useParams();
const searchParams = useSearchParams();
const symbol = params.symbol as string;
const marketParam = (searchParams.get('market') || '').toLowerCase();
const normalizedMarket = (() => {
if (marketParam === 'usa') return 'us';
if (marketParam === 'china') return 'cn';
if (marketParam === 'hkex') return 'hk';
if (marketParam === 'jpn') return 'jp';
return marketParam;
})();
const displayMarket = marketParam === 'china' ? '中国' : marketParam;
const isChina = normalizedMarket === '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 chinaFin = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10);
const otherFin = useFinancials(!isChina ? normalizedMarket : undefined, !isChina ? symbol : undefined, 10);
const financials = (chinaFin.data ?? otherFin.data) as any;
const error = chinaFin.error ?? otherFin.error;
const isLoading = chinaFin.isLoading || otherFin.isLoading;
const unifiedSymbol = isChina ? normalizedTsCode : symbol;
const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useSnapshot(normalizedMarket, unifiedSymbol);
const { data: realtime, error: realtimeError, isLoading: realtimeLoading } = useRealtimeQuote(normalizedMarket, unifiedSymbol, { maxAgeSeconds: 30, refreshIntervalMs: 5000 });
const { data: financialConfig } = useFinancialConfig();
const { data: templateSets } = useAnalysisTemplateSets();
return {
symbol,
unifiedSymbol,
displayMarket,
normalizedMarket,
marketParam,
financials,
isLoading,
error,
snapshot,
snapshotLoading,
snapshotError,
realtime,
realtimeLoading,
realtimeError,
financialConfig: financialConfig as any,
templateSets
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
export 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`;
};
export 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`;
};
export const numberFormatter = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export const integerFormatter = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
export const normalizeMarkdown = (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;
};
export const removeTitleFromContent = (content: string, title: string): string => {
if (!content || !title) {
return content;
}
const lines = content.split('\n');
// Trim and remove markdown from first line
const firstLine = (lines[0] || '').trim().replace(/^(#+\s*|\*\*|__)/, '').replace(/(\*\*|__)$/, '').trim();
if (firstLine === title) {
return lines.slice(1).join('\n').trim();
}
return content;
};