refactor: 拆分 ReportPage 为组件和 Hooks
- 将庞大的 page.tsx 拆分为多个独立组件 (components/) - 提取业务逻辑到 Hooks (hooks/) - 提取工具函数到 utils.ts - 优化代码结构和可维护性
This commit is contained in:
parent
e699cda81e
commit
4fef6bf35b
115
frontend/src/app/report/[symbol]/components/AnalysisContent.tsx
Normal file
115
frontend/src/app/report/[symbol]/components/AnalysisContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
147
frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx
Normal file
147
frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
569
frontend/src/app/report/[symbol]/components/FinancialTable.tsx
Normal file
569
frontend/src/app/report/[symbol]/components/FinancialTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
146
frontend/src/app/report/[symbol]/components/ReportHeader.tsx
Normal file
146
frontend/src/app/report/[symbol]/components/ReportHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
57
frontend/src/app/report/[symbol]/components/StockChart.tsx
Normal file
57
frontend/src/app/report/[symbol]/components/StockChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
77
frontend/src/app/report/[symbol]/components/TaskStatus.tsx
Normal file
77
frontend/src/app/report/[symbol]/components/TaskStatus.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
414
frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts
Normal file
414
frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts
Normal 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 来作为替代。
|
||||
};
|
||||
}
|
||||
|
||||
65
frontend/src/app/report/[symbol]/hooks/useReportData.ts
Normal file
65
frontend/src/app/report/[symbol]/hooks/useReportData.ts
Normal 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
64
frontend/src/app/report/[symbol]/utils.ts
Normal file
64
frontend/src/app/report/[symbol]/utils.ts
Normal 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;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user