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_codenull
*
* @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_mvdaily_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."