Compare commits
11 Commits
b5a4d2212c
...
8b5d5f5777
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5d5f5777 | ||
|
|
1e904eb7f4 | ||
|
|
cb0a12eef5 | ||
|
|
1794674806 | ||
|
|
2852436a82 | ||
|
|
515e2d53c9 | ||
|
|
7d42abea78 | ||
|
|
194a4d4377 | ||
|
|
2f7cd70d36 | ||
|
|
e01d57c217 | ||
|
|
aab1ab665b |
11
.gitignore
vendored
11
.gitignore
vendored
@ -43,7 +43,6 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
@ -249,7 +248,7 @@ dist
|
|||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
public
|
/public
|
||||||
|
|
||||||
# Storybook build outputs
|
# Storybook build outputs
|
||||||
.out
|
.out
|
||||||
@ -302,7 +301,7 @@ data/
|
|||||||
*.xlsx
|
*.xlsx
|
||||||
|
|
||||||
# 报告和输出
|
# 报告和输出
|
||||||
reports/
|
/reports/
|
||||||
output/
|
output/
|
||||||
exports/
|
exports/
|
||||||
|
|
||||||
@ -321,7 +320,7 @@ test_*.py
|
|||||||
*_test.py
|
*_test.py
|
||||||
tests/test_*.py
|
tests/test_*.py
|
||||||
tests/*_test.py
|
tests/*_test.py
|
||||||
test/
|
/test/
|
||||||
tests/output/
|
tests/output/
|
||||||
tests/reports/
|
tests/reports/
|
||||||
tests/coverage/
|
tests/coverage/
|
||||||
@ -338,8 +337,8 @@ tests/artifacts/
|
|||||||
*.spec.jsx
|
*.spec.jsx
|
||||||
*.spec.tsx
|
*.spec.tsx
|
||||||
__tests__/
|
__tests__/
|
||||||
test/
|
/test/
|
||||||
tests/
|
/tests/
|
||||||
*.test.snap
|
*.test.snap
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|||||||
@ -669,13 +669,77 @@ async def generate_analysis(
|
|||||||
# Initialize analysis client with configured model
|
# Initialize analysis client with configured model
|
||||||
client = AnalysisClient(api_key=api_key, base_url=base_url, model=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
|
# Generate analysis
|
||||||
result = await client.generate_analysis(
|
result = await client.generate_analysis(
|
||||||
analysis_type=analysis_type,
|
analysis_type=analysis_type,
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
ts_code=ts_code,
|
ts_code=ts_code,
|
||||||
prompt_template=prompt_template,
|
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')}")
|
logger.info(f"[API] Analysis generation completed, success={result.get('success')}")
|
||||||
|
|||||||
@ -20,6 +20,12 @@ const eslintConfig = [
|
|||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-empty-object-type": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@ -17,11 +17,11 @@ const nextConfig = {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
destination: "http://localhost:8000/api/:path*",
|
destination: "http://127.0.0.1:8000/api/:path*",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/health",
|
source: "/health",
|
||||||
destination: "http://localhost:8000/health",
|
destination: "http://127.0.0.1:8000/health",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal 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
1
frontend/public/next.svg
Normal 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 |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal 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 |
14
frontend/src/app/api/config/test/route.ts
Normal file
14
frontend/src/app/api/config/test/route.ts
Normal 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' } });
|
||||||
|
}
|
||||||
@ -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';
|
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 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 target = `${BACKEND_BASE}/financials/${path}${url.search}`;
|
||||||
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
|
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
|
|||||||
@ -1491,6 +1491,7 @@ export default function ReportPage() {
|
|||||||
: '待开始'}
|
: '待开始'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 失败时的“重新分析”按钮(兼容原逻辑) */}
|
||||||
{state.error && !state.loading && (
|
{state.error && !state.loading && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -1502,6 +1503,18 @@ export default function ReportPage() {
|
|||||||
重新分析
|
重新分析
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* 新增:始终可见的“重新生成分析”按钮 */}
|
||||||
|
{!state.loading && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => retryAnalysis(analysisType)}
|
||||||
|
disabled={currentAnalysisTask !== null}
|
||||||
|
>
|
||||||
|
<RotateCw className="size-4" />
|
||||||
|
重新生成分析
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
|
|||||||
48
frontend/src/app/reports/page.tsx
Normal file
48
frontend/src/app/reports/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
434
frontend/src/lib/execution-step-manager.ts
Normal file
434
frontend/src/lib/execution-step-manager.ts
Normal 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);
|
||||||
|
}
|
||||||
326
frontend/src/lib/financial-utils.ts
Normal file
326
frontend/src/lib/financial-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
20
pm2.config.js
Normal 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
117
scripts/run.sh
Executable 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
11
scripts/stop.sh
Executable 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."
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user