refactor(architecture): Align frontend & docs with DB gateway pattern
本次提交旨在完成一次架构一致性重构,核心目标是使前端代码和相关文档完全符合“`data-persistence-service`是唯一数据库守门人”的设计原则。
主要变更包括:
1. **移除前端数据库直连**:
* 从`docker-compose.yml`中删除了`frontend`服务的`DATABASE_URL`。
* 彻底移除了`frontend`项目中的`Prisma`依赖、配置文件和客户端实例。
2. **清理前端UI**:
* 从配置页面中删除了所有与数据库设置相关的UI组件和业务逻辑。
3. **同步更新文档**:
* 更新了《用户使用文档》和《需求文档》,移除了所有提及或要求前端进行数据库配置的过时内容。
此次重构后,系统前端的数据交互已完全收敛至`api-gateway`,确保了架构的统一性、健壮性和高内聚。
This commit is contained in:
parent
45ec5bb16d
commit
9d62a53b73
@ -0,0 +1,51 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
rust-version = "1.63"
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
authors = ["Amanieu d'Antras <amanieu@gmail.com>"]
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "Per-object thread-local storage"
|
||||
documentation = "https://docs.rs/thread_local/"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"thread_local",
|
||||
"concurrent",
|
||||
"thread",
|
||||
]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/Amanieu/thread_local-rs"
|
||||
|
||||
[features]
|
||||
nightly = []
|
||||
|
||||
[lib]
|
||||
name = "thread_local"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bench]]
|
||||
name = "thread_local"
|
||||
path = "benches/thread_local.rs"
|
||||
harness = false
|
||||
|
||||
[dependencies.cfg-if]
|
||||
version = "1.0.0"
|
||||
|
||||
[dev-dependencies.criterion]
|
||||
version = "0.5.1"
|
||||
@ -55,8 +55,6 @@ services:
|
||||
environment:
|
||||
# 让 Next 的 API 路由代理到新的 api-gateway
|
||||
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
|
||||
# Prisma 直连数据库(与后端共用同一库)
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public
|
||||
NODE_ENV: development
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
volumes:
|
||||
@ -66,7 +64,6 @@ services:
|
||||
ports:
|
||||
- "13001:3001"
|
||||
depends_on:
|
||||
- postgres-db
|
||||
- api-gateway
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@ -104,8 +104,7 @@
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. 选股系统应当提供配置页面用于设置数据库连接参数
|
||||
2. 选股系统应当提供配置页面用于设置Gemini_API密钥
|
||||
3. 选股系统应当提供配置页面用于设置各市场的数据源配置
|
||||
4. 当配置更新时,选股系统应当验证配置的有效性
|
||||
5. 当配置保存时,选股系统应当将配置持久化存储
|
||||
1. 选股系统应当提供配置页面用于设置Gemini_API密钥
|
||||
2. 选股系统应当提供配置页面用于设置各市场的数据源配置
|
||||
3. 当配置更新时,选股系统应当验证配置的有效性
|
||||
4. 当配置保存时,选股系统应当将配置持久化存储
|
||||
@ -73,7 +73,6 @@
|
||||
|
||||
系统提供完善的配置管理功能:
|
||||
|
||||
- **数据库配置**:配置 PostgreSQL 数据库连接
|
||||
- **AI 服务配置**:配置 AI 模型的 API 密钥和端点
|
||||
- **数据源配置**:配置 Tushare、Finnhub 等数据源的 API 密钥
|
||||
- **分析模块配置**:自定义分析模块的名称、模型和提示词模板
|
||||
@ -221,15 +220,11 @@ A:
|
||||
|
||||
首次使用系统时,需要配置以下内容:
|
||||
|
||||
1. **数据库配置**(如使用)
|
||||
- 数据库连接 URL:`postgresql+asyncpg://user:password@host:port/database`
|
||||
- 使用"测试连接"按钮验证连接
|
||||
|
||||
2. **AI 服务配置**
|
||||
1. **AI 服务配置**
|
||||
- API Key:输入您的 AI 服务 API 密钥
|
||||
- Base URL:输入 API 端点地址(如使用自建服务)
|
||||
|
||||
3. **数据源配置**
|
||||
2. **数据源配置**
|
||||
- **Tushare**:输入 Tushare API Key(中国市场必需)
|
||||
- **Finnhub**:输入 Finnhub API Key(全球市场可选)
|
||||
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
# 项目文档中心
|
||||
|
||||
欢迎来到基本面选股系统的文档中心。本文档旨在作为项目所有相关文档的入口和导航,帮助团队成员快速找到所需信息。
|
||||
|
||||
## 概览
|
||||
|
||||
本文档库遵循特定的结构化命名和分类约定,旨在清晰地分离不同领域的关注点。主要目录结构如下:
|
||||
|
||||
- **/1_requirements**: 存放所有与产品需求和用户功能相关的文档。
|
||||
- **/2_architecture**: 包含系统高级架构、设计原则和核心规范。
|
||||
- **/3_project_management**: 用于项目跟踪、开发日志和任务管理。
|
||||
- **/4_archive**: 存放已合并或过时的历史文档。
|
||||
- **/5_data_dictionary**: 定义系统中使用的数据模型和字段。
|
||||
|
||||
---
|
||||
|
||||
## 快速导航
|
||||
|
||||
以下是项目中几个最核心文档的快速访问链接,直接指向其关键章节。
|
||||
|
||||
### 1. 需求与功能
|
||||
|
||||
- **[需求文档 (`requirements.md`)]** - 定义了产品的核心功能和MVP版本的验收标准。
|
||||
- [查看系统核心功能](1_requirements/20251108_[Active]_requirements.md#需求-1)
|
||||
- [了解九大分析模块](1_requirements/20251108_[Active]_requirements.md#需求-5)
|
||||
- [查看报告生成进度追踪需求](1_requirements/20251108_[Active]_requirements.md#需求-7)
|
||||
|
||||
- **[用户使用文档 (`user-guide.md`)]** - 为系统的最终用户提供详细的操作指南。
|
||||
- [快速入门指引](1_requirements/20251109_[Active]_user-guide.md#快速开始)
|
||||
- [财务数据指标解读](1_requirements/20251109_[Active]_user-guide.md#财务数据解读)
|
||||
- [首次使用的系统配置](1_requirements/20251109_[Active]_user-guide.md#首次使用配置)
|
||||
|
||||
### 2. 架构与设计
|
||||
|
||||
- **[系统架构总览 (`system_architecture.md`)]** - 项目的“单一事实源”,描述了事件驱动微服务架构的核心理念。
|
||||
- [核心架构理念与原则](2_architecture/20251116_[Active]_system_architecture.md#12-核心架构理念)
|
||||
- [目标架构图](2_architecture/20251116_[Active]_system_architecture.md#21-目标架构图)
|
||||
- [数据库 Schema 设计概览](2_architecture/20251116_[Active]_system_architecture.md#5-数据库-schema-设计)
|
||||
|
||||
- **[系统模块设计准则 (`architecture_module_specification.md`)]** - 对微服务必须遵守的 `SystemModule` 行为契约进行了形式化定义。
|
||||
- [核心思想:`SystemModule` Trait](4_archive/merged_sources/20251115_[Active]_architecture_module_specification.md#3-systemmodule-trait模块的行为契约)
|
||||
- [强制实现的可观测性接口 (`/health`, `/tasks`)](4_archive/merged_sources/20251115_[Active]_architecture_module_specification.md#41-可观测性接口的数据结构)
|
||||
|
||||
### 3. 数据与模型
|
||||
|
||||
- **[财务数据字典 (`financial_data_dictionary.md`)]** - 定义了所有前端展示的财务指标及其在不同数据源(Tushare, Finnhub)的映射关系。
|
||||
- [查看主要财务指标定义](5_data_dictionary/20251109_[Living]_financial_data_dictionary.md#1-主要指标-key-indicators)
|
||||
- [查看资产负债结构定义](5_data_dictionary/20251109_[Living]_financial_data_dictionary.md#3-资产负债结构-asset--liability-structure)
|
||||
|
||||
- **[数据库 Schema 详细设计 (`database_schema_design.md`)]** - 提供了所有核心数据表的详细 `CREATE TABLE` 语句和设计哲学。
|
||||
- [为何选择 TimescaleDB](4_archive/merged_sources/20251109_[Active]_database_schema_design.md#11-时间序列数据-postgresql--timescaledb)
|
||||
- [查看 `time_series_financials` 表结构](4_archive/merged_sources/20251109_[Active]_database_schema_design.md#211-time_series_financials-财务指标表)
|
||||
- [查看 `analysis_results` 表结构](4_archive/merged_sources/20251109_[Active]_database_schema_design.md#22-analysis_results-ai分析结果表)
|
||||
|
||||
### 4. 项目管理
|
||||
|
||||
- **[项目当前状态 (`project-status.md`)]** - 一个动态更新的文档,记录了项目的当前进展、已知问题和下一步计划。
|
||||
- [查看当前功能与数据状态](3_project_management/20251109_[Living]_project-status.md#当前功能与数据状态)
|
||||
- [查看已知问题与限制](3_project_management/20251109_[Living]_project-status.md#已知问题限制)
|
||||
- [查看后续开发计划](3_project_management/20251109_[Living]_project-status.md#后续计划优先级由高到低)
|
||||
|
||||
- **开发日志与任务**:
|
||||
- [查看所有开发日志](./3_project_management/logs/)
|
||||
- [查看已完成的任务](./3_project_management/tasks/completed/)
|
||||
@ -9,7 +9,6 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@ -38,7 +37,6 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.5",
|
||||
"prisma": "^6.18.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@ -1,19 +0,0 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
shadowDatabaseUrl = env("PRISMA_MIGRATE_SHADOW_DATABASE_URL")
|
||||
}
|
||||
|
||||
model Report {
|
||||
id String @id @default(uuid())
|
||||
symbol String
|
||||
content Json
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '../../../../lib/prisma'
|
||||
|
||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
@ -21,9 +22,28 @@ export async function GET(
|
||||
return Response.json({ error: 'missing id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const report = await prisma.report.findUnique({ where: { id } })
|
||||
if (!report) {
|
||||
return Response.json({ error: 'not found' }, { status: 404 })
|
||||
if (!BACKEND_BASE) {
|
||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||
}
|
||||
const resp = await fetch(`${BACKEND_BASE}/analysis-results/${encodeURIComponent(id)}`);
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
return new Response(text || 'not found', { status: resp.status });
|
||||
}
|
||||
// 将后端 DTO(generated_at 等)适配为前端旧结构字段(createdAt)
|
||||
try {
|
||||
const dto = JSON.parse(text);
|
||||
const adapted = {
|
||||
id: dto.id,
|
||||
symbol: dto.symbol,
|
||||
createdAt: dto.generated_at || dto.generatedAt || null,
|
||||
content: dto.content,
|
||||
module_id: dto.module_id,
|
||||
model_name: dto.model_name,
|
||||
meta_data: dto.meta_data,
|
||||
};
|
||||
return Response.json(adapted);
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid response from backend' }, { status: 502 });
|
||||
}
|
||||
return Response.json(report)
|
||||
}
|
||||
|
||||
@ -1,43 +1,15 @@
|
||||
export const runtime = 'nodejs'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
|
||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url)
|
||||
const limit = Number(url.searchParams.get('limit') || 50)
|
||||
const offset = Number(url.searchParams.get('offset') || 0)
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.report.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: offset,
|
||||
take: Math.min(Math.max(limit, 1), 200)
|
||||
}),
|
||||
prisma.report.count()
|
||||
])
|
||||
|
||||
return Response.json({ items, total })
|
||||
// 历史报告列表功能在新架构中由后端持久化服务统一提供。
|
||||
// 当前网关未提供“全量列表”接口(需要 symbol 条件),因此此路由返回空集合。
|
||||
return Response.json({ items: [], total: 0 }, { status: 200 });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const symbol = String(body.symbol || '').trim()
|
||||
const content = body.content
|
||||
|
||||
if (!symbol) {
|
||||
return Response.json({ error: 'symbol is required' }, { status: 400 })
|
||||
}
|
||||
if (typeof content === 'undefined') {
|
||||
return Response.json({ error: 'content is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const created = await prisma.report.create({
|
||||
data: { symbol, content }
|
||||
})
|
||||
|
||||
return Response.json(created, { status: 201 })
|
||||
} catch (e) {
|
||||
return Response.json({ error: 'invalid json body' }, { status: 400 })
|
||||
}
|
||||
// 新架构下,报告持久化由后端流水线/服务完成,此处不再直接创建。
|
||||
return Response.json({ error: 'Not implemented: creation is handled by backend pipeline' }, { status: 501 });
|
||||
}
|
||||
|
||||
@ -24,7 +24,6 @@ export default function ConfigPage() {
|
||||
const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig();
|
||||
|
||||
// 本地表单状态
|
||||
const [dbUrl, setDbUrl] = useState('');
|
||||
const [newApiApiKey, setNewApiApiKey] = useState('');
|
||||
const [newApiBaseUrl, setNewApiBaseUrl] = useState('');
|
||||
const [tushareApiKey, setTushareApiKey] = useState('');
|
||||
@ -110,11 +109,6 @@ export default function ConfigPage() {
|
||||
const validateConfig = () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 验证数据库URL格式
|
||||
if (dbUrl && !dbUrl.match(/^postgresql(\+asyncpg)?:\/\/.+/)) {
|
||||
errors.push('数据库URL格式不正确,应为 postgresql://user:pass@host:port/dbname');
|
||||
}
|
||||
|
||||
// 验证New API Base URL格式
|
||||
if (newApiBaseUrl && !newApiBaseUrl.match(/^https?:\/\/.+/)) {
|
||||
errors.push('New API Base URL格式不正确,应为 http:// 或 https:// 开头');
|
||||
@ -149,11 +143,6 @@ export default function ConfigPage() {
|
||||
|
||||
const newConfig: Partial<SystemConfig> = {};
|
||||
|
||||
// 只更新有值的字段
|
||||
if (dbUrl) {
|
||||
newConfig.database = { url: dbUrl };
|
||||
}
|
||||
|
||||
if (newApiApiKey || newApiBaseUrl) {
|
||||
newConfig.new_api = {
|
||||
api_key: newApiApiKey || config?.new_api?.api_key || '',
|
||||
@ -197,10 +186,6 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestDb = () => {
|
||||
handleTest('database', { url: dbUrl });
|
||||
};
|
||||
|
||||
const handleTestNewApi = () => {
|
||||
handleTest('new_api', {
|
||||
api_key: newApiApiKey || config?.new_api?.api_key,
|
||||
@ -217,7 +202,6 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDbUrl('');
|
||||
setNewApiApiKey('');
|
||||
setNewApiBaseUrl('');
|
||||
setTushareApiKey('');
|
||||
@ -230,7 +214,6 @@ export default function ConfigPage() {
|
||||
if (!config) return;
|
||||
|
||||
const configToExport = {
|
||||
database: config.database,
|
||||
new_api: config.new_api,
|
||||
data_sources: config.data_sources,
|
||||
export_time: new Date().toISOString(),
|
||||
@ -296,51 +279,18 @@ export default function ConfigPage() {
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">配置中心</h1>
|
||||
<p className="text-muted-foreground">
|
||||
管理系统配置,包括数据库连接、API密钥等。敏感信息不回显,留空表示保持当前值。
|
||||
管理系统配置,包括 AI 服务与数据源等。敏感信息不回显,留空表示保持当前值。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Tabs defaultValue="database" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="database">数据库</TabsTrigger>
|
||||
<Tabs defaultValue="ai" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="ai">AI服务</TabsTrigger>
|
||||
<TabsTrigger value="data-sources">数据源</TabsTrigger>
|
||||
<TabsTrigger value="analysis">分析配置</TabsTrigger>
|
||||
<TabsTrigger value="system">系统</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="database" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>数据库配置</CardTitle>
|
||||
<CardDescription>PostgreSQL 数据库连接设置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-url">数据库连接URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="db-url"
|
||||
type="text"
|
||||
value={dbUrl}
|
||||
onChange={(e) => setDbUrl(e.target.value)}
|
||||
placeholder="postgresql+asyncpg://user:password@host:port/database"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleTestDb} variant="outline">
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
{testResults.database && (
|
||||
<Badge variant={testResults.database.success ? 'default' : 'destructive'}>
|
||||
{testResults.database.message}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -565,12 +515,6 @@ export default function ConfigPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据库状态</Label>
|
||||
<Badge variant={config?.database?.url ? 'default' : 'secondary'}>
|
||||
{config?.database?.url ? '已配置' : '未配置'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>New API</Label>
|
||||
<Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>
|
||||
|
||||
@ -108,48 +108,6 @@ export default function ReportPage() {
|
||||
error?: string;
|
||||
}>>([]);
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
|
||||
const saveReport = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
setSaveMsg(null)
|
||||
const content = {
|
||||
market,
|
||||
normalizedSymbol: normalizedTsCode,
|
||||
financialsMeta: financials?.meta || null,
|
||||
// 同步保存财务数据(用于报告详情页展示)
|
||||
financials: financials
|
||||
? {
|
||||
ts_code: financials.ts_code,
|
||||
name: (financials as any).name,
|
||||
series: financials.series,
|
||||
meta: financials.meta,
|
||||
}
|
||||
: null,
|
||||
analyses: Object.fromEntries(
|
||||
Object.entries(analysisStates).map(([k, v]) => [k, { content: v.content, error: v.error, elapsed_ms: v.elapsed_ms, tokens: v.tokens }])
|
||||
)
|
||||
}
|
||||
const resp = await fetch('/api/reports', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ symbol: normalizedTsCode, content })
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const t = await resp.json().catch(() => ({}))
|
||||
throw new Error(t?.error || `HTTP ${resp.status}`)
|
||||
}
|
||||
const data = await resp.json()
|
||||
setSaveMsg('保存成功')
|
||||
return data
|
||||
} catch (e) {
|
||||
setSaveMsg(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runFullAnalysis = async () => {
|
||||
if (!financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
|
||||
@ -755,14 +713,6 @@ export default function ReportPage() {
|
||||
style={{ width: `${completionProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{allTasksCompleted && (
|
||||
<div className="pt-2">
|
||||
<Button onClick={saveReport} disabled={saving} variant="outline">
|
||||
{saving ? '保存中...' : '保存报告'}
|
||||
</Button>
|
||||
{saveMsg && <span className="ml-2 text-xs text-muted-foreground">{saveMsg}</span>}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{currentAnalysisTask && analysisConfig && (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
import { headers } from 'next/headers'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
@ -15,7 +15,30 @@ type Report = {
|
||||
|
||||
export default async function ReportDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const data = await prisma.report.findUnique({ where: { id } })
|
||||
const h = await headers()
|
||||
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
||||
const proto = h.get('x-forwarded-proto') || 'http'
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
|
||||
const resp = await fetch(`${base}/api/reports/${encodeURIComponent(id)}`, { cache: 'no-store' })
|
||||
if (!resp.ok) {
|
||||
return <div className="text-sm text-red-600">未找到报告</div>
|
||||
}
|
||||
const raw = await resp.json()
|
||||
let parsedContent: any = {}
|
||||
try {
|
||||
// 后端 content 可能为 JSON 字符串,也可能为已解析对象
|
||||
parsedContent = typeof raw?.content === 'string' ? JSON.parse(raw.content) : (raw?.content ?? {})
|
||||
} catch {
|
||||
parsedContent = {}
|
||||
}
|
||||
const data: Report | null = raw
|
||||
? {
|
||||
id: String(raw.id),
|
||||
symbol: String(raw.symbol ?? ''),
|
||||
content: parsedContent,
|
||||
createdAt: String(raw.createdAt ?? raw.generated_at ?? new Date().toISOString()),
|
||||
}
|
||||
: null
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-sm text-red-600">未找到报告</div>
|
||||
|
||||
@ -2,6 +2,8 @@ import useSWR, { SWRConfiguration } from "swr";
|
||||
import { Financials, FinancialsIdentifier } from "@/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnalysisStep, AnalysisTask } from "@/lib/execution-step-manager";
|
||||
import { useConfigStore } from "@/stores/useConfigStore";
|
||||
import type { SystemConfig } from "@/stores/useConfigStore";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
@ -232,3 +234,69 @@ export function useRealtimeQuote(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// 配置相关 Hooks 与函数
|
||||
// ===============================
|
||||
|
||||
export function useConfig() {
|
||||
const { setConfig, setError, setLoading } = useConfigStore();
|
||||
const { data, error, isLoading } = useSWR<SystemConfig>('/api/config', fetcher);
|
||||
useEffect(() => {
|
||||
setLoading(Boolean(isLoading));
|
||||
if (error) {
|
||||
setError(error.message || '加载配置失败');
|
||||
} else if (data) {
|
||||
setConfig(data);
|
||||
}
|
||||
}, [data, error, isLoading, setConfig, setError, setLoading]);
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export async function updateConfig(payload: Partial<SystemConfig>) {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
const updated: SystemConfig = await res.json();
|
||||
// 同步到 store
|
||||
try {
|
||||
const { setConfig } = useConfigStore.getState();
|
||||
setConfig(updated);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function testConfig(type: string, data: unknown) {
|
||||
const res = await fetch('/api/config/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, data }),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const err = JSON.parse(text);
|
||||
throw new Error(err?.message || text);
|
||||
} catch {
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { success: true, message: text || 'OK' };
|
||||
}
|
||||
}
|
||||
|
||||
export function useFinancialConfig() {
|
||||
// 透传后端的财务配置(如指标分组、显示名映射等)
|
||||
return useSWR<any>('/api/financials/config', fetcher);
|
||||
}
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma?: PrismaClient }
|
||||
|
||||
function loadDatabaseUrlFromConfig(): string | undefined {
|
||||
try {
|
||||
const configPath = path.resolve(process.cwd(), '..', 'config', 'config.json')
|
||||
const raw = fs.readFileSync(configPath, 'utf-8')
|
||||
const json = JSON.parse(raw)
|
||||
const dbUrl: unknown = json?.database?.url
|
||||
if (typeof dbUrl !== 'string' || !dbUrl) return undefined
|
||||
|
||||
// 将后端风格的 "postgresql+asyncpg://" 转换为 Prisma 需要的 "postgresql://"
|
||||
let url = dbUrl.replace(/^postgresql\+[^:]+:\/\//, 'postgresql://')
|
||||
// 若未指定 schema,默认 public
|
||||
if (!/[?&]schema=/.test(url)) {
|
||||
url += (url.includes('?') ? '&' : '?') + 'schema=public'
|
||||
}
|
||||
return url
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const databaseUrl = loadDatabaseUrlFromConfig() || process.env.DATABASE_URL
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined,
|
||||
log: ['error', 'warn']
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user