feat: 完成基础财务分析系统功能开发
This commit is contained in:
parent
ce6cc1ddb8
commit
aab1ab665b
147
backend/alembic.ini
Normal file
147
backend/alembic.ini
Normal 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
1
backend/alembic/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
83
backend/alembic/env.py
Normal file
83
backend/alembic/env.py
Normal 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()
|
||||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal 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"}
|
||||||
79
backend/alembic/versions/65b0d87d025a_initial_migration.py
Normal file
79
backend/alembic/versions/65b0d87d025a_initial_migration.py
Normal 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 ###
|
||||||
31
backend/app/core/config.py
Normal file
31
backend/app/core/config.py
Normal 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()
|
||||||
9
backend/app/core/database.py
Normal file
9
backend/app/core/database.py
Normal 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)
|
||||||
18
backend/app/core/dependencies.py
Normal file
18
backend/app/core/dependencies.py
Normal 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
36
backend/app/main.py
Normal 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}
|
||||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal 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"]
|
||||||
20
backend/app/models/analysis_module.py
Normal file
20
backend/app/models/analysis_module.py
Normal 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")
|
||||||
3
backend/app/models/base.py
Normal file
3
backend/app/models/base.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
23
backend/app/models/progress_tracking.py
Normal file
23
backend/app/models/progress_tracking.py
Normal 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")
|
||||||
21
backend/app/models/report.py
Normal file
21
backend/app/models/report.py
Normal 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")
|
||||||
13
backend/app/models/system_config.py
Normal file
13
backend/app/models/system_config.py
Normal 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)
|
||||||
38
backend/app/routers/config.py
Normal file
38
backend/app/routers/config.py
Normal 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")
|
||||||
249
backend/app/routers/financial.py
Normal file
249
backend/app/routers/financial.py
Normal 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")
|
||||||
|
)
|
||||||
33
backend/app/schemas/config.py
Normal file
33
backend/app/schemas/config.py
Normal 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
|
||||||
57
backend/app/schemas/financial.py
Normal file
57
backend/app/schemas/financial.py
Normal 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
|
||||||
162
backend/app/services/company_profile_client.py
Normal file
162
backend/app/services/company_profile_client.py
Normal 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
|
||||||
87
backend/app/services/config_manager.py
Normal file
87
backend/app/services/config_manager.py
Normal 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()
|
||||||
52
backend/app/services/tushare_client.py
Normal file
52
backend/app/services/tushare_client.py
Normal 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
8
backend/requirements.txt
Normal 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
|
||||||
@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
此阶段专注于实现数据库模型和系统配置API,为上层业务逻辑提供基础。
|
此阶段专注于实现数据库模型和系统配置API,为上层业务逻辑提供基础。
|
||||||
|
|
||||||
- **T2.1 [Backend/DB]**: 根据设计文档,使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。
|
- [x] **T2.1 [Backend/DB]**: 根据设计文档,使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。 **[完成 - 2025-10-21]**
|
||||||
- **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本,在数据库中生成上述四张表。
|
- [x] **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本,在数据库中生成上述四张表。 **[完成 - 2025-10-21]**
|
||||||
- **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。
|
- [x] **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。 **[完成 - 2025-10-21]**
|
||||||
- **T2.4 [Backend/API]**: 创建Pydantic Schema,用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。
|
- **T2.4 [Backend/API]**: 创建Pydantic Schema,用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。
|
||||||
- **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。
|
- **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。
|
||||||
- **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。
|
- **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。
|
||||||
@ -26,13 +26,14 @@
|
|||||||
## Phase 3: 前端基础与配置页面 (P1)
|
## Phase 3: 前端基础与配置页面 (P1)
|
||||||
|
|
||||||
此阶段完成前端项目的基本设置,并开发出第一个功能页面——系统配置管理。
|
此阶段完成前端项目的基本设置,并开发出第一个功能页面——系统配置管理。
|
||||||
|
**[完成 - 2025-10-21]**
|
||||||
|
|
||||||
- **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。
|
- [x] **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。
|
||||||
- **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。
|
- [x] **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。
|
||||||
- **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。
|
- [x] **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。
|
||||||
- **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件,包含用于数据库、Gemini API和数据源配置的表单。
|
- [x] **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件,包含用于数据库、Gemini API和数据源配置的表单。
|
||||||
- **T3.5 [Frontend/API]**: 编写API客户端函数,用于调用后端的`/api/config`系列接口。
|
- [x] **T3.5 [Frontend/API]**: 编写API客户端函数,用于调用后端的`/api/config`系列接口。
|
||||||
- **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。
|
- [x] **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。
|
||||||
|
|
||||||
## Phase 4: 核心功能 - 报告生成与进度追踪 (P1)
|
## Phase 4: 核心功能 - 报告生成与进度追踪 (P1)
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const 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() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
1582
frontend/package-lock.json
generated
1582
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,14 +12,19 @@
|
|||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "15.5.5",
|
"next": "15.5.5",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.3.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": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|||||||
20
frontend/src/app/api/config/route.ts
Normal file
20
frontend/src/app/api/config/route.ts
Normal 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' } });
|
||||||
|
}
|
||||||
12
frontend/src/app/api/financials/[...slug]/route.ts
Normal file
12
frontend/src/app/api/financials/[...slug]/route.ts
Normal 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' } });
|
||||||
|
}
|
||||||
@ -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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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() {
|
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 [saving, setSaving] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig();
|
if (config) {
|
||||||
testHealth();
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold">配置中心</h1>
|
<h1 className="text-2xl font-semibold">配置中心</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
切换 LLM、配置数据源与模板;不回显敏感密钥,留空表示保持现值。
|
管理系统配置,包括数据库、API密钥等。敏感密钥不回显,留空表示保持现值。
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>后端健康</CardTitle>
|
<CardTitle>数据库配置</CardTitle>
|
||||||
<CardDescription>GET /health</CardDescription>
|
<CardDescription>PostgreSQL 连接设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center gap-2">
|
<CardContent className="space-y-4">
|
||||||
<Badge variant={health === "ok" ? "secondary" : "outline"}>{health}</Badge>
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="outline" onClick={testHealth}>重新测试</Button>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>LLM 设置</CardTitle>
|
<CardTitle>AI 服务配置</CardTitle>
|
||||||
<CardDescription>Gemini / OpenAI</CardDescription>
|
<CardDescription>Google Gemini API 设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<label className="text-sm w-28">Provider</label>
|
<label className="w-28">API Key</label>
|
||||||
<select
|
<Input
|
||||||
className="border rounded px-2 py-1 bg-background"
|
type="password"
|
||||||
value={provider}
|
value={geminiApiKey}
|
||||||
onChange={(e) => setProvider(e.target.value as any)}
|
onChange={(e) => setGeminiApiKey(e.target.value)}
|
||||||
>
|
placeholder="留空表示保持现值"
|
||||||
<option value="gemini">Gemini</option>
|
className="flex-1"
|
||||||
<option value="openai">OpenAI</option>
|
/>
|
||||||
</select>
|
<Button onClick={handleTestGemini}>测试</Button>
|
||||||
</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)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
{geminiTestResult && (
|
||||||
|
<Badge variant={geminiTestResult.success ? 'secondary' : 'destructive'}>
|
||||||
|
{geminiTestResult.message}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>数据源密钥</CardTitle>
|
<CardTitle>数据源配置</CardTitle>
|
||||||
<CardDescription>TuShare / Finnhub / JP</CardDescription>
|
<CardDescription>Tushare API 设置</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex items-center gap-4">
|
||||||
<label className="text-sm w-28">TuShare</label>
|
<label className="w-28">Tushare Token</label>
|
||||||
<Input type="password" placeholder="留空=保持现值" value={tushareKey} onChange={(e) => setTushareKey(e.target.value)} />
|
<Input
|
||||||
</div>
|
type="password"
|
||||||
<div className="flex gap-2 items-center">
|
value={tushareApiKey}
|
||||||
<label className="text-sm w-28">Finnhub</label>
|
onChange={(e) => setTushareApiKey(e.target.value)}
|
||||||
<Input type="password" placeholder="留空=保持现值" value={finnhubKey} onChange={(e) => setFinnhubKey(e.target.value)} />
|
placeholder="留空表示保持现值"
|
||||||
</div>
|
className="flex-1"
|
||||||
<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)} />
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<div className="flex items-center gap-4">
|
||||||
<CardHeader>
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
<CardTitle>数据库与模板</CardTitle>
|
{saving ? '保存中...' : '保存所有配置'}
|
||||||
<CardDescription>非敏感配置可回显</CardDescription>
|
</Button>
|
||||||
</CardHeader>
|
{saveMessage && <span className="text-sm text-muted-foreground">{saveMessage}</span>}
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,674 +1,56 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
/**
|
import { useState } from 'react';
|
||||||
* 主页组件 - 上市公司基本面分析平台
|
import { useRouter } from 'next/navigation';
|
||||||
*
|
|
||||||
* 功能包括:
|
|
||||||
* - 股票搜索和建议
|
|
||||||
* - 财务数据查询和展示
|
|
||||||
* - 表格行配置管理
|
|
||||||
* - 执行状态显示
|
|
||||||
* - 图表可视化
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Card,
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/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";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function StockInputForm() {
|
||||||
// ============================================================================
|
const [symbol, setSymbol] = useState('');
|
||||||
// 基础状态管理
|
const [market, setMarket] = useState('china');
|
||||||
// ============================================================================
|
const router = useRouter();
|
||||||
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");
|
|
||||||
|
|
||||||
// ============================================================================
|
const handleSearch = () => {
|
||||||
// 数据状态管理
|
if (symbol.trim()) {
|
||||||
// ============================================================================
|
router.push(`/report/${symbol.trim()}?market=${market}`);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理搜索请求
|
|
||||||
*/
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="flex justify-center items-center h-full">
|
||||||
{/* StatusBar Component */}
|
<Card className="w-full max-w-md">
|
||||||
<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>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<CardTitle>基本面分析报告</CardTitle>
|
||||||
<div>
|
<CardDescription>输入股票代码和市场,生成综合分析报告。</CardDescription>
|
||||||
<CardTitle>近10年指标(A股)</CardTitle>
|
</CardHeader>
|
||||||
<CardDescription>数据来自 Tushare,{selected && selected.name ? `${selected.name} (${selected.ts_code})` : selected?.ts_code}</CardDescription>
|
<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>
|
||||||
<div className="mt-1">
|
<div className="space-y-2">
|
||||||
<Select value={chartType} onValueChange={(v) => setChartType(v as ChartType)}>
|
<label>交易市场</label>
|
||||||
<SelectTrigger className="w-40" aria-label="选择图表类型">
|
<Select value={market} onValueChange={setMarket}>
|
||||||
<SelectValue placeholder="选择图表类型" />
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="bar">柱状图</SelectItem>
|
<SelectItem value="china">中国</SelectItem>
|
||||||
<SelectItem value="line">点线图</SelectItem>
|
<SelectItem value="hongkong">香港</SelectItem>
|
||||||
|
<SelectItem value="usa">美国</SelectItem>
|
||||||
|
<SelectItem value="japan">日本</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button onClick={handleSearch} className="w-full">生成报告</Button>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
422
frontend/src/app/report/[symbol]/page.tsx
Normal file
422
frontend/src/app/report/[symbol]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/components/ui/spinner.tsx
Normal file
16
frontend/src/components/ui/spinner.tsx
Normal 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 }
|
||||||
66
frontend/src/components/ui/tabs.tsx
Normal file
66
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||||
52
frontend/src/hooks/useApi.ts
Normal file
52
frontend/src/hooks/useApi.ts
Normal 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次
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/stores/useConfigStore.ts
Normal file
38
frontend/src/stores/useConfigStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
@ -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 {
|
export interface BatchFinancialDataResponse {
|
||||||
/** 股票代码 */
|
/** 股票代码 */
|
||||||
ts_code: string;
|
ts_code: string;
|
||||||
@ -92,6 +111,8 @@ export interface BatchFinancialDataResponse {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** 数据系列 */
|
/** 数据系列 */
|
||||||
series: FinancialDataSeries;
|
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
83
package-lock.json
generated
Normal 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
6
package.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"swr": "^2.3.6",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
121
scripts/dev.sh
Executable file
121
scripts/dev.sh
Executable 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 "$@"
|
||||||
@ -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."
|
|
||||||
@ -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."
|
|
||||||
@ -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."
|
|
||||||
Loading…
Reference in New Issue
Block a user