Compare commits

..

11 Commits

Author SHA1 Message Date
xucheng
8b5d5f5777 feat(frontend): add always-visible '重新生成分析' button per module\nfix(backend): inject dependency context for single-module generation (final_conclusion placeholders) 2025-10-31 03:09:43 +00:00
xucheng
1e904eb7f4 chore(frontend): use 127.0.0.1:8000 in rewrites to avoid IPv6 (::1) 2025-10-30 08:56:07 +00:00
xucheng
cb0a12eef5 chore(frontend): 提交前端静态资源与 API 路由调整 2025-10-30 16:28:53 +08:00
xucheng
1794674806 chore(gitignore): 限定 /public、/reports 与 /test(s)/,避免误忽略前端必要文件 2025-10-30 16:28:05 +08:00
xucheng
2852436a82 chore: sync local changes after pulling remote updates 2025-10-30 08:12:58 +00:00
xucheng
515e2d53c9 chore(run.sh): enhance logging and add npm check for frontend setup 2025-10-30 16:08:21 +08:00
xucheng
7d42abea78 chore(run.sh): add detailed logging for venv setup 2025-10-30 15:29:04 +08:00
xucheng
194a4d4377 fix(run.sh): add check for python3-venv and provide instructions 2025-10-30 15:16:45 +08:00
xucheng
2f7cd70d36 feat: add run script to start frontend and backend with pm2 2025-10-30 15:03:03 +08:00
xucheng
e01d57c217 Merge branch 'develop'
# Conflicts:
#	backend/app/routers/config.py
#	backend/app/routers/financial.py
#	backend/app/schemas/config.py
#	backend/app/schemas/financial.py
#	backend/app/services/company_profile_client.py
#	backend/app/services/config_manager.py
#	backend/requirements.txt
#	frontend/src/app/config/page.tsx
#	frontend/src/app/report/[symbol]/page.tsx
#	frontend/src/hooks/useApi.ts
#	frontend/src/stores/useConfigStore.ts
#	frontend/src/types/index.ts
#	scripts/dev.sh
2025-10-30 14:53:47 +08:00
xucheng
aab1ab665b feat: 完成基础财务分析系统功能开发 2025-10-28 23:30:12 +08:00
19 changed files with 1078 additions and 11 deletions

11
.gitignore vendored
View File

@ -43,7 +43,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
@ -249,7 +248,7 @@ dist
# Gatsby files
.cache/
public
/public
# Storybook build outputs
.out
@ -302,7 +301,7 @@ data/
*.xlsx
# 报告和输出
reports/
/reports/
output/
exports/
@ -321,7 +320,7 @@ test_*.py
*_test.py
tests/test_*.py
tests/*_test.py
test/
/test/
tests/output/
tests/reports/
tests/coverage/
@ -338,8 +337,8 @@ tests/artifacts/
*.spec.jsx
*.spec.tsx
__tests__/
test/
tests/
/test/
/tests/
*.test.snap
coverage/
.nyc_output/

View File

@ -669,13 +669,77 @@ async def generate_analysis(
# Initialize analysis client with configured model
client = AnalysisClient(api_key=api_key, base_url=base_url, model=model)
# Prepare dependency context for single-module generation
# If the requested module declares dependencies, generate them first and inject their outputs
context = {}
try:
dependencies = analysis_cfg.get("dependencies", []) or []
if dependencies:
# Load full modules config to resolve dependency graph
analysis_config_full = load_analysis_config()
modules_config = analysis_config_full.get("analysis_modules", {})
# Collect all transitive dependencies
all_required = set()
def collect_all_deps(mod_name: str):
for dep in modules_config.get(mod_name, {}).get("dependencies", []) or []:
if dep not in all_required:
all_required.add(dep)
collect_all_deps(dep)
for dep in dependencies:
all_required.add(dep)
collect_all_deps(dep)
# Build subgraph and topologically sort
graph = {name: [d for d in (modules_config.get(name, {}).get("dependencies", []) or []) if d in all_required] for name in all_required}
in_degree = {u: 0 for u in graph}
for u, deps in graph.items():
for v in deps:
in_degree[v] += 1
queue = [u for u, deg in in_degree.items() if deg == 0]
order = []
while queue:
u = queue.pop(0)
order.append(u)
for v in graph.get(u, []):
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
if len(order) != len(graph):
# Fallback: if cycle detected, just use any order
order = list(all_required)
# Generate dependencies in order
completed = {}
for mod in order:
cfg = modules_config.get(mod, {})
dep_ctx = {d: completed.get(d, "") for d in (cfg.get("dependencies", []) or [])}
dep_client = AnalysisClient(api_key=api_key, base_url=base_url, model=cfg.get("model", model))
dep_result = await dep_client.generate_analysis(
analysis_type=mod,
company_name=company_name,
ts_code=ts_code,
prompt_template=cfg.get("prompt_template", ""),
financial_data=financial_data,
context=dep_ctx,
)
completed[mod] = dep_result.get("content", "") if dep_result.get("success") else ""
context = {dep: completed.get(dep, "") for dep in dependencies}
except Exception:
# Best-effort context; if anything goes wrong, continue without it
context = {}
# Generate analysis
result = await client.generate_analysis(
analysis_type=analysis_type,
company_name=company_name,
ts_code=ts_code,
prompt_template=prompt_template,
financial_data=financial_data
financial_data=financial_data,
context=context,
)
logger.info(f"[API] Analysis generation completed, success={result.get('success')}")

View File

@ -20,6 +20,12 @@ const eslintConfig = [
"next-env.d.ts",
],
},
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": "off",
},
},
];
export default eslintConfig;

View File

@ -17,11 +17,11 @@ const nextConfig = {
return [
{
source: "/api/:path*",
destination: "http://localhost:8000/api/:path*",
destination: "http://127.0.0.1:8000/api/:path*",
},
{
source: "/health",
destination: "http://localhost:8000/health",
destination: "http://127.0.0.1:8000/health",
},
];
},

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,14 @@
import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:8000/api';
export async function POST(req: NextRequest) {
const body = await req.text();
const resp = await fetch(`${BACKEND_BASE}/config/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const text = await resp.text();
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
}

View File

@ -2,9 +2,13 @@ import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:8000/api';
export async function GET(req: NextRequest, { params }: { params: { slug: string[] } }) {
export async function GET(
req: NextRequest,
context: { params: Promise<{ slug: string[] }> }
) {
const url = new URL(req.url);
const path = params.slug.join('/');
const { slug } = await context.params;
const path = slug.join('/');
const target = `${BACKEND_BASE}/financials/${path}${url.search}`;
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
const text = await resp.text();

View File

@ -1491,6 +1491,7 @@ export default function ReportPage() {
: '待开始'}
</div>
</div>
{/* 失败时的“重新分析”按钮(兼容原逻辑) */}
{state.error && !state.loading && (
<Button
variant="outline"
@ -1502,6 +1503,18 @@ export default function ReportPage() {
重新分析
</Button>
)}
{/* 新增:始终可见的“重新生成分析”按钮 */}
{!state.loading && (
<Button
variant="ghost"
size="sm"
onClick={() => retryAnalysis(analysisType)}
disabled={currentAnalysisTask !== null}
>
<RotateCw className="size-4" />
重新生成分析
</Button>
)}
</div>
{state.error && (

View File

@ -0,0 +1,48 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export default function ReportsPage() {
return (
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">报表中心</h1>
<p className="text-sm text-muted-foreground">查看与管理财务报表与分析结果。</p>
</header>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>利润表</CardTitle>
<CardDescription>收入、成本、净利润</CardDescription>
</CardHeader>
<CardContent className="space-x-2">
<Badge variant="outline">季度</Badge>
<Badge variant="secondary">年度</Badge>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>资产负债表</CardTitle>
<CardDescription>资产、负债、权益</CardDescription>
</CardHeader>
<CardContent className="space-x-2">
<Badge variant="outline">结构</Badge>
<Badge variant="secondary">趋势</Badge>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>现金流量表</CardTitle>
<CardDescription>经营、投资、筹资</CardDescription>
</CardHeader>
<CardContent className="space-x-2">
<Badge variant="outline">自由现金流</Badge>
<Badge variant="secondary">质量</Badge>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,434 @@
/**
* ExecutionStepManager - 可扩展的步骤执行框架
*
* 提供步骤的动态添加、管理和执行,支持错误处理和状态回调
*
* 主要功能:
* - 步骤的动态添加和管理
* - 支持重试机制和错误处理
* - 提供执行状态回调
* - 支持并行和串行执行
* - 可扩展的步骤定义
*
* @author Financial Analysis Platform Team
* @version 1.0.0
*/
// ============================================================================
// 类型定义
// ============================================================================
/**
* 执行步骤接口
*/
export interface ExecutionStep {
/** 步骤唯一标识符 */
id: string;
/** 步骤显示名称 */
name: string;
/** 步骤详细描述 */
description: string;
/** 执行函数(可选) */
execute?: () => Promise<void>;
}
/**
* 执行选项接口
*/
export interface ExecutionOptions {
/** 步骤开始回调 */
onStepStart?: (step: ExecutionStep, index: number, total: number) => void;
/** 步骤完成回调 */
onStepComplete?: (step: ExecutionStep, index: number, total: number) => void;
/** 步骤错误回调 */
onStepError?: (step: ExecutionStep, index: number, total: number, error: Error) => void;
/** 全部完成回调 */
onComplete?: () => void;
/** 执行错误回调 */
onError?: (error: Error) => void;
/** 最大重试次数 */
maxRetries?: number;
/** 重试延迟(毫秒) */
retryDelay?: number;
/** 出错时是否继续执行 */
continueOnError?: boolean;
}
/**
* 执行上下文接口
*/
export interface ExecutionContext {
/** 当前执行步骤 */
currentStep: ExecutionStep | null;
/** 当前步骤索引 */
stepIndex: number;
/** 总步骤数 */
totalSteps: number;
/** 是否正在运行 */
isRunning: boolean;
/** 是否有错误 */
hasError: boolean;
/** 错误信息 */
errorMessage?: string;
/** 重试次数 */
retryCount: number;
/** 最大重试次数 */
maxRetries: number;
/** 是否可重试 */
canRetry: boolean;
}
export class ExecutionStepManager {
private steps: ExecutionStep[] = [];
private context: ExecutionContext = {
currentStep: null,
stepIndex: 0,
totalSteps: 0,
isRunning: false,
hasError: false,
errorMessage: undefined,
retryCount: 0,
maxRetries: 0,
canRetry: false
};
private options: ExecutionOptions = {};
constructor(steps: ExecutionStep[] = [], options: ExecutionOptions = {}) {
this.steps = [...steps];
this.options = {
maxRetries: 2,
retryDelay: 1000,
continueOnError: false,
...options
};
this.updateContext();
}
/**
* 添加执行步骤
*/
addStep(step: ExecutionStep): void {
this.steps.push(step);
this.updateContext();
}
/**
* 批量添加执行步骤
*/
addSteps(steps: ExecutionStep[]): void {
this.steps.push(...steps);
this.updateContext();
}
/**
* 插入步骤到指定位置
*/
insertStep(index: number, step: ExecutionStep): void {
this.steps.splice(index, 0, step);
this.updateContext();
}
/**
* 移除步骤
*/
removeStep(stepId: string): boolean {
const index = this.steps.findIndex(step => step.id === stepId);
if (index !== -1) {
this.steps.splice(index, 1);
this.updateContext();
return true;
}
return false;
}
/**
* 清空所有步骤
*/
clearSteps(): void {
this.steps = [];
this.updateContext();
}
/**
* 获取所有步骤
*/
getSteps(): ExecutionStep[] {
return [...this.steps];
}
/**
* 获取当前执行上下文
*/
getContext(): ExecutionContext {
return { ...this.context };
}
/**
* 更新执行选项
*/
setOptions(options: ExecutionOptions): void {
this.options = { ...this.options, ...options };
}
/**
* 执行所有步骤
*/
async execute(): Promise<void> {
if (this.context.isRunning) {
throw new Error('Execution is already in progress');
}
if (this.steps.length === 0) {
throw new Error('No steps to execute');
}
this.context.isRunning = true;
this.context.hasError = false;
this.context.errorMessage = undefined;
this.context.stepIndex = 0;
this.context.retryCount = 0;
this.context.maxRetries = this.options.maxRetries || 2;
try {
for (let i = 0; i < this.steps.length; i++) {
const step = this.steps[i];
this.context.currentStep = step;
this.context.stepIndex = i;
// 通知步骤开始
this.options.onStepStart?.(step, i, this.steps.length);
let stepSuccess = false;
let lastError: Error | null = null;
// 重试逻辑
for (let retryAttempt = 0; retryAttempt <= this.context.maxRetries; retryAttempt++) {
try {
this.context.retryCount = retryAttempt;
// 如果是重试,等待一段时间
if (retryAttempt > 0 && this.options.retryDelay) {
await new Promise(resolve => setTimeout(resolve, this.options.retryDelay));
}
// 执行步骤(如果有执行函数)
if (step.execute) {
await step.execute();
}
stepSuccess = true;
break; // 成功执行,跳出重试循环
} catch (stepError) {
lastError = stepError instanceof Error ? stepError : new Error(String(stepError));
// 如果还有重试机会,继续重试
if (retryAttempt < this.context.maxRetries) {
console.warn(`Step "${step.name}" failed, retrying (${retryAttempt + 1}/${this.context.maxRetries + 1}):`, lastError.message);
continue;
}
}
}
if (stepSuccess) {
// 通知步骤完成
this.options.onStepComplete?.(step, i, this.steps.length);
} else {
// 所有重试都失败了
const error = lastError || new Error('Step execution failed');
// 更新错误状态
this.context.hasError = true;
this.context.errorMessage = error.message;
this.context.canRetry = true;
// 通知步骤错误
this.options.onStepError?.(step, i, this.steps.length, error);
// 如果不继续执行,抛出错误
if (!this.options.continueOnError) {
throw error;
}
}
}
// 所有步骤执行完成
this.options.onComplete?.();
} catch (error) {
const execError = error instanceof Error ? error : new Error(String(error));
// 通知执行错误
this.options.onError?.(execError);
// 重新抛出错误
throw execError;
} finally {
this.context.isRunning = false;
}
}
/**
* 执行单个步骤
*/
async executeStep(stepId: string): Promise<void> {
const stepIndex = this.steps.findIndex(step => step.id === stepId);
if (stepIndex === -1) {
throw new Error(`Step with id '${stepId}' not found`);
}
const step = this.steps[stepIndex];
this.context.currentStep = step;
this.context.stepIndex = stepIndex;
this.context.isRunning = true;
this.context.hasError = false;
this.context.errorMessage = undefined;
try {
// 通知步骤开始
this.options.onStepStart?.(step, stepIndex, this.steps.length);
// 执行步骤
if (step.execute) {
await step.execute();
}
// 通知步骤完成
this.options.onStepComplete?.(step, stepIndex, this.steps.length);
} catch (stepError) {
const error = stepError instanceof Error ? stepError : new Error(String(stepError));
// 更新错误状态
this.context.hasError = true;
this.context.errorMessage = error.message;
// 通知步骤错误
this.options.onStepError?.(step, stepIndex, this.steps.length, error);
throw error;
} finally {
this.context.isRunning = false;
}
}
/**
* 停止执行(如果正在运行)
*/
stop(): void {
this.context.isRunning = false;
}
/**
* 重试当前失败的步骤
*/
async retry(): Promise<void> {
if (!this.context.hasError || !this.context.canRetry) {
throw new Error('No failed step to retry');
}
if (this.context.isRunning) {
throw new Error('Execution is already in progress');
}
// 重置错误状态
this.context.hasError = false;
this.context.errorMessage = undefined;
this.context.canRetry = false;
// 重新执行从当前步骤开始
try {
await this.execute();
} catch (error) {
// 错误已经在execute方法中处理
throw error;
}
}
/**
* 重置执行状态
*/
reset(): void {
this.context = {
currentStep: null,
stepIndex: 0,
totalSteps: this.steps.length,
isRunning: false,
hasError: false,
errorMessage: undefined,
retryCount: 0,
maxRetries: this.options.maxRetries || 2,
canRetry: false
};
}
/**
* 检查是否正在执行
*/
isRunning(): boolean {
return this.context.isRunning;
}
/**
* 检查是否有错误
*/
hasError(): boolean {
return this.context.hasError;
}
/**
* 获取错误信息
*/
getErrorMessage(): string | undefined {
return this.context.errorMessage;
}
/**
* 检查是否可以重试
*/
canRetry(): boolean {
return this.context.canRetry;
}
/**
* 更新执行上下文
*/
private updateContext(): void {
this.context.totalSteps = this.steps.length;
this.context.maxRetries = this.options.maxRetries || 2;
if (!this.context.isRunning) {
this.context.stepIndex = 0;
this.context.currentStep = null;
this.context.retryCount = 0;
}
}
/**
* 创建一个带有预定义步骤的管理器实例
*/
static createWithSteps(steps: ExecutionStep[], options: ExecutionOptions = {}): ExecutionStepManager {
return new ExecutionStepManager(steps, options);
}
/**
* 创建一个空的管理器实例
*/
static create(options: ExecutionOptions = {}): ExecutionStepManager {
return new ExecutionStepManager([], options);
}
}
/**
* 预定义的执行步骤
*/
export const DEFAULT_EXECUTION_STEPS: ExecutionStep[] = [
{
id: 'fetch_financial_data',
name: '正在读取财务数据',
description: '从Tushare API获取公司财务指标数据'
}
];
/**
* 创建默认的执行步骤管理器
*/
export function createDefaultStepManager(options: ExecutionOptions = {}): ExecutionStepManager {
return ExecutionStepManager.createWithSteps(DEFAULT_EXECUTION_STEPS, options);
}

View File

@ -0,0 +1,326 @@
/**
* 财务数据处理工具函数
* 包含股票代码规范化、数据格式化等通用功能
*/
import type { MarketType, CompanyInfo, FinancialMetricConfig } from '@/types';
// ============================================================================
// 股票代码处理
// ============================================================================
/**
* 规范化A股代码为ts_code格式
* @param input - 输入的股票代码
* @returns 规范化后的ts_code,如果无效则返回null
*
* @example
* ```typescript
* normalizeTsCode('600519') // '600519.SH'
* normalizeTsCode('000001') // '000001.SZ'
* normalizeTsCode('600519.SH') // '600519.SH'
* normalizeTsCode('invalid') // null
* ```
*/
export function normalizeTsCode(input: string): string | null {
const code = (input || "").trim();
if (!code) return null;
// 仅支持数字代码的简易规则:6开头上证,0/3开头深证
if (/^\d{6}$/.test(code)) {
if (code.startsWith("6")) return `${code}.SH`;
if (code.startsWith("0") || code.startsWith("3")) return `${code}.SZ`;
}
// 若已含交易所后缀则直接返回
if (/^\d{6}\.(SH|SZ)$/i.test(code)) return code.toUpperCase();
return null;
}
/**
* 验证股票代码是否有效
* @param code - 股票代码
* @returns 是否为有效的股票代码
*/
export function isValidTsCode(code: string): boolean {
return normalizeTsCode(code) !== null;
}
/**
* 从输入中提取股票代码
* @param input - 可能包含股票代码和公司名的输入
* @returns 提取的股票代码,如果没有找到则返回null
*/
export function extractTsCode(input: string): string | null {
const trimmed = input.trim();
// 尝试从输入开头提取代码
const codeMatch = trimmed.match(/^(\d{6}(?:\.[A-Z]{2})?)/);
if (codeMatch) {
return normalizeTsCode(codeMatch[1]);
}
return null;
}
// ============================================================================
// 数据格式化
// ============================================================================
/**
* 格式化财务数值显示
* @param value - 原始数值
* @param group - 指标分组
* @param api - API接口名
* @param metricKey - 指标键名
* @returns 格式化后的字符串
*/
export function formatFinancialValue(
value: number | null | undefined,
group?: string,
api?: string,
metricKey?: string
): string {
if (value === null || typeof value === "undefined") return "-";
const num = Number(value);
if (Number.isNaN(num)) return "-";
const nf1 = new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: 1,
maximumFractionDigits: 1
});
const nf0 = new Intl.NumberFormat("zh-CN", {
maximumFractionDigits: 0
});
// 报表类统一按亿元显示(1位小数,千分位)
if (group === "income" || group === "balancesheet" || group === "cashflow" || metricKey === "revenue") {
const scaled = num / 1e8;
return nf1.format(scaled);
}
// 市值 total_mv(daily_basic,万元)-> 亿元,整数(千分位)
if (api === "daily_basic" && metricKey === "total_mv") {
const scaledYi = num / 1e4;
return nf0.format(Math.round(scaledYi));
}
// 员工数 / 股东数 -> 整数(千分位),不做单位换算
if (metricKey === "employees" || metricKey === "holder_num") {
return nf0.format(Math.round(num));
}
// 其他数值 -> 1位小数 + 千分位
return nf1.format(num);
}
/**
* 获取指标单位文本
* @param group - 指标分组
* @param api - API接口名
* @param metricKey - 指标键名
* @returns 单位文本,如果不需要显示单位则返回空字符串
*/
export function getMetricUnit(group?: string, api?: string, metricKey?: string): string {
// 报表类和市值显示亿元单位
if (
group === "income" ||
group === "balancesheet" ||
group === "cashflow" ||
metricKey === "total_mv"
) {
return "(亿元)";
}
return "";
}
// ============================================================================
// 配置处理
// ============================================================================
/**
* 扁平化API分组配置
* @param apiGroups - API分组配置对象
* @param groupOrder - 分组顺序数组
* @returns 扁平化的配置项数组和映射对象
*/
export function flattenApiGroups(
apiGroups: Record<string, FinancialMetricConfig[]>,
groupOrder: string[] = ["income", "fina_indicator", "balancesheet", "cashflow", "daily_basic", "daily", "unknown"]
): {
items: FinancialMetricConfig[];
groupMap: Record<string, string>;
apiMap: Record<string, string>;
} {
const items: FinancialMetricConfig[] = [];
const groupMap: Record<string, string> = {};
const apiMap: Record<string, string> = {};
for (const group of groupOrder) {
const arr = Array.isArray(apiGroups[group]) ? apiGroups[group] : [];
for (const item of arr) {
const param = (item?.tushareParam || "").trim();
if (!param) continue;
const api = (item?.api || "").trim();
items.push({
displayText: item.displayText,
tushareParam: param,
api,
group
});
groupMap[param] = group;
apiMap[param] = api;
}
}
return { items, groupMap, apiMap };
}
// ============================================================================
// 错误处理
// ============================================================================
/**
* 增强错误信息
* @param error - 原始错误
* @returns 增强后的错误信息
*/
export function enhanceErrorMessage(error: unknown): string {
const errorMsg = error instanceof Error ? error.message : "未知错误";
// 网络相关错误
if (errorMsg.includes('fetch') || errorMsg.includes('NetworkError')) {
return "网络连接失败,请检查后端服务是否已启动或网络连接是否正常。";
}
if (errorMsg.includes('timeout')) {
return "请求超时,请稍后重试。";
}
// 数据相关错误
if (errorMsg.includes('未查询到数据')) {
return "未查询到数据,请确认代码或稍后重试。";
}
// 服务相关错误
if (errorMsg.includes('500') || errorMsg.includes('Internal Server Error')) {
return "服务器内部错误,请稍后重试或联系管理员。";
}
if (errorMsg.includes('404') || errorMsg.includes('Not Found')) {
return "请求的资源不存在,请检查输入参数。";
}
// 默认错误处理
if (!errorMsg || errorMsg === "未知错误") {
return "查询失败,请检查后端服务是否已启动。";
}
return errorMsg;
}
/**
* 判断错误是否可重试
* @param error - 错误对象或错误信息
* @returns 是否可重试
*/
export function isRetryableError(error: unknown): boolean {
const errorMsg = error instanceof Error ? error.message : String(error);
return (
errorMsg.includes('网络') ||
errorMsg.includes('连接') ||
errorMsg.includes('超时') ||
errorMsg.includes('timeout') ||
errorMsg.includes('fetch') ||
errorMsg.includes('NetworkError')
);
}
// ============================================================================
// 数据验证
// ============================================================================
/**
* 验证市场类型是否支持
* @param market - 市场类型
* @returns 是否支持该市场
*/
export function isSupportedMarket(market: string): market is MarketType {
return ['cn', 'us', 'hk', 'jp', 'other'].includes(market);
}
/**
* 验证公司信息是否有效
* @param company - 公司信息对象
* @returns 是否为有效的公司信息
*/
export function isValidCompanyInfo(company: unknown): company is CompanyInfo {
return (
typeof company === 'object' &&
company !== null &&
typeof (company as CompanyInfo).ts_code === 'string' &&
isValidTsCode((company as CompanyInfo).ts_code)
);
}
// ============================================================================
// 本地存储工具
// ============================================================================
/**
* 检查localStorage是否可用
* @returns localStorage是否可用
*/
export function isLocalStorageAvailable(): boolean {
try {
const testKey = '__localStorage_test__';
localStorage.setItem(testKey, 'test');
localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
}
/**
* 安全地从localStorage获取数据
* @param key - 存储键
* @param defaultValue - 默认值
* @returns 存储的数据或默认值
*/
export function safeGetFromStorage<T>(key: string, defaultValue: T): T {
if (!isLocalStorageAvailable()) {
return defaultValue;
}
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : defaultValue;
} catch {
return defaultValue;
}
}
/**
* 安全地向localStorage保存数据
* @param key - 存储键
* @param value - 要保存的数据
* @returns 是否保存成功
*/
export function safeSetToStorage(key: string, value: unknown): boolean {
if (!isLocalStorageAvailable()) {
return false;
}
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

20
pm2.config.js Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
apps : [{
name: "frontend",
cwd: "./frontend",
script: "npm",
args: "start",
env: {
"PORT": 3000
}
}, {
name: "backend",
cwd: "./backend",
script: "./.venv/bin/uvicorn",
args: "app.main:app --host 0.0.0.0 --port 8000",
interpreter: "none",
env: {
"PYTHONPATH": "."
}
}]
};

117
scripts/run.sh Executable file
View File

@ -0,0 +1,117 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status.
set -e
# --- Prerequisite check for python3-venv ---
echo "Checking for Python venv module..."
if ! python3 -c 'import venv' &> /dev/null; then
echo "Python 'venv' module is missing. It's required to create a virtual environment for the backend."
# Attempt to provide specific installation instructions
if command -v apt &> /dev/null; then
PY_VERSION_SHORT=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
echo "It seems you are on a Debian/Ubuntu-based system. Please install the venv module by running:"
echo "sudo apt update && sudo apt install python${PY_VERSION_SHORT}-venv"
else
echo "Please install the Python 'venv' module for your system."
fi
exit 1
fi
echo "Python venv module found."
# --- Backend Setup ---
echo "Setting up backend..."
cd backend
echo "--> Changed directory to $(pwd)"
# Create a virtual environment if it doesn't exist or is invalid
echo "--> Checking for .venv/bin/activate..."
if [ ! -f ".venv/bin/activate" ]; then
echo "--> .venv/bin/activate NOT found. Recreating virtual environment..."
rm -rf .venv
python3 -m venv .venv
echo "--> Virtual environment created."
else
echo "--> .venv/bin/activate found. Skipping creation."
fi
# Activate virtual environment and install dependencies
echo "--> Activating virtual environment..."
source .venv/bin/activate
echo "--> Virtual environment activated."
pip install -r requirements.txt
deactivate
echo "Backend setup complete."
cd ..
echo "--> Changed directory to $(pwd)"
# --- Frontend Setup ---
echo "Setting up frontend..."
# Check if npm is installed
if ! command -v npm &> /dev/null
then
echo "npm could not be found. Please install Node.js and npm to continue."
exit 1
fi
cd frontend
echo "--> Changed directory to $(pwd)"
npm install
echo "Frontend setup complete."
# --- Frontend Production Build ---
echo "Building frontend for production..."
npm run build
echo "Frontend build complete."
cd ..
echo "--> Changed directory to $(pwd)"
# --- PM2 Execution ---
echo "Starting application with pm2..."
# Check if pm2 is installed
if ! command -v pm2 &> /dev/null
then
echo "pm2 could not be found. Please install it with 'npm install -g pm2'"
exit 1
fi
# Create a pm2 config file if it doesn't exist
if [ ! -f "pm2.config.js" ]; then
echo "Creating pm2.config.js..."
cat > pm2.config.js << EOL
module.exports = {
apps : [{
name: "frontend",
cwd: "./frontend",
script: "npm",
args: "start",
env: {
"PORT": 3000
}
}, {
name: "backend",
cwd: "./backend",
script: "./.venv/bin/uvicorn",
args: "app.main:app --host 0.0.0.0 --port 8000",
interpreter: "none",
env: {
"PYTHONPATH": "."
}
}]
};
EOL
fi
# Start processes with pm2
pm2 start pm2.config.js
echo "Application started with pm2."
echo "Use 'pm2 list' to see the status of the applications."
echo "Use 'pm2 logs' to see the logs."
echo "Use 'pm2 stop all' to stop the applications."

11
scripts/stop.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/bash
# 停止所有用 pm2 启动的服务,并清理进程
echo "Stopping all pm2 applications..."
pm2 stop all
echo "All pm2 applications stopped."
echo "Deleting all pm2 processes..."
pm2 delete all
echo "All pm2 processes deleted."