feat: 完成公司简介部分

This commit is contained in:
xucheng 2025-10-28 23:30:12 +08:00
parent ce6cc1ddb8
commit 6508589027
43 changed files with 3867 additions and 983 deletions

147
backend/alembic.ini Normal file
View File

@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://value:Value609!@192.168.3.195:5432/fundamental
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

83
backend/alembic/env.py Normal file
View File

@ -0,0 +1,83 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Add app directory to sys.path
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
# Import Base from your models
from app.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url").replace("+asyncpg", "")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
section = config.get_section(config.config_ini_section, {})
section['sqlalchemy.url'] = section['sqlalchemy.url'].replace("+asyncpg", "")
connectable = engine_from_config(
section,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,79 @@
"""Initial migration
Revision ID: 65b0d87d025a
Revises:
Create Date: 2025-10-22 09:03:08.353806
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '65b0d87d025a'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('reports',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('symbol', sa.String(), nullable=False),
sa.Column('market', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_reports_market'), 'reports', ['market'], unique=False)
op.create_index(op.f('ix_reports_status'), 'reports', ['status'], unique=False)
op.create_index(op.f('ix_reports_symbol'), 'reports', ['symbol'], unique=False)
op.create_table('analysis_modules',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('report_id', sa.UUID(), nullable=False),
sa.Column('module_type', sa.String(), nullable=False),
sa.Column('content', sa.JSON(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('error_message', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_analysis_modules_report_id'), 'analysis_modules', ['report_id'], unique=False)
op.create_index(op.f('ix_analysis_modules_status'), 'analysis_modules', ['status'], unique=False)
op.create_table('progress_tracking',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('report_id', sa.UUID(), nullable=False),
sa.Column('step_name', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('token_usage', sa.Integer(), nullable=True),
sa.Column('error_message', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_progress_tracking_report_id'), 'progress_tracking', ['report_id'], unique=False)
op.create_index(op.f('ix_progress_tracking_status'), 'progress_tracking', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_progress_tracking_status'), table_name='progress_tracking')
op.drop_index(op.f('ix_progress_tracking_report_id'), table_name='progress_tracking')
op.drop_table('progress_tracking')
op.drop_index(op.f('ix_analysis_modules_status'), table_name='analysis_modules')
op.drop_index(op.f('ix_analysis_modules_report_id'), table_name='analysis_modules')
op.drop_table('analysis_modules')
op.drop_index(op.f('ix_reports_symbol'), table_name='reports')
op.drop_index(op.f('ix_reports_status'), table_name='reports')
op.drop_index(op.f('ix_reports_market'), table_name='reports')
op.drop_table('reports')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""
Application settings management using Pydantic
"""
from typing import List, Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
APP_NAME: str = "Fundamental Analysis System"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
# Default database URL switched to SQLite (async) to avoid optional driver issues.
# You can override via config or env to PostgreSQL later.
DATABASE_URL: str = "sqlite+aiosqlite:///./app.db"
DATABASE_ECHO: bool = False
# API settings
API_V1_STR: str = "/api"
ALLOWED_ORIGINS: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
# External service credentials, can be overridden
GEMINI_API_KEY: Optional[str] = None
TUSHARE_TOKEN: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = True
# Global settings instance
settings = Settings()

View File

@ -0,0 +1,9 @@
"""
SQLAlchemy async database session factory
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DATABASE_ECHO, future=True)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

View File

@ -0,0 +1,18 @@
"""
Application dependencies and providers
"""
from typing import AsyncGenerator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
from app.services.config_manager import ConfigManager
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Provides a database session to the application."""
async with AsyncSessionLocal() as session:
yield session
def get_config_manager(db_session: AsyncSession = Depends(get_db_session)) -> ConfigManager:
"""Dependency to get the configuration manager."""
return ConfigManager(db_session=db_session)

36
backend/app/main.py Normal file
View File

@ -0,0 +1,36 @@
"""
FastAPI application entrypoint
"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.routers.config import router as config_router
from app.routers.financial import router as financial_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s: %(message)s',
datefmt='%H:%M:%S'
)
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(config_router, prefix=f"{settings.API_V1_STR}/config", tags=["config"])
app.include_router(financial_router, prefix=f"{settings.API_V1_STR}/financials", tags=["financials"])
@app.get("/")
async def root():
return {"status": "ok", "name": settings.APP_NAME, "version": settings.APP_VERSION}

View File

@ -0,0 +1,7 @@
from .base import Base
from .system_config import SystemConfig
from .report import Report
from .analysis_module import AnalysisModule
from .progress_tracking import ProgressTracking
__all__ = ["Base", "SystemConfig", "Report", "AnalysisModule", "ProgressTracking"]

View File

@ -0,0 +1,20 @@
"""
Analysis Module Model
"""
import uuid
from sqlalchemy import Column, String, JSON, ForeignKey, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as pgUUID
from sqlalchemy.orm import relationship
from .base import Base
class AnalysisModule(Base):
__tablename__ = 'analysis_modules'
id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
report_id = Column(pgUUID(as_uuid=True), ForeignKey('reports.id'), nullable=False, index=True)
module_type = Column(String, nullable=False)
content = Column(JSON)
status = Column(String, nullable=False, default='pending', index=True)
error_message = Column(String)
report = relationship("Report", back_populates="analysis_modules")

View File

@ -0,0 +1,3 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()

View File

@ -0,0 +1,23 @@
"""
Progress Tracking Model
"""
import uuid
from sqlalchemy import Column, String, Integer, DateTime, func, ForeignKey
from sqlalchemy.dialects.postgresql import UUID as pgUUID
from sqlalchemy.orm import relationship
from .base import Base
class ProgressTracking(Base):
__tablename__ = 'progress_tracking'
id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
report_id = Column(pgUUID(as_uuid=True), ForeignKey('reports.id'), nullable=False, index=True)
step_name = Column(String, nullable=False)
status = Column(String, nullable=False, default='pending', index=True)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
duration_ms = Column(Integer)
token_usage = Column(Integer)
error_message = Column(String)
report = relationship("Report", back_populates="progress_tracking")

View File

@ -0,0 +1,21 @@
"""
Report Model
"""
import uuid
from sqlalchemy import Column, String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as pgUUID
from sqlalchemy.orm import relationship
from .base import Base
class Report(Base):
__tablename__ = 'reports'
id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
symbol = Column(String, nullable=False, index=True)
market = Column(String, nullable=False, index=True)
status = Column(String, nullable=False, default='generating', index=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
analysis_modules = relationship("AnalysisModule", back_populates="report", cascade="all, delete-orphan")
progress_tracking = relationship("ProgressTracking", back_populates="report", cascade="all, delete-orphan")

View File

@ -0,0 +1,13 @@
"""
System Configuration Model
"""
from sqlalchemy import Column, String, JSON
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class SystemConfig(Base):
__tablename__ = 'system_config'
config_key = Column(String, primary_key=True, index=True)
config_value = Column(JSON, nullable=False)

View File

@ -0,0 +1,38 @@
"""
API router for configuration management
"""
from fastapi import APIRouter, Depends, HTTPException
from app.core.dependencies import get_config_manager
from app.schemas.config import ConfigResponse, ConfigUpdateRequest, ConfigTestRequest, ConfigTestResponse
from app.services.config_manager import ConfigManager
router = APIRouter()
@router.get("/", response_model=ConfigResponse)
async def get_config(config_manager: ConfigManager = Depends(get_config_manager)):
"""Retrieve the current system configuration."""
return await config_manager.get_config()
@router.put("/", response_model=ConfigResponse)
async def update_config(
config_update: ConfigUpdateRequest,
config_manager: ConfigManager = Depends(get_config_manager)
):
"""Update system configuration."""
return await config_manager.update_config(config_update)
@router.post("/test", response_model=ConfigTestResponse)
async def test_config(
test_request: ConfigTestRequest,
config_manager: ConfigManager = Depends(get_config_manager)
):
"""Test a specific configuration (e.g., database connection)."""
# The test logic will be implemented in a subsequent step inside the ConfigManager
# For now, we return a placeholder response.
# test_result = await config_manager.test_config(
# test_request.config_type,
# test_request.config_data
# )
# return test_result
raise HTTPException(status_code=501, detail="Not Implemented")

View File

@ -0,0 +1,249 @@
"""
API router for financial data (Tushare for China market)
"""
import json
import os
import time
from datetime import datetime, timezone
from typing import Dict, List
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
import os
from app.core.config import settings
from app.schemas.financial import BatchFinancialDataResponse, FinancialConfigResponse, FinancialMeta, StepRecord, CompanyProfileResponse
from app.services.tushare_client import TushareClient
from app.services.company_profile_client import CompanyProfileClient
router = APIRouter()
# Load metric config from file (project root is repo root, not backend/)
# routers/ -> app/ -> backend/ -> repo root
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
FINANCIAL_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "financial-tushare.json")
BASE_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "config.json")
def _load_json(path: str) -> Dict:
if not os.path.exists(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
@router.get("/config", response_model=FinancialConfigResponse)
async def get_financial_config():
data = _load_json(FINANCIAL_CONFIG_PATH)
api_groups = data.get("api_groups", {})
return FinancialConfigResponse(api_groups=api_groups)
@router.get("/china/{ts_code}", response_model=BatchFinancialDataResponse)
async def get_china_financials(
ts_code: str,
years: int = Query(5, ge=1, le=15),
):
# Load Tushare token
base_cfg = _load_json(BASE_CONFIG_PATH)
token = (
os.environ.get("TUSHARE_TOKEN")
or settings.TUSHARE_TOKEN
or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key")
)
if not token:
raise HTTPException(status_code=500, detail="Tushare API token not configured. Set TUSHARE_TOKEN env or config/config.json data_sources.tushare.api_key")
# Load metric config
fin_cfg = _load_json(FINANCIAL_CONFIG_PATH)
api_groups: Dict[str, List[Dict]] = fin_cfg.get("api_groups", {})
client = TushareClient(token=token)
# Meta tracking
started_real = datetime.now(timezone.utc)
started = time.perf_counter_ns()
api_calls_total = 0
api_calls_by_group: Dict[str, int] = {}
steps: List[StepRecord] = []
current_action = "初始化"
# Get company name from stock_basic API
company_name = None
try:
basic_data = await client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name")
api_calls_total += 1
if basic_data and len(basic_data) > 0:
company_name = basic_data[0].get("name")
except Exception:
# If getting company name fails, continue without it
pass
# Collect series per metric key
series: Dict[str, List[Dict]] = {}
# Helper to store year-value pairs while keeping most recent per year
def _merge_year_value(key: str, year: str, value):
arr = series.setdefault(key, [])
# upsert by year
for item in arr:
if item["year"] == year:
item["value"] = value
return
arr.append({"year": year, "value": value})
# Query each API group we care
errors: Dict[str, str] = {}
for group_name, metrics in api_groups.items():
step = StepRecord(
name=f"拉取 {group_name}",
start_ts=started_real.isoformat(),
status="running",
)
steps.append(step)
current_action = step.name
if not metrics:
continue
api_name = metrics[0].get("api") or group_name
fields = list({m.get("tushareParam") for m in metrics if m.get("tushareParam")})
if not fields:
continue
date_field = "end_date" if group_name in ("fina_indicator", "income", "balancesheet", "cashflow") else "trade_date"
try:
data_rows = await client.query(api_name=api_name, params={"ts_code": ts_code, "limit": 5000}, fields=None)
api_calls_total += 1
api_calls_by_group[group_name] = api_calls_by_group.get(group_name, 0) + 1
except Exception as e:
step.status = "error"
step.error = str(e)
step.end_ts = datetime.now(timezone.utc).isoformat()
step.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000)
errors[group_name] = str(e)
continue
tmp: Dict[str, Dict] = {}
for row in data_rows:
date_val = row.get(date_field)
if not date_val:
continue
year = str(date_val)[:4]
existing = tmp.get(year)
if existing is None or str(row.get(date_field)) > str(existing.get(date_field)):
tmp[year] = row
for metric in metrics:
key = metric.get("tushareParam")
if not key:
continue
for year, row in tmp.items():
_merge_year_value(key, year, row.get(key))
step.status = "done"
step.end_ts = datetime.now(timezone.utc).isoformat()
step.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000)
finished_real = datetime.now(timezone.utc)
elapsed_ms = int((time.perf_counter_ns() - started) / 1_000_000)
if not series:
# If nothing succeeded, expose partial error info
raise HTTPException(status_code=502, detail={"message": "No data returned from Tushare", "errors": errors})
# Truncate years and sort
for key, arr in series.items():
# Deduplicate and sort desc by year, then cut to requested years, and return asc
uniq = {item["year"]: item for item in arr}
arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["year"], reverse=True)
arr_limited = arr_sorted_desc[:years]
arr_sorted = sorted(arr_limited, key=lambda x: x["year"]) # ascending by year
series[key] = arr_sorted
meta = FinancialMeta(
started_at=started_real.isoformat(),
finished_at=finished_real.isoformat(),
elapsed_ms=elapsed_ms,
api_calls_total=api_calls_total,
api_calls_by_group=api_calls_by_group,
current_action=None,
steps=steps,
)
return BatchFinancialDataResponse(ts_code=ts_code, name=company_name, series=series, meta=meta)
@router.get("/china/{ts_code}/company-profile", response_model=CompanyProfileResponse)
async def get_company_profile(
ts_code: str,
company_name: str = Query(None, description="Company name for better context"),
):
"""
Get company profile for a company using Gemini AI (non-streaming, single response)
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[API] Company profile requested for {ts_code}")
# Load config
base_cfg = _load_json(BASE_CONFIG_PATH)
gemini_cfg = base_cfg.get("llm", {}).get("gemini", {})
api_key = gemini_cfg.get("api_key")
if not api_key:
logger.error("[API] Gemini API key not configured")
raise HTTPException(
status_code=500,
detail="Gemini API key not configured. Set config.json llm.gemini.api_key"
)
client = CompanyProfileClient(api_key=api_key)
# Get company name from ts_code if not provided
if not company_name:
logger.info(f"[API] Fetching company name for {ts_code}")
# Try to get from stock_basic API
try:
base_cfg = _load_json(BASE_CONFIG_PATH)
token = (
os.environ.get("TUSHARE_TOKEN")
or settings.TUSHARE_TOKEN
or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key")
)
if token:
from app.services.tushare_client import TushareClient
tushare_client = TushareClient(token=token)
basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name")
if basic_data and len(basic_data) > 0:
company_name = basic_data[0].get("name", ts_code)
logger.info(f"[API] Got company name: {company_name}")
else:
company_name = ts_code
else:
company_name = ts_code
except Exception as e:
logger.warning(f"[API] Failed to get company name: {e}")
company_name = ts_code
logger.info(f"[API] Generating profile for {company_name}")
# Generate profile using non-streaming API
result = await client.generate_profile(
company_name=company_name,
ts_code=ts_code,
financial_data=None
)
logger.info(f"[API] Profile generation completed, success={result.get('success')}")
return CompanyProfileResponse(
ts_code=ts_code,
company_name=company_name,
content=result.get("content", ""),
model=result.get("model", "gemini-2.5-flash"),
tokens=result.get("tokens", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}),
elapsed_ms=result.get("elapsed_ms", 0),
success=result.get("success", False),
error=result.get("error")
)

View File

@ -0,0 +1,33 @@
"""
Configuration-related Pydantic schemas
"""
from typing import Dict, Optional, Any
from pydantic import BaseModel, Field
class DatabaseConfig(BaseModel):
url: str = Field(..., description="数据库连接URL")
class GeminiConfig(BaseModel):
api_key: str = Field(..., description="Gemini API Key")
base_url: Optional[str] = None
class DataSourceConfig(BaseModel):
api_key: str = Field(..., description="数据源API Key")
class ConfigResponse(BaseModel):
database: DatabaseConfig
gemini_api: GeminiConfig
data_sources: Dict[str, DataSourceConfig]
class ConfigUpdateRequest(BaseModel):
database: Optional[DatabaseConfig] = None
gemini_api: Optional[GeminiConfig] = None
data_sources: Optional[Dict[str, DataSourceConfig]] = None
class ConfigTestRequest(BaseModel):
config_type: str
config_data: Dict[str, Any]
class ConfigTestResponse(BaseModel):
success: bool
message: str

View File

@ -0,0 +1,57 @@
"""
Pydantic schemas for financial APIs
"""
from typing import Dict, List, Optional
from pydantic import BaseModel
class YearDataPoint(BaseModel):
year: str
value: Optional[float]
class StepRecord(BaseModel):
name: str
start_ts: str # ISO8601
end_ts: Optional[str] = None
duration_ms: Optional[int] = None
status: str # running|done|error
error: Optional[str] = None
class FinancialMeta(BaseModel):
started_at: str # ISO8601
finished_at: Optional[str] = None
elapsed_ms: Optional[int] = None
api_calls_total: int = 0
api_calls_by_group: Dict[str, int] = {}
current_action: Optional[str] = None
steps: List[StepRecord] = []
class BatchFinancialDataResponse(BaseModel):
ts_code: str
name: Optional[str] = None
series: Dict[str, List[YearDataPoint]]
meta: Optional[FinancialMeta] = None
class FinancialConfigResponse(BaseModel):
api_groups: Dict[str, List[dict]]
class TokenUsage(BaseModel):
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
class CompanyProfileResponse(BaseModel):
ts_code: str
company_name: Optional[str] = None
content: str
model: str
tokens: TokenUsage
elapsed_ms: int
success: bool = True
error: Optional[str] = None

View File

@ -0,0 +1,162 @@
"""
Google Gemini API Client for company profile generation
"""
import time
from typing import Dict, List, Optional
import google.generativeai as genai
class CompanyProfileClient:
def __init__(self, api_key: str):
"""Initialize Gemini client with API key"""
genai.configure(api_key=api_key)
self.model = genai.GenerativeModel("gemini-2.5-flash")
async def generate_profile(
self,
company_name: str,
ts_code: str,
financial_data: Optional[Dict] = None
) -> Dict:
"""
Generate company profile using Gemini API (non-streaming)
Args:
company_name: Company name
ts_code: Stock code
financial_data: Optional financial data for context
Returns:
Dict with profile content and metadata
"""
start_time = time.perf_counter_ns()
# Build prompt
prompt = self._build_prompt(company_name, ts_code, financial_data)
# Call Gemini API (using sync API in async context)
try:
# Run synchronous API call in executor
import asyncio
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: self.model.generate_content(prompt)
)
# Get token usage
usage_metadata = response.usage_metadata if hasattr(response, 'usage_metadata') else None
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return {
"content": response.text,
"model": "gemini-2.5-flash",
"tokens": {
"prompt_tokens": usage_metadata.prompt_token_count if usage_metadata else 0,
"completion_tokens": usage_metadata.candidates_token_count if usage_metadata else 0,
"total_tokens": usage_metadata.total_token_count if usage_metadata else 0,
} if usage_metadata else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms,
"success": True,
}
except Exception as e:
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return {
"content": "",
"model": "gemini-2.5-flash",
"tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms,
"success": False,
"error": str(e),
}
def generate_profile_stream(
self,
company_name: str,
ts_code: str,
financial_data: Optional[Dict] = None
):
"""
Generate company profile using Gemini API with streaming
Args:
company_name: Company name
ts_code: Stock code
financial_data: Optional financial data for context
Yields:
Chunks of generated content
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[CompanyProfile] Starting stream generation for {company_name} ({ts_code})")
# Build prompt
prompt = self._build_prompt(company_name, ts_code, financial_data)
logger.info(f"[CompanyProfile] Prompt built, length: {len(prompt)} chars")
# Call Gemini API with streaming
try:
logger.info("[CompanyProfile] Calling Gemini API with stream=True")
# Generate streaming response (sync call, but yields chunks)
response_stream = self.model.generate_content(prompt, stream=True)
logger.info("[CompanyProfile] Gemini API stream object created")
chunk_count = 0
# Stream chunks
logger.info("[CompanyProfile] Starting to iterate response stream")
for chunk in response_stream:
logger.info(f"[CompanyProfile] Received chunk from Gemini, has text: {hasattr(chunk, 'text')}")
if hasattr(chunk, 'text') and chunk.text:
chunk_count += 1
text_len = len(chunk.text)
logger.info(f"[CompanyProfile] Chunk {chunk_count}: {text_len} chars")
yield chunk.text
else:
logger.warning(f"[CompanyProfile] Chunk has no text attribute or empty, chunk: {chunk}")
logger.info(f"[CompanyProfile] Stream iteration completed. Total chunks: {chunk_count}")
except Exception as e:
logger.error(f"[CompanyProfile] Error during streaming: {type(e).__name__}: {str(e)}", exc_info=True)
yield f"\n\n---\n\n**错误**: {type(e).__name__}: {str(e)}"
def _build_prompt(self, company_name: str, ts_code: str, financial_data: Optional[Dict] = None) -> str:
"""Build prompt for company profile generation"""
prompt = f"""您是一位专业的证券市场分析师。请为公司 {company_name} (股票代码: {ts_code}) 生成一份详细且专业的公司介绍。开头不要自我介绍直接开始正文。正文用MarkDown输出尽量说明信息来源用斜体显示信息来源。在生成内容时请严格遵循以下要求并采用清晰、结构化的格式
1. **公司概览**:
* 简要介绍公司的性质核心业务领域及其在行业中的定位
* 提炼并阐述公司的核心价值理念
2. **主营业务**:
* 详细描述公司主要的**产品或服务**
* **重要提示**如果能获取到公司最新的官方**年报****财务报告**请从中提取各主要产品/服务线的**收入金额**和其占公司总收入的**百分比****明确标注数据来源**例如"数据来源于XX年年度报告"
* **严格禁止**编造或估算任何财务数据若无法找到公开准确的财务数据**不要**在这一点中提及具体金额或比例仅描述业务内容
3. **发展历程**:
* 以时间线或关键事件的形式概述公司自成立以来的主要**里程碑事件**重大发展阶段战略转型或重要成就
4. **核心团队**:
* 介绍公司**主要管理层和核心技术团队成员**
* 对于每位核心成员提供其**职务主要工作履历教育背景**
* 如果公开可查可补充其**出生年份**
5. **供应链**:
* 描述公司的**主要原材料部件或服务来源**
* 如果公开信息中包含请列出**主要供应商名称****明确其在总采购金额中的大致占比**若无此数据则仅描述采购模式
6. **主要客户及销售模式**:
* 阐明公司的**销售模式**例如直销经销线上销售代理等
* 列出公司的**主要客户群体****代表性大客户**
* 如果公开信息中包含请标明**主要客户或前五大客户的销售额占公司总销售额的比例**若无此数据则仅描述客户类型
7. **未来展望**:
* 基于公司**公开的官方声明管理层访谈或战略规划**总结公司未来的发展方向战略目标重点项目或市场预期请确保此部分内容有可靠的信息来源支持"""
if financial_data:
prompt += f"\n\n参考财务数据:\n{financial_data}"
return prompt

View File

@ -0,0 +1,87 @@
"""
Configuration Management Service
"""
import json
import os
from typing import Any, Dict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models.system_config import SystemConfig
from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, GeminiConfig, DataSourceConfig
class ConfigManager:
"""Manages system configuration by merging a static JSON file with dynamic settings from the database."""
def __init__(self, db_session: AsyncSession, config_path: str = None):
self.db = db_session
if config_path is None:
# Default path: backend/ -> project_root/ -> config/config.json
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
self.config_path = os.path.join(project_root, "config", "config.json")
else:
self.config_path = config_path
def _load_base_config_from_file(self) -> Dict[str, Any]:
"""Loads the base configuration from the JSON file."""
if not os.path.exists(self.config_path):
return {}
try:
with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
return {}
async def _load_dynamic_config_from_db(self) -> Dict[str, Any]:
"""Loads dynamic configuration overrides from the database."""
db_configs = {}
result = await self.db.execute(select(SystemConfig))
for record in result.scalars().all():
db_configs[record.config_key] = record.config_value
return db_configs
def _merge_configs(self, base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
"""Deeply merges the override config into the base config."""
for key, value in overrides.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
base[key] = self._merge_configs(base[key], value)
else:
base[key] = value
return base
async def get_config(self) -> ConfigResponse:
"""Gets the final, merged configuration."""
base_config = self._load_base_config_from_file()
db_config = await self._load_dynamic_config_from_db()
merged_config = self._merge_configs(base_config, db_config)
return ConfigResponse(
database=DatabaseConfig(**merged_config.get("database", {})),
gemini_api=GeminiConfig(**merged_config.get("llm", {}).get("gemini", {})),
data_sources={
k: DataSourceConfig(**v)
for k, v in merged_config.get("data_sources", {}).items()
}
)
async def update_config(self, config_update: ConfigUpdateRequest) -> ConfigResponse:
"""Updates configuration in the database and returns the new merged config."""
update_dict = config_update.dict(exclude_unset=True)
for key, value in update_dict.items():
existing_config = await self.db.get(SystemConfig, key)
if existing_config:
# Merge with existing DB value before updating
if isinstance(existing_config.config_value, dict) and isinstance(value, dict):
merged_value = self._merge_configs(existing_config.config_value, value)
existing_config.config_value = merged_value
else:
existing_config.config_value = value
else:
new_config = SystemConfig(config_key=key, config_value=value)
self.db.add(new_config)
await self.db.commit()
return await self.get_config()

View File

@ -0,0 +1,52 @@
"""
Minimal async client for Tushare Pro API
"""
from typing import Any, Dict, List, Optional
import httpx
TUSHARE_PRO_URL = "https://api.tushare.pro"
class TushareClient:
def __init__(self, token: str):
self.token = token
self._client = httpx.AsyncClient(timeout=30)
async def query(
self,
api_name: str,
params: Optional[Dict[str, Any]] = None,
fields: Optional[str] = None,
) -> List[Dict[str, Any]]:
payload = {
"api_name": api_name,
"token": self.token,
"params": params or {},
}
# default larger page size if not provided
if "limit" not in payload["params"]:
payload["params"]["limit"] = 5000
if fields:
payload["fields"] = fields
resp = await self._client.post(TUSHARE_PRO_URL, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
err = data.get("msg") or "Tushare error"
raise RuntimeError(f"{api_name}: {err}")
fields_def = data.get("data", {}).get("fields", [])
items = data.get("data", {}).get("items", [])
rows: List[Dict[str, Any]] = []
for it in items:
row = {fields_def[i]: it[i] for i in range(len(fields_def))}
rows.append(row)
return rows
async def aclose(self):
await self._client.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self.aclose()

8
backend/requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.2
pydantic-settings==2.5.2
SQLAlchemy==2.0.36
aiosqlite==0.20.0
alembic==1.13.3
google-generativeai==0.8.3

View File

@ -16,9 +16,9 @@
此阶段专注于实现数据库模型和系统配置API为上层业务逻辑提供基础。
- **T2.1 [Backend/DB]**: 根据设计文档使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。
- **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本在数据库中生成上述四张表。
- **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。
- [x] **T2.1 [Backend/DB]**: 根据设计文档使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。 **[完成 - 2025-10-21]**
- [x] **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本在数据库中生成上述四张表。 **[完成 - 2025-10-21]**
- [x] **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。 **[完成 - 2025-10-21]**
- **T2.4 [Backend/API]**: 创建Pydantic Schema用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。
- **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。
- **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。
@ -26,13 +26,14 @@
## Phase 3: 前端基础与配置页面 (P1)
此阶段完成前端项目的基本设置,并开发出第一个功能页面——系统配置管理。
**[完成 - 2025-10-21]**
- **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。
- **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。
- **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。
- **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件包含用于数据库、Gemini API和数据源配置的表单。
- **T3.5 [Frontend/API]**: 编写API客户端函数用于调用后端的`/api/config`系列接口。
- **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。
- [x] **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。
- [x] **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。
- [x] **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。
- [x] **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件包含用于数据库、Gemini API和数据源配置的表单。
- [x] **T3.5 [Frontend/API]**: 编写API客户端函数用于调用后端的`/api/config`系列接口。
- [x] **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。
## Phase 4: 核心功能 - 报告生成与进度追踪 (P1)

View File

@ -1,5 +1,18 @@
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
// Explicitly set Turbopack root to this frontend directory to silence multi-lockfile warning
turbopack: {
root: __dirname,
},
// Increase server timeout for long-running AI requests
experimental: {
proxyTimeout: 120000, // 120 seconds
},
async rewrites() {
return [
{

File diff suppressed because it is too large Load Diff

View File

@ -12,14 +12,19 @@
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-markdown": "^10.1.0",
"recharts": "^3.3.0",
"tailwind-merge": "^3.3.1"
"remark-gfm": "^4.0.1",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@ -0,0 +1,20 @@
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() {
const resp = await fetch(`${BACKEND_BASE}/config`);
const text = await resp.text();
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
}
export async function PUT(req: NextRequest) {
const body = await req.text();
const resp = await fetch(`${BACKEND_BASE}/config`, {
method: 'PUT',
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

@ -0,0 +1,12 @@
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[] } }) {
const url = new URL(req.url);
const path = params.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();
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
}

View File

@ -1,229 +1,162 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { useState, useEffect } from 'react';
import { useConfig, updateConfig, testConfig } from '@/hooks/useApi';
import { useConfigStore, SystemConfig } from '@/stores/useConfigStore';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
type Config = {
llm?: {
provider?: "gemini" | "openai";
gemini?: { api_key?: string; base_url?: string };
openai?: { api_key?: string; base_url?: string };
};
data_sources?: {
tushare?: { api_key?: string };
finnhub?: { api_key?: string };
jp_source?: { api_key?: string };
};
database?: { url?: string };
prompts?: { info?: string; finance?: string };
};
export default function ConfigPage() {
const [cfg, setCfg] = useState<Config | null>(null);
// 从 Zustand store 获取全局状态
const { config, loading, error, setConfig } = useConfigStore();
// 使用 SWR hook 加载初始配置
useConfig();
// 本地表单状态
const [dbUrl, setDbUrl] = useState('');
const [geminiApiKey, setGeminiApiKey] = useState('');
const [tushareApiKey, setTushareApiKey] = useState('');
// 测试结果状态
const [dbTestResult, setDbTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [geminiTestResult, setGeminiTestResult] = useState<{ success: boolean; message: string } | null>(null);
// 保存状态
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [health, setHealth] = useState<string>("unknown");
// form inputs (敏感字段不回显,留空表示保持现有值)
const [provider, setProvider] = useState<"gemini" | "openai">("gemini");
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const [geminiKey, setGeminiKey] = useState(""); // 留空则保留
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
const [openaiKey, setOpenaiKey] = useState(""); // 留空则保留
const [tushareKey, setTushareKey] = useState(""); // 留空则保留
const [finnhubKey, setFinnhubKey] = useState(""); // 留空则保留
const [jpKey, setJpKey] = useState(""); // 留空则保留
const [dbUrl, setDbUrl] = useState("");
const [promptInfo, setPromptInfo] = useState("");
const [promptFinance, setPromptFinance] = useState("");
async function loadConfig() {
try {
const res = await fetch("/api/config");
const data: Config = await res.json();
setCfg(data);
// 非敏感字段可回显
setProvider((data.llm?.provider as any) ?? "gemini");
setGeminiBaseUrl(data.llm?.gemini?.base_url ?? "");
setOpenaiBaseUrl(data.llm?.openai?.base_url ?? "");
setDbUrl(data.database?.url ?? "");
setPromptInfo(data.prompts?.info ?? "");
setPromptFinance(data.prompts?.finance ?? "");
} catch {
setMsg("加载配置失败");
}
}
async function saveConfig() {
if (!cfg) return;
setSaving(true);
setMsg(null);
try {
// 构造覆盖配置:敏感字段若为空则沿用现有值
const next: Config = {
llm: {
provider,
gemini: {
base_url: geminiBaseUrl,
api_key: geminiKey || cfg.llm?.gemini?.api_key || undefined,
},
openai: {
base_url: openaiBaseUrl,
api_key: openaiKey || cfg.llm?.openai?.api_key || undefined,
},
},
data_sources: {
tushare: { api_key: tushareKey || cfg.data_sources?.tushare?.api_key || undefined },
finnhub: { api_key: finnhubKey || cfg.data_sources?.finnhub?.api_key || undefined },
jp_source: { api_key: jpKey || cfg.data_sources?.jp_source?.api_key || undefined },
},
database: { url: dbUrl },
prompts: { info: promptInfo, finance: promptFinance },
};
const res = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
const ok = await res.json();
if (ok?.status === "ok") {
setMsg("保存成功");
await loadConfig();
// 清空敏感输入(避免页面存储)
setGeminiKey("");
setOpenaiKey("");
setTushareKey("");
setFinnhubKey("");
setJpKey("");
} else {
setMsg("保存失败");
}
} catch {
setMsg("保存失败");
} finally {
setSaving(false);
}
}
async function testHealth() {
try {
const res = await fetch("/health");
const h = await res.json();
setHealth(h?.status ?? "unknown");
} catch {
setHealth("error");
}
}
const [saveMessage, setSaveMessage] = useState('');
useEffect(() => {
loadConfig();
testHealth();
}, []);
if (config) {
setDbUrl(config.database?.url || '');
// API Keys 不回显
}
}, [config]);
const handleSave = async () => {
setSaving(true);
setSaveMessage('保存中...');
const newConfig: Partial<SystemConfig> = {
database: { url: dbUrl },
gemini_api: { api_key: geminiApiKey },
data_sources: {
tushare: { api_key: tushareApiKey },
},
};
try {
const updated = await updateConfig(newConfig);
setConfig(updated); // 更新全局状态
setSaveMessage('保存成功!');
setGeminiApiKey(''); // 清空敏感字段输入
setTushareApiKey('');
} catch (e: any) {
setSaveMessage(`保存失败: ${e.message}`);
} finally {
setSaving(false);
setTimeout(() => setSaveMessage(''), 3000);
}
};
const handleTestDb = async () => {
const result = await testConfig('database', { url: dbUrl });
setDbTestResult(result);
};
const handleTestGemini = async () => {
const result = await testConfig('gemini', { api_key: geminiApiKey || config?.gemini_api.api_key });
setGeminiTestResult(result);
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error loading config: {error}</div>;
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">
LLM
API密钥等
</p>
</header>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>GET /health</CardDescription>
<CardTitle></CardTitle>
<CardDescription>PostgreSQL </CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-2">
<Badge variant={health === "ok" ? "secondary" : "outline"}>{health}</Badge>
<Button variant="outline" onClick={testHealth}></Button>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<label className="w-28">URL</label>
<Input
type="text"
value={dbUrl}
onChange={(e) => setDbUrl(e.target.value)}
placeholder="postgresql+asyncpg://user:pass@host:port/dbname"
className="flex-1"
/>
<Button onClick={handleTestDb}></Button>
</div>
{dbTestResult && (
<Badge variant={dbTestResult.success ? 'secondary' : 'destructive'}>
{dbTestResult.message}
</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>LLM </CardTitle>
<CardDescription>Gemini / OpenAI</CardDescription>
<CardTitle>AI </CardTitle>
<CardDescription>Google Gemini API </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<label className="text-sm w-28">Provider</label>
<select
className="border rounded px-2 py-1 bg-background"
value={provider}
onChange={(e) => setProvider(e.target.value as any)}
>
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Gemini Base URL</label>
<Input placeholder="可留空" value={geminiBaseUrl} onChange={(e) => setGeminiBaseUrl(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">Gemini Key</label>
<Input type="password" placeholder="留空=保持现值" value={geminiKey} onChange={(e) => setGeminiKey(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">OpenAI Base URL</label>
<Input placeholder="可留空" value={openaiBaseUrl} onChange={(e) => setOpenaiBaseUrl(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">OpenAI Key</label>
<Input type="password" placeholder="留空=保持现值" value={openaiKey} onChange={(e) => setOpenaiKey(e.target.value)} />
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<label className="w-28">API Key</label>
<Input
type="password"
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="留空表示保持现值"
className="flex-1"
/>
<Button onClick={handleTestGemini}></Button>
</div>
{geminiTestResult && (
<Badge variant={geminiTestResult.success ? 'secondary' : 'destructive'}>
{geminiTestResult.message}
</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>TuShare / Finnhub / JP</CardDescription>
<CardTitle></CardTitle>
<CardDescription>Tushare API </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 items-center">
<label className="text-sm w-28">TuShare</label>
<Input type="password" placeholder="留空=保持现值" value={tushareKey} onChange={(e) => setTushareKey(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">Finnhub</label>
<Input type="password" placeholder="留空=保持现值" value={finnhubKey} onChange={(e) => setFinnhubKey(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">JP Source</label>
<Input type="password" placeholder="留空=保持现值" value={jpKey} onChange={(e) => setJpKey(e.target.value)} />
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<label className="w-28">Tushare Token</label>
<Input
type="password"
value={tushareApiKey}
onChange={(e) => setTushareApiKey(e.target.value)}
placeholder="留空表示保持现值"
className="flex-1"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<label className="text-sm w-28">DB URL</label>
<Input placeholder="postgresql+asyncpg://..." value={dbUrl} onChange={(e) => setDbUrl(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Prompt Info</label>
<Input placeholder="模板info" value={promptInfo} onChange={(e) => setPromptInfo(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Prompt Finance</label>
<Input placeholder="模板finance" value={promptFinance} onChange={(e) => setPromptFinance(e.target.value)} />
</div>
<div className="flex gap-2">
<Button onClick={saveConfig} disabled={saving}>{saving ? "保存中…" : "保存配置"}</Button>
{msg && <span className="text-xs text-muted-foreground">{msg}</span>}
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存所有配置'}
</Button>
{saveMessage && <span className="text-sm text-muted-foreground">{saveMessage}</span>}
</div>
</div>
);
}
}

View File

@ -1,674 +1,56 @@
"use client";
'use client';
/**
* -
*
*
* -
* -
* -
* -
* -
*/
import { useState, useMemo } from "react";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StatusBar, useStatusBar } from "@/components/ui/status-bar";
import { createDefaultStepManager } from "@/lib/execution-step-manager";
import { RowSettingsPanel } from "@/components/ui/row-settings";
import { useRowConfig } from "@/hooks/use-row-config";
import { EnhancedTable } from "@/components/ui/enhanced-table";
import { Notification } from "@/components/ui/notification";
import {
normalizeTsCode,
flattenApiGroups,
enhanceErrorMessage,
isRetryableError,
formatFinancialValue,
getMetricUnit
} from "@/lib/financial-utils";
import type {
MarketType,
ChartType,
CompanyInfo,
CompanySuggestion,
RevenueDataPoint,
FinancialMetricConfig,
FinancialDataSeries,
ExecutionStep,
BatchFinancialDataResponse,
FinancialConfigResponse,
SearchApiResponse,
BatchDataRequest
} from "@/types";
import {
ResponsiveContainer,
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function Home() {
// ============================================================================
// 基础状态管理
// ============================================================================
const [market, setMarket] = useState<MarketType>("cn");
const [query, setQuery] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [chartType, setChartType] = useState<ChartType>("bar");
export default function StockInputForm() {
const [symbol, setSymbol] = useState('');
const [market, setMarket] = useState('china');
const router = useRouter();
// ============================================================================
// 数据状态管理
// ============================================================================
const [items, setItems] = useState<RevenueDataPoint[]>([]);
const [selected, setSelected] = useState<CompanyInfo | null>(null);
const [configItems, setConfigItems] = useState<FinancialMetricConfig[]>([]);
const [metricSeries, setMetricSeries] = useState<FinancialDataSeries>({});
const [selectedMetric, setSelectedMetric] = useState<string>("revenue");
const [selectedMetricName, setSelectedMetricName] = useState<string>("营业收入");
const [paramToGroup, setParamToGroup] = useState<Record<string, string>>({});
const [paramToApi, setParamToApi] = useState<Record<string, string>>({});
// ============================================================================
// 搜索相关状态
// ============================================================================
const [suggestions, setSuggestions] = useState<CompanySuggestion[]>([]);
const [typingTimer, setTypingTimer] = useState<NodeJS.Timeout | null>(null);
// ============================================================================
// 状态栏管理
// ============================================================================
const {
statusBarState,
showStatusBar,
showSuccess,
showError,
hideStatusBar
} = useStatusBar();
// ============================================================================
// 执行步骤管理
// ============================================================================
const [stepManager] = useState(() => {
return createDefaultStepManager({
onStepStart: (step: ExecutionStep, index: number, total: number) => {
showStatusBar(step, index, total);
},
onStepComplete: (_step: ExecutionStep, index: number, total: number) => {
// If there are more steps, update to next step
if (index < total - 1) {
// This will be handled by the next step start
} else {
// All steps completed
showSuccess();
}
},
onStepError: (_step: ExecutionStep, _index: number, _total: number, error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
},
onComplete: () => {
showSuccess();
},
onError: (error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
}
});
});
// ============================================================================
// 表格行配置管理
// ============================================================================
const [isRowSettingsPanelOpen, setIsRowSettingsPanelOpen] = useState(false);
// Row configuration management - memoize to prevent infinite re-renders
const rowIds = useMemo(() =>
configItems.map(item => item.tushareParam || '').filter(Boolean),
[configItems]
);
const {
rowConfigs,
customRows,
updateRowConfig,
saveStatus,
clearSaveStatus,
addCustomRow,
deleteCustomRow,
updateRowOrder
} = useRowConfig(selected?.ts_code || null, rowIds);
const rowDisplayTexts = useMemo(() => {
const texts = configItems.reduce((acc, item) => {
if (item.tushareParam) {
acc[item.tushareParam] = item.displayText;
}
return acc;
}, {} as Record<string, string>);
// 添加自定义行的显示文本
Object.entries(customRows).forEach(([rowId, customRow]) => {
texts[rowId] = customRow.displayText;
});
return texts;
}, [configItems, customRows]);
// ============================================================================
// 搜索建议功能
// ============================================================================
/**
*
* @param text -
*/
async function fetchSuggestions(text: string): Promise<void> {
if (market !== "cn") {
setSuggestions([]);
return;
}
const searchQuery = (text || "").trim();
if (!searchQuery) {
setSuggestions([]);
return;
}
try {
const response = await fetch(
`http://localhost:8000/api/search?query=${encodeURIComponent(searchQuery)}&limit=8`
);
const data: SearchApiResponse = await response.json();
const suggestions = Array.isArray(data?.items) ? data.items : [];
setSuggestions(suggestions);
} catch (error) {
console.warn('Failed to fetch suggestions:', error);
setSuggestions([]);
}
}
// ============================================================================
// 搜索处理功能
// ============================================================================
/**
*
*/
const retrySearch = async (): Promise<void> => {
if (stepManager.canRetry()) {
try {
await stepManager.retry();
} catch {
// 错误已经在stepManager中处理
}
} else {
// 如果不能重试,重新执行搜索
await handleSearch();
const handleSearch = () => {
if (symbol.trim()) {
router.push(`/report/${symbol.trim()}?market=${market}`);
}
};
/**
*
*/
async function handleSearch(): Promise<void> {
// 重置状态
setError("");
setItems([]);
setMetricSeries({});
setConfigItems([]);
setSelectedMetric("revenue");
// 验证市场支持
if (market !== "cn") {
setError("目前仅支持 A 股查询,请选择\"中国\"市场。");
return;
}
// 获取并验证股票代码
const tsCode = selected?.ts_code || normalizeTsCode(query);
if (!tsCode) {
setError("请输入有效的 A 股股票代码(如 600519 / 000001 或带后缀 600519.SH。");
return;
}
setLoading(true);
try {
// 创建搜索执行步骤
const searchStep: ExecutionStep = {
id: 'fetch_financial_data',
name: '正在读取财务数据',
description: '从Tushare API获取公司财务指标数据',
execute: async () => {
await executeSearchStep(tsCode);
}
};
// 清空之前的步骤并添加新的搜索步骤
stepManager.clearSteps();
stepManager.addStep(searchStep);
// 执行搜索步骤
await stepManager.execute();
} catch (error) {
const errorMsg = enhanceErrorMessage(error);
setError(errorMsg);
// 错误处理已经在stepManager的回调中处理
} finally {
setLoading(false);
}
}
/**
*
* @param tsCode -
*/
async function executeSearchStep(tsCode: string): Promise<void> {
// 1) 获取配置tushare专用解析 api_groups -> 扁平 items
const configResponse = await fetch(`http://localhost:8000/api/financial-config`);
const configData: FinancialConfigResponse = await configResponse.json();
const groups = configData?.api_groups || {};
const { items, groupMap, apiMap } = flattenApiGroups(groups);
setConfigItems(items);
setParamToGroup(groupMap);
setParamToApi(apiMap);
// 2) 批量请求年度序列同API字段合并读取
const years = 10;
const metrics = Array.from(new Set(["revenue", ...items.map(i => i.tushareParam)]));
const batchRequest: BatchDataRequest = {
ts_code: tsCode,
years,
metrics
};
const batchResponse = await fetch(
`http://localhost:8000/api/financialdata/${encodeURIComponent(market)}/batch`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batchRequest),
}
);
const batchData: BatchFinancialDataResponse = await batchResponse.json();
const seriesObj = batchData?.series || {};
// 处理数据系列
const processedSeries: FinancialDataSeries = {};
for (const metric of metrics) {
const series = Array.isArray(seriesObj[metric]) ? seriesObj[metric] : [];
processedSeries[metric] = [...series].sort((a, b) => Number(a.year) - Number(b.year));
}
setMetricSeries(processedSeries);
// 3) 设置选中公司与默认图表序列(收入)
setSelected({
ts_code: batchData?.ts_code || tsCode,
name: batchData?.name
});
const revenueSeries = processedSeries["revenue"] || [];
setItems(revenueSeries.map(d => ({ year: d.year, revenue: d.value })));
const revenueName = items.find(i => i.tushareParam === "revenue")?.displayText || "营业收入";
setSelectedMetricName(revenueName);
if (revenueSeries.length === 0) {
throw new Error("未查询到数据,请确认代码或稍后重试。");
}
}
// ============================================================================
// 渲染组件
// ============================================================================
return (
<div className="space-y-8">
{/* StatusBar Component */}
<StatusBar
isVisible={statusBarState.isVisible}
currentStep={statusBarState.currentStep}
stepIndex={statusBarState.stepIndex}
totalSteps={statusBarState.totalSteps}
status={statusBarState.status}
errorMessage={statusBarState.errorMessage}
onDismiss={hideStatusBar}
onRetry={retrySearch}
retryable={statusBarState.retryable}
/>
{/* Configuration Save Status Notification */}
{saveStatus.status !== 'idle' && (
<Notification
message={saveStatus.message || ''}
type={saveStatus.status === 'success' ? 'success' : saveStatus.status === 'error' ? 'error' : 'info'}
isVisible={true}
onDismiss={clearSaveStatus}
position="bottom-right"
autoHide={saveStatus.status === 'success'}
autoHideDelay={2000}
/>
)}
{/* Row Settings Panel */}
<RowSettingsPanel
isOpen={isRowSettingsPanelOpen}
onClose={() => setIsRowSettingsPanelOpen(false)}
rowConfigs={rowConfigs}
rowDisplayTexts={rowDisplayTexts}
onConfigChange={updateRowConfig}
onRowOrderChange={updateRowOrder}
onDeleteCustomRow={deleteCustomRow}
enableRowReordering={true}
/>
<section className="space-y-3">
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
使 Next.js + shadcn/ui
</p>
<div className="flex gap-2 max-w-xl relative">
<Select value={market} onValueChange={(v) => setMarket(v as MarketType)}>
<SelectTrigger className="w-28 sm:w-40" aria-label="选择市场">
<SelectValue placeholder="选择市场" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cn"></SelectItem>
<SelectItem value="us"></SelectItem>
<SelectItem value="hk"></SelectItem>
<SelectItem value="jp"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
<Input
placeholder="输入股票代码或公司名,例如 600519 / 000001 / 贵州茅台"
value={query}
onChange={(e) => {
const v = e.target.value;
setQuery(v);
setSelected(null);
if (typingTimer) clearTimeout(typingTimer);
const t = setTimeout(() => fetchSuggestions(v), 250);
setTypingTimer(t);
}}
/>
{/* 下拉建议 */}
{suggestions.length > 0 && (
<div className="absolute top-12 left-0 right-0 z-10 bg-white border rounded shadow">
{suggestions.map((s, i) => (
<div
key={s.ts_code + i}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer flex justify-between"
onClick={() => {
setQuery(`${s.ts_code} ${s.name}`);
setSelected({ ts_code: s.ts_code, name: s.name });
setSuggestions([]);
}}
>
<span>{s.name}</span>
<span className="text-muted-foreground">{s.ts_code}</span>
</div>
))}
</div>
)}
<Button onClick={handleSearch} disabled={loading}>
{loading ? "查询中..." : "搜索"}
</Button>
</div>
{error && <Badge variant="secondary">{error}</Badge>}
</section>
<section>
{items.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>10A股</CardTitle>
<CardDescription> Tushare{selected && selected.name ? `${selected.name} (${selected.ts_code})` : selected?.ts_code}</CardDescription>
</div>
<div className="mt-1">
<Select value={chartType} onValueChange={(v) => setChartType(v as ChartType)}>
<SelectTrigger className="w-40" aria-label="选择图表类型">
<SelectValue placeholder="选择图表类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar"></SelectItem>
<SelectItem value="line">线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* 使用 Recharts 渲染图表(动态单位) */}
{(() => {
// 获取当前选中指标的信息
const currentMetricInfo = configItems.find(ci => (ci.tushareParam || "") === selectedMetric);
const metricGroup = currentMetricInfo?.group;
const metricApi = currentMetricInfo?.api;
const metricUnit = getMetricUnit(metricGroup, metricApi, selectedMetric);
// 构建完整的图例名称
const legendName = `${selectedMetricName}${metricUnit}`;
// 根据指标类型确定数据缩放和单位
const shouldScaleToYi = (
metricGroup === "income" ||
metricGroup === "balancesheet" ||
metricGroup === "cashflow" ||
selectedMetric === "total_mv"
);
const chartData = items.map((d) => {
let scaledValue = typeof d.revenue === "number" ? d.revenue : 0;
if (shouldScaleToYi) {
// 对于财务报表数据,转换为亿元
if (selectedMetric === "total_mv") {
// 市值从万元转为亿元
scaledValue = scaledValue / 1e4;
} else {
// 其他财务数据从元转为亿元
scaledValue = scaledValue / 1e8;
}
}
return {
year: d.year,
metricValue: scaledValue,
};
});
return (
<div className="w-full h-[320px]">
<ResponsiveContainer width="100%" height="100%">
{chartType === "bar" ? (
<BarChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Bar dataKey="metricValue" name={legendName} fill="#4f46e5" />
</BarChart>
) : (
<LineChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Line type="monotone" dataKey="metricValue" name={legendName} stroke="#4f46e5" dot />
</LineChart>
)}
</ResponsiveContainer>
</div>
);
})()}
{/* 增强数据表格 */}
{(() => {
const series = metricSeries;
const allYears = Object.values(series)
.flat()
.map(d => d.year);
if (allYears.length === 0) return null;
const yearsDesc = Array.from(new Set(allYears)).sort((a, b) => Number(b) - Number(a));
const columns = yearsDesc;
function valueOf(m: string, year: string): number | null | undefined {
const s = series[m] || [];
const f = s.find(d => d.year === year);
return f ? f.value : undefined;
}
function fmtCell(m: string, y: string): string {
const v = valueOf(m, y);
const group = paramToGroup[m] || "";
const api = paramToApi[m] || "";
return formatFinancialValue(v, group, api, m);
}
// 行点击切换图表数据源
function onRowClick(m: string) {
setSelectedMetric(m);
// 指标中文名传给图表
const rowInfo = configItems.find(ci => (ci.tushareParam || "") === m);
setSelectedMetricName(rowInfo?.displayText || m);
const s = series[m] || [];
setItems(s.map(d => ({ year: d.year, revenue: d.value })));
}
// 准备表格数据
const baseTableData = configItems.map((row, idx) => {
const m = (row.tushareParam || "").trim();
const values: Record<string, string> = {};
if (m) {
columns.forEach(year => {
values[year] = fmtCell(m, year);
});
} else {
columns.forEach(year => {
values[year] = "-";
});
}
return {
id: m || `row_${idx}`,
displayText: row.displayText + getMetricUnit(row.group, row.api, row.tushareParam),
values,
group: row.group,
api: row.api,
tushareParam: row.tushareParam,
isCustomRow: false
};
});
// 添加自定义行数据(仅分隔线)
const customRowData = Object.entries(customRows)
.filter(([, customRow]) => customRow.customRowType === 'separator')
.map(([rowId, customRow]) => {
const values: Record<string, string> = {};
columns.forEach(year => {
values[year] = "-"; // 分隔线不显示数据
});
return {
id: rowId,
displayText: customRow.displayText,
values,
isCustomRow: true,
customRowType: customRow.customRowType
};
});
// 合并基础数据和自定义行数据
const tableData = [...baseTableData, ...customRowData];
return (
<EnhancedTable
data={tableData}
columns={columns}
rowConfigs={rowConfigs}
selectedRowId={selectedMetric}
onRowClick={onRowClick}
onRowConfigChange={updateRowConfig}
onOpenSettings={() => setIsRowSettingsPanelOpen(true)}
onAddCustomRow={addCustomRow}
onDeleteCustomRow={deleteCustomRow}
onRowOrderChange={updateRowOrder}
enableAnimations={true}
animationDuration={300}
enableVirtualization={configItems.length > 50}
maxVisibleRows={50}
enableRowDragging={true}
/>
);
})()}
</CardContent>
</Card>
)}
</section>
<div className="flex justify-center items-center h-full">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label></label>
<Input
placeholder="例如: 600519.SH 或 AAPL"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
/>
</div>
<div className="space-y-2">
<label></label>
<Select value={market} onValueChange={setMarket}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="china"></SelectItem>
<SelectItem value="hongkong"></SelectItem>
<SelectItem value="usa"></SelectItem>
<SelectItem value="japan"></SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleSearch} className="w-full"></Button>
</CardContent>
</Card>
</div>
);
}
}

View File

@ -0,0 +1,422 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { useChinaFinancials } from '@/hooks/useApi';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle, XCircle } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useEffect, useState, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { CompanyProfileResponse } from '@/types';
export default function ReportPage() {
const params = useParams();
const searchParams = useSearchParams();
const symbol = params.symbol as string;
const market = (searchParams.get('market') || '').toLowerCase();
const isChina = market === 'china' || market === 'cn';
// 规范化中国市场 ts_code若为6位数字或无后缀自动推断交易所
const normalizedTsCode = (() => {
if (!isChina) return symbol;
if (!symbol) return symbol;
if (symbol.includes('.')) return symbol.toUpperCase();
const onlyDigits = symbol.replace(/\D/g, '');
if (onlyDigits.length === 6) {
const first = onlyDigits[0];
if (first === '6') return `${onlyDigits}.SH`;
if (first === '0' || first === '3') return `${onlyDigits}.SZ`;
}
return symbol.toUpperCase();
})();
const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined);
// 公司简介状态(一次性加载)
const [profileContent, setProfileContent] = useState('');
const [profileLoading, setProfileLoading] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const fetchedRef = useRef<boolean>(false); // 防止重复请求
// 当财务数据加载完成后,加载公司简介
useEffect(() => {
if (!isChina || isLoading || error || !financials || fetchedRef.current) {
return;
}
fetchedRef.current = true; // 标记已请求
const fetchProfile = async () => {
setProfileLoading(true);
setProfileError(null);
setProfileContent('');
try {
const response = await fetch(
`/api/financials/china/${normalizedTsCode}/company-profile?company_name=${encodeURIComponent(financials.name || normalizedTsCode)}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: CompanyProfileResponse = await response.json();
if (data.success) {
setProfileContent(data.content);
} else {
setProfileError(data.error || '生成失败');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '加载失败';
setProfileError(errorMessage);
} finally {
setProfileLoading(false);
}
};
fetchProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isChina, isLoading, error, financials, normalizedTsCode]);
return (
<div className="space-y-4">
<div className="flex items-stretch justify-between gap-4">
{/* 左侧:报告信息卡片 */}
<Card className="flex-1">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">{normalizedTsCode}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">{market}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">
{isLoading ? (
<span className="flex items-center gap-1">
<Spinner className="size-3" />
<span className="text-muted-foreground">...</span>
</span>
) : financials?.name ? (
financials.name
) : (
<span className="text-muted-foreground">-</span>
)}
</span>
</div>
</CardContent>
</Card>
{/* 右侧:任务状态 */}
{isChina && (
<Card className="w-80">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* 财务数据任务 */}
<div className="flex items-center gap-2 text-sm">
{isLoading ? (
<Spinner className="size-4" />
) : error ? (
<XCircle className="size-4 text-red-500" />
) : financials ? (
<CheckCircle className="size-4 text-green-600" />
) : null}
<div>
<div className="font-medium"></div>
<div className="text-xs text-muted-foreground">{normalizedTsCode} · {market}</div>
</div>
</div>
{/* 公司简介任务 */}
{!isLoading && !error && financials && (
<div className="flex items-center gap-2 text-sm">
{profileLoading ? (
<Spinner className="size-4" />
) : profileError ? (
<XCircle className="size-4 text-red-500" />
) : profileContent ? (
<CheckCircle className="size-4 text-green-600" />
) : null}
<div>
<div className="font-medium"></div>
<div className="text-xs text-muted-foreground">{normalizedTsCode} · Gemini AI</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
{isChina && (
<Tabs defaultValue="financial" className="mt-4">
<TabsList>
<TabsTrigger value="financial"></TabsTrigger>
<TabsTrigger value="profile"></TabsTrigger>
<TabsTrigger value="execution"></TabsTrigger>
</TabsList>
<TabsContent value="financial" className="space-y-4">
<h2 className="text-lg font-medium"> Tushare</h2>
<div className="flex items-center gap-3 text-sm">
{isLoading ? (
<Spinner className="size-4" />
) : error ? (
<XCircle className="size-4 text-red-500" />
) : (
<CheckCircle className="size-4 text-green-600" />
)}
<div className="text-muted-foreground">
{isLoading
? '正在读取 Tushare 数据…'
: error
? '读取失败'
: '读取完成'}
</div>
</div>
{error && <p className="text-red-500"></p>}
{isLoading && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"></span>
<Spinner className="size-4" />
</div>
)}
{financials && (
<div className="overflow-x-auto">
{(() => {
const series = financials?.series ?? {};
const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null }>;
const years = Array.from(new Set(allPoints.map((p) => p?.year).filter(Boolean))).sort();
if (years.length === 0) {
return <p className="text-sm text-muted-foreground"></p>;
}
return (
<table className="min-w-full text-sm">
<thead>
<tr>
<th className="text-left p-2"></th>
{years.map((y) => (
<th key={y} className="text-right p-2">{y}</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(series).map(([metric, points]) => (
<tr key={metric}>
<td className="p-2 text-muted-foreground">{metric}</td>
{years.map((y) => {
const pointsArray = points as Array<{ year?: string; value?: number | null }>;
const v = pointsArray.find((p) => p?.year === y)?.value;
return <td key={y} className="text-right p-2">{v ?? '-'}</td>;
})}
</tr>
))}
</tbody>
</table>
);
})()}
</div>
)}
</TabsContent>
<TabsContent value="profile" className="space-y-4">
<h2 className="text-lg font-medium"> Gemini AI</h2>
{!financials && (
<p className="text-sm text-muted-foreground">...</p>
)}
{financials && (
<>
<div className="flex items-center gap-3 text-sm">
{profileLoading ? (
<Spinner className="size-4" />
) : profileError ? (
<XCircle className="size-4 text-red-500" />
) : profileContent ? (
<CheckCircle className="size-4 text-green-600" />
) : null}
<div className="text-muted-foreground">
{profileLoading
? '正在生成公司简介...'
: profileError
? '生成失败'
: profileContent
? '生成完成'
: ''}
</div>
</div>
{profileError && (
<p className="text-red-500">: {profileError}</p>
)}
{(profileLoading || profileContent) && (
<div className="space-y-4">
<div className="border rounded-lg p-6 bg-card">
<div className="leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({node, ...props}) => <h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0" {...props} />,
h2: ({node, ...props}) => <h2 className="text-xl font-bold mb-3 mt-5 first:mt-0" {...props} />,
h3: ({node, ...props}) => <h3 className="text-lg font-semibold mb-2 mt-4 first:mt-0" {...props} />,
p: ({node, ...props}) => <p className="mb-2 leading-7" {...props} />,
ul: ({node, ...props}) => <ul className="list-disc list-inside mb-2 space-y-1 ml-4" {...props} />,
ol: ({node, ...props}) => <ol className="list-decimal list-inside mb-2 space-y-1 ml-4" {...props} />,
li: ({node, ...props}) => <li className="ml-2" {...props} />,
strong: ({node, ...props}) => <strong className="font-semibold" {...props} />,
em: ({node, ...props}) => <em className="italic text-muted-foreground" {...props} />,
blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />,
code: ({node, inline, ...props}: any) =>
inline
? <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props} />
: <code className="block bg-muted p-4 rounded my-2 overflow-x-auto font-mono text-sm" {...props} />,
pre: ({node, ...props}) => <pre className="bg-muted p-4 rounded my-2 overflow-x-auto" {...props} />,
table: ({node, ...props}) => <div className="overflow-x-auto my-2"><table className="border-collapse border border-border w-full" {...props} /></div>,
th: ({node, ...props}) => <th className="border border-border px-3 py-2 bg-muted font-semibold text-left" {...props} />,
td: ({node, ...props}) => <td className="border border-border px-3 py-2" {...props} />,
a: ({node, ...props}) => <a className="text-primary underline hover:text-primary/80" {...props} />,
hr: ({node, ...props}) => <hr className="my-4 border-border" {...props} />,
}}
>
{profileContent}
</ReactMarkdown>
{profileLoading && (
<span className="inline-flex items-center gap-2 mt-2 text-muted-foreground">
<Spinner className="size-3" />
<span className="text-sm">...</span>
</span>
)}
</div>
</div>
</div>
)}
</>
)}
</TabsContent>
<TabsContent value="execution" className="space-y-4">
<h2 className="text-lg font-medium"></h2>
{/* 执行概况卡片 */}
{financials && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 财务数据状态 */}
<div>
<div className="flex items-center gap-2 mb-2">
{isLoading ? (
<Spinner className="size-4" />
) : error ? (
<XCircle className="size-4 text-red-500" />
) : (
<CheckCircle className="size-4 text-green-600" />
)}
<span className="font-medium"></span>
</div>
{financials?.meta && (
<div className="ml-6 text-sm text-muted-foreground space-y-1">
<div>: {financials.meta.elapsed_ms} ms</div>
<div>API调用: {financials.meta.api_calls_total} </div>
<div>: {financials.meta.started_at}</div>
{financials.meta.finished_at && (
<div>: {financials.meta.finished_at}</div>
)}
</div>
)}
</div>
{/* 公司简介状态 */}
{!isLoading && !error && financials && (
<div className="pt-3 border-t">
<div className="flex items-center gap-2 mb-2">
{profileLoading ? (
<Spinner className="size-4" />
) : profileError ? (
<XCircle className="size-4 text-red-500" />
) : profileContent ? (
<CheckCircle className="size-4 text-green-600" />
) : null}
<span className="font-medium"></span>
</div>
{profileContent && (
<div className="ml-6 text-sm text-muted-foreground space-y-1">
<div>: {profileLoading ? '生成中...' : profileContent ? '已完成' : '等待中'}</div>
<div>: {profileContent.length}</div>
</div>
)}
</div>
)}
{/* 总体统计 */}
<div className="pt-3 border-t">
<div className="font-medium mb-2"></div>
<div className="ml-6 text-sm text-muted-foreground space-y-1">
<div>: {financials?.meta?.elapsed_ms || 0} ms</div>
{financials?.meta?.steps && (
<div>: {financials.meta.steps.filter(s => s.status === 'done').length}/{financials.meta.steps.length}</div>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* 执行步骤 */}
{financials?.meta?.steps && financials.meta.steps.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{financials.meta.steps.map((s, i) => (
<li key={i} className="flex items-start gap-3 p-2 rounded hover:bg-muted/50">
<div className="mt-0.5">
{s.status === 'running' && <Spinner className="size-4" />}
{s.status === 'done' && <CheckCircle className="size-4 text-green-600" />}
{s.status === 'error' && <XCircle className="size-4 text-red-500" />}
</div>
<div className="flex-1">
<div className="font-medium">{s.name}</div>
<div className="text-sm text-muted-foreground">
: {s.status}
{s.duration_ms && ` • 耗时: ${s.duration_ms}ms`}
{s.start_ts && ` • 开始: ${new Date(s.start_ts).toLocaleTimeString()}`}
</div>
{s.error && (
<div className="text-sm text-red-500 mt-1">: {s.error}</div>
)}
</div>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,52 @@
import useSWR from 'swr';
import { useConfigStore } from '@/stores/useConfigStore';
import { BatchFinancialDataResponse, FinancialConfigResponse } from '@/types';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function useConfig() {
const { setConfig, setError } = useConfigStore();
const { data, error, isLoading } = useSWR('/api/config', fetcher, {
onSuccess: (data) => setConfig(data),
onError: (err) => setError(err.message),
});
return { data, error, isLoading };
}
export async function updateConfig(newConfig: any) {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newConfig),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function testConfig(type: string, data: any) {
const res = await fetch('/api/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_type: type, config_data: data }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export function useFinancialConfig() {
return useSWR<FinancialConfigResponse>('/api/financials/config', fetcher);
}
export function useChinaFinancials(ts_code?: string) {
return useSWR<BatchFinancialDataResponse>(
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}` : null,
fetcher,
{
revalidateOnFocus: false, // 不在窗口聚焦时重新验证
revalidateOnReconnect: false, // 不在网络重连时重新验证
dedupingInterval: 300000, // 5分钟内去重
errorRetryCount: 1, // 错误重试1次
}
);
}

View File

@ -0,0 +1,38 @@
import { create } from 'zustand';
// 根据设计文档定义配置项的类型
export interface DatabaseConfig {
url: string;
}
export interface GeminiConfig {
api_key: string;
}
export interface DataSourceConfig {
[key: string]: { api_key: string };
}
export interface SystemConfig {
database: DatabaseConfig;
gemini_api: GeminiConfig;
data_sources: DataSourceConfig;
}
interface ConfigState {
config: SystemConfig | null;
loading: boolean;
error: string | null;
setConfig: (config: SystemConfig) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export const useConfigStore = create<ConfigState>((set) => ({
config: null,
loading: true,
error: null,
setConfig: (config) => set({ config, loading: false, error: null }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error, loading: false }),
}));

View File

@ -85,6 +85,25 @@ export interface FinancialDataSeries {
/**
*
*/
export interface StepRecord {
name: string;
start_ts: string;
end_ts?: string;
duration_ms?: number;
status: 'running' | 'done' | 'error';
error?: string;
}
export interface FinancialMeta {
started_at: string;
finished_at?: string;
elapsed_ms?: number;
api_calls_total: number;
api_calls_by_group: Record<string, number>;
current_action?: string | null;
steps: StepRecord[];
}
export interface BatchFinancialDataResponse {
/** 股票代码 */
ts_code: string;
@ -92,6 +111,8 @@ export interface BatchFinancialDataResponse {
name?: string;
/** 数据系列 */
series: FinancialDataSeries;
/** 元数据耗时、API调用次数、步骤 */
meta?: FinancialMeta;
}
/**
@ -104,6 +125,40 @@ export interface FinancialConfigResponse {
};
}
/**
* Token使用情况接口
*/
export interface TokenUsage {
/** 提示词token数 */
prompt_tokens: number;
/** 完成token数 */
completion_tokens: number;
/** 总token数 */
total_tokens: number;
}
/**
*
*/
export interface CompanyProfileResponse {
/** 股票代码 */
ts_code: string;
/** 公司名称 */
company_name?: string;
/** 分析内容 */
content: string;
/** 使用的模型 */
model: string;
/** Token使用情况 */
tokens: TokenUsage;
/** 耗时(毫秒) */
elapsed_ms: number;
/** 是否成功 */
success: boolean;
/** 错误信息 */
error?: string;
}
// ============================================================================
// 表格相关类型
// ============================================================================

83
package-lock.json generated Normal file
View File

@ -0,0 +1,83 @@
{
"name": "Fundamental_Analysis",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"swr": "^2.3.6",
"zustand": "^5.0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"swr": "^2.3.6",
"zustand": "^5.0.8"
}
}

121
scripts/dev.sh Executable file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
# Colors
RESET="\033[0m"
GREEN="\033[32m"
CYAN="\033[36m"
YELLOW="\033[33m"
RED="\033[31m"
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
BACKEND_DIR="$REPO_ROOT/backend"
FRONTEND_DIR="$REPO_ROOT/frontend"
CONFIG_FILE="$REPO_ROOT/config/config.json"
# Port configuration
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Kill process using specified port
kill_port() {
local port=$1
local pids=$(lsof -ti tcp:"$port" 2>/dev/null || true)
if [[ -n "$pids" ]]; then
echo -e "${YELLOW}[CLEANUP]${RESET} Killing process(es) using port $port: $pids"
echo "$pids" | xargs kill -9 2>/dev/null || true
sleep 1
fi
}
ensure_backend() {
cd "$BACKEND_DIR"
if [[ ! -d .venv ]]; then
echo -e "${YELLOW}[SETUP]${RESET} Creating Python venv and installing backend requirements..."
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
else
source .venv/bin/activate
fi
# Export TUSHARE_TOKEN from config if available (prefer jq, fallback to node)
if [[ -f "$CONFIG_FILE" ]]; then
if command -v jq >/dev/null 2>&1; then
TUSHARE_TOKEN_VAL=$(jq -r '.data_sources.tushare.api_key // empty' "$CONFIG_FILE" 2>/dev/null || true)
else
TUSHARE_TOKEN_VAL=$(node -e "const fs=require('fs');try{const c=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));const v=c?.data_sources?.tushare?.api_key||'';if(v)process.stdout.write(v)}catch{}" "$CONFIG_FILE" 2>/dev/null || true)
fi
if [[ -n "${TUSHARE_TOKEN_VAL:-}" ]]; then
export TUSHARE_TOKEN="$TUSHARE_TOKEN_VAL"
fi
fi
}
run_backend() {
ensure_backend
cd "$BACKEND_DIR"
# Run and colorize output (avoid stdbuf on macOS)
UVICORN_CMD=(uvicorn app.main:app --reload --port "$BACKEND_PORT")
"${UVICORN_CMD[@]}" 2>&1 | awk -v p="[BACKEND]" -v color="$GREEN" -v reset="$RESET" '{print color p " " $0 reset}'
}
ensure_frontend() {
cd "$FRONTEND_DIR"
if [[ ! -d node_modules ]]; then
echo -e "${YELLOW}[SETUP]${RESET} Installing frontend dependencies (npm install)..."
npm install --no-fund --no-audit
fi
}
run_frontend() {
ensure_frontend
cd "$FRONTEND_DIR"
npm run dev 2>&1 | awk -v p="[FRONTEND]" -v color="$CYAN" -v reset="$RESET" '{print color p " " $0 reset}'
}
cleanup() {
echo -e "\n${YELLOW}[CLEANUP]${RESET} Stopping services..."
# Kill process groups to ensure all child processes are terminated
if [[ -n "${BACKEND_PID:-}" ]]; then
kill -TERM -"$BACKEND_PID" 2>/dev/null || kill "$BACKEND_PID" 2>/dev/null || true
fi
if [[ -n "${FRONTEND_PID:-}" ]]; then
kill -TERM -"$FRONTEND_PID" 2>/dev/null || kill "$FRONTEND_PID" 2>/dev/null || true
fi
sleep 1
# Force kill any remaining processes on these ports
kill_port "$BACKEND_PORT"
kill_port "$FRONTEND_PORT"
wait 2>/dev/null || true
echo -e "${GREEN}[CLEANUP]${RESET} All services stopped."
}
main() {
echo -e "${CYAN}Dev Launcher${RESET}: Starting Backend ($BACKEND_PORT) and Frontend ($FRONTEND_PORT)\n"
# Clean up ports before starting
kill_port "$BACKEND_PORT"
kill_port "$FRONTEND_PORT"
echo -e "${GREEN}[BACKEND]${RESET} API: http://127.0.0.1:$BACKEND_PORT"
echo -e "${CYAN}[FRONTEND]${RESET} APP: http://127.0.0.1:$FRONTEND_PORT\n"
run_backend & BACKEND_PID=$!
run_frontend & FRONTEND_PID=$!
trap cleanup INT TERM EXIT
# Wait on both; if either exits, we exit (portable)
while true; do
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then break; fi
if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then break; fi
sleep 1
done
}
main "$@"

View File

@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
# Forward CLEAN and USE_SYSTEM_PYTHON to sub-scripts
export CLEAN="${CLEAN:-0}"
export USE_SYSTEM_PYTHON="${USE_SYSTEM_PYTHON:-0}"
"$REPO_ROOT/scripts/setup_backend.sh"
"$REPO_ROOT/scripts/setup_frontend.sh"
echo "[all] environments setup completed."

View File

@ -1,66 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
BACKEND_DIR="$REPO_ROOT/backend"
VENV_DIR="$REPO_ROOT/.venv"
PYTHON_BIN="python3"
# Allow override: USE_SYSTEM_PYTHON=1 to skip venv
USE_SYSTEM_PYTHON="${USE_SYSTEM_PYTHON:-0}"
# CLEAN=1 to recreate venv
CLEAN="${CLEAN:-0}"
echo "[backend] repo: $REPO_ROOT"
echo "[backend] dir: $BACKEND_DIR"
echo "[backend] checking Python..."
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python)"
else
echo "[backend] ERROR: python3/python not found" >&2
exit 1
fi
echo "[backend] python: $PYTHON_BIN ($("$PYTHON_BIN" -V))"
if [[ "$USE_SYSTEM_PYTHON" == "1" ]]; then
echo "[backend] using system Python (no venv)"
else
if [[ "$CLEAN" == "1" && -d "$VENV_DIR" ]]; then
echo "[backend] CLEAN=1 -> removing existing venv: $VENV_DIR"
rm -rf "$VENV_DIR"
fi
if [[ ! -d "$VENV_DIR" ]]; then
echo "[backend] creating venv: $VENV_DIR"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# Activate venv
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
echo "[backend] venv activated: $VIRTUAL_ENV"
echo "[backend] upgrading pip/setuptools/wheel"
python -m pip install --upgrade pip setuptools wheel
fi
REQ_FILE="$BACKEND_DIR/requirements.txt"
if [[ ! -f "$REQ_FILE" ]]; then
echo "[backend] ERROR: requirements.txt not found at $REQ_FILE" >&2
exit 1
fi
echo "[backend] installing requirements: $REQ_FILE"
pip install -r "$REQ_FILE"
# Show versions
echo "[backend] python version: $(python -V)"
echo "[backend] pip version: $(python -m pip -V)"
if command -v uvicorn >/dev/null 2>&1; then
echo "[backend] uvicorn version: $(python -c 'import uvicorn,sys; print(uvicorn.__version__)' || echo unknown)"
else
echo "[backend] uvicorn not found; you may need it for dev server"
fi
echo "[backend] setup completed."

View File

@ -1,37 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
FRONTEND_DIR="$REPO_ROOT/frontend"
CLEAN="${CLEAN:-0}"
cd "$FRONTEND_DIR"
echo "[frontend] repo: $REPO_ROOT"
echo "[frontend] dir: $FRONTEND_DIR"
if ! command -v node >/dev/null 2>&1; then
echo "[frontend] ERROR: node not found" >&2
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo "[frontend] ERROR: npm not found" >&2
exit 1
fi
echo "[frontend] node: $(node -v)"
echo "[frontend] npm: $(npm -v)"
if [[ "$CLEAN" == "1" ]]; then
echo "[frontend] CLEAN=1 -> removing node_modules and .next/.turbo"
rm -rf node_modules .next .turbo
fi
if [[ -f package-lock.json ]]; then
echo "[frontend] detected package-lock.json -> using npm ci"
npm ci
else
echo "[frontend] no lockfile -> using npm install"
npm install
fi
echo "[frontend] setup completed."