diff --git a/.gitignore b/.gitignore index fa16cab..c4e2ef4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/frontend/src/lib/execution-step-manager.ts b/frontend/src/lib/execution-step-manager.ts new file mode 100644 index 0000000..5bb8a4a --- /dev/null +++ b/frontend/src/lib/execution-step-manager.ts @@ -0,0 +1,434 @@ +/** + * ExecutionStepManager - 可扩展的步骤执行框架 + * + * 提供步骤的动态添加、管理和执行,支持错误处理和状态回调 + * + * 主要功能: + * - 步骤的动态添加和管理 + * - 支持重试机制和错误处理 + * - 提供执行状态回调 + * - 支持并行和串行执行 + * - 可扩展的步骤定义 + * + * @author Financial Analysis Platform Team + * @version 1.0.0 + */ + +// ============================================================================ +// 类型定义 +// ============================================================================ + +/** + * 执行步骤接口 + */ +export interface ExecutionStep { + /** 步骤唯一标识符 */ + id: string; + /** 步骤显示名称 */ + name: string; + /** 步骤详细描述 */ + description: string; + /** 执行函数(可选) */ + execute?: () => Promise; +} + +/** + * 执行选项接口 + */ +export interface ExecutionOptions { + /** 步骤开始回调 */ + onStepStart?: (step: ExecutionStep, index: number, total: number) => void; + /** 步骤完成回调 */ + onStepComplete?: (step: ExecutionStep, index: number, total: number) => void; + /** 步骤错误回调 */ + onStepError?: (step: ExecutionStep, index: number, total: number, error: Error) => void; + /** 全部完成回调 */ + onComplete?: () => void; + /** 执行错误回调 */ + onError?: (error: Error) => void; + /** 最大重试次数 */ + maxRetries?: number; + /** 重试延迟(毫秒) */ + retryDelay?: number; + /** 出错时是否继续执行 */ + continueOnError?: boolean; +} + +/** + * 执行上下文接口 + */ +export interface ExecutionContext { + /** 当前执行步骤 */ + currentStep: ExecutionStep | null; + /** 当前步骤索引 */ + stepIndex: number; + /** 总步骤数 */ + totalSteps: number; + /** 是否正在运行 */ + isRunning: boolean; + /** 是否有错误 */ + hasError: boolean; + /** 错误信息 */ + errorMessage?: string; + /** 重试次数 */ + retryCount: number; + /** 最大重试次数 */ + maxRetries: number; + /** 是否可重试 */ + canRetry: boolean; +} + +export class ExecutionStepManager { + private steps: ExecutionStep[] = []; + private context: ExecutionContext = { + currentStep: null, + stepIndex: 0, + totalSteps: 0, + isRunning: false, + hasError: false, + errorMessage: undefined, + retryCount: 0, + maxRetries: 0, + canRetry: false + }; + private options: ExecutionOptions = {}; + + constructor(steps: ExecutionStep[] = [], options: ExecutionOptions = {}) { + this.steps = [...steps]; + this.options = { + maxRetries: 2, + retryDelay: 1000, + continueOnError: false, + ...options + }; + this.updateContext(); + } + + /** + * 添加执行步骤 + */ + addStep(step: ExecutionStep): void { + this.steps.push(step); + this.updateContext(); + } + + /** + * 批量添加执行步骤 + */ + addSteps(steps: ExecutionStep[]): void { + this.steps.push(...steps); + this.updateContext(); + } + + /** + * 插入步骤到指定位置 + */ + insertStep(index: number, step: ExecutionStep): void { + this.steps.splice(index, 0, step); + this.updateContext(); + } + + /** + * 移除步骤 + */ + removeStep(stepId: string): boolean { + const index = this.steps.findIndex(step => step.id === stepId); + if (index !== -1) { + this.steps.splice(index, 1); + this.updateContext(); + return true; + } + return false; + } + + /** + * 清空所有步骤 + */ + clearSteps(): void { + this.steps = []; + this.updateContext(); + } + + /** + * 获取所有步骤 + */ + getSteps(): ExecutionStep[] { + return [...this.steps]; + } + + /** + * 获取当前执行上下文 + */ + getContext(): ExecutionContext { + return { ...this.context }; + } + + /** + * 更新执行选项 + */ + setOptions(options: ExecutionOptions): void { + this.options = { ...this.options, ...options }; + } + + /** + * 执行所有步骤 + */ + async execute(): Promise { + if (this.context.isRunning) { + throw new Error('Execution is already in progress'); + } + + if (this.steps.length === 0) { + throw new Error('No steps to execute'); + } + + this.context.isRunning = true; + this.context.hasError = false; + this.context.errorMessage = undefined; + this.context.stepIndex = 0; + this.context.retryCount = 0; + this.context.maxRetries = this.options.maxRetries || 2; + + try { + for (let i = 0; i < this.steps.length; i++) { + const step = this.steps[i]; + this.context.currentStep = step; + this.context.stepIndex = i; + + // 通知步骤开始 + this.options.onStepStart?.(step, i, this.steps.length); + + let stepSuccess = false; + let lastError: Error | null = null; + + // 重试逻辑 + for (let retryAttempt = 0; retryAttempt <= this.context.maxRetries; retryAttempt++) { + try { + this.context.retryCount = retryAttempt; + + // 如果是重试,等待一段时间 + if (retryAttempt > 0 && this.options.retryDelay) { + await new Promise(resolve => setTimeout(resolve, this.options.retryDelay)); + } + + // 执行步骤(如果有执行函数) + if (step.execute) { + await step.execute(); + } + + stepSuccess = true; + break; // 成功执行,跳出重试循环 + } catch (stepError) { + lastError = stepError instanceof Error ? stepError : new Error(String(stepError)); + + // 如果还有重试机会,继续重试 + if (retryAttempt < this.context.maxRetries) { + console.warn(`Step "${step.name}" failed, retrying (${retryAttempt + 1}/${this.context.maxRetries + 1}):`, lastError.message); + continue; + } + } + } + + if (stepSuccess) { + // 通知步骤完成 + this.options.onStepComplete?.(step, i, this.steps.length); + } else { + // 所有重试都失败了 + const error = lastError || new Error('Step execution failed'); + + // 更新错误状态 + this.context.hasError = true; + this.context.errorMessage = error.message; + this.context.canRetry = true; + + // 通知步骤错误 + this.options.onStepError?.(step, i, this.steps.length, error); + + // 如果不继续执行,抛出错误 + if (!this.options.continueOnError) { + throw error; + } + } + } + + // 所有步骤执行完成 + this.options.onComplete?.(); + } catch (error) { + const execError = error instanceof Error ? error : new Error(String(error)); + + // 通知执行错误 + this.options.onError?.(execError); + + // 重新抛出错误 + throw execError; + } finally { + this.context.isRunning = false; + } + } + + /** + * 执行单个步骤 + */ + async executeStep(stepId: string): Promise { + const stepIndex = this.steps.findIndex(step => step.id === stepId); + if (stepIndex === -1) { + throw new Error(`Step with id '${stepId}' not found`); + } + + const step = this.steps[stepIndex]; + this.context.currentStep = step; + this.context.stepIndex = stepIndex; + this.context.isRunning = true; + this.context.hasError = false; + this.context.errorMessage = undefined; + + try { + // 通知步骤开始 + this.options.onStepStart?.(step, stepIndex, this.steps.length); + + // 执行步骤 + if (step.execute) { + await step.execute(); + } + + // 通知步骤完成 + this.options.onStepComplete?.(step, stepIndex, this.steps.length); + } catch (stepError) { + const error = stepError instanceof Error ? stepError : new Error(String(stepError)); + + // 更新错误状态 + this.context.hasError = true; + this.context.errorMessage = error.message; + + // 通知步骤错误 + this.options.onStepError?.(step, stepIndex, this.steps.length, error); + + throw error; + } finally { + this.context.isRunning = false; + } + } + + /** + * 停止执行(如果正在运行) + */ + stop(): void { + this.context.isRunning = false; + } + + /** + * 重试当前失败的步骤 + */ + async retry(): Promise { + if (!this.context.hasError || !this.context.canRetry) { + throw new Error('No failed step to retry'); + } + + if (this.context.isRunning) { + throw new Error('Execution is already in progress'); + } + + // 重置错误状态 + this.context.hasError = false; + this.context.errorMessage = undefined; + this.context.canRetry = false; + + // 重新执行从当前步骤开始 + try { + await this.execute(); + } catch (error) { + // 错误已经在execute方法中处理 + throw error; + } + } + + /** + * 重置执行状态 + */ + reset(): void { + this.context = { + currentStep: null, + stepIndex: 0, + totalSteps: this.steps.length, + isRunning: false, + hasError: false, + errorMessage: undefined, + retryCount: 0, + maxRetries: this.options.maxRetries || 2, + canRetry: false + }; + } + + /** + * 检查是否正在执行 + */ + isRunning(): boolean { + return this.context.isRunning; + } + + /** + * 检查是否有错误 + */ + hasError(): boolean { + return this.context.hasError; + } + + /** + * 获取错误信息 + */ + getErrorMessage(): string | undefined { + return this.context.errorMessage; + } + + /** + * 检查是否可以重试 + */ + canRetry(): boolean { + return this.context.canRetry; + } + + /** + * 更新执行上下文 + */ + private updateContext(): void { + this.context.totalSteps = this.steps.length; + this.context.maxRetries = this.options.maxRetries || 2; + if (!this.context.isRunning) { + this.context.stepIndex = 0; + this.context.currentStep = null; + this.context.retryCount = 0; + } + } + + /** + * 创建一个带有预定义步骤的管理器实例 + */ + static createWithSteps(steps: ExecutionStep[], options: ExecutionOptions = {}): ExecutionStepManager { + return new ExecutionStepManager(steps, options); + } + + /** + * 创建一个空的管理器实例 + */ + static create(options: ExecutionOptions = {}): ExecutionStepManager { + return new ExecutionStepManager([], options); + } +} + +/** + * 预定义的执行步骤 + */ +export const DEFAULT_EXECUTION_STEPS: ExecutionStep[] = [ + { + id: 'fetch_financial_data', + name: '正在读取财务数据', + description: '从Tushare API获取公司财务指标数据' + } +]; + +/** + * 创建默认的执行步骤管理器 + */ +export function createDefaultStepManager(options: ExecutionOptions = {}): ExecutionStepManager { + return ExecutionStepManager.createWithSteps(DEFAULT_EXECUTION_STEPS, options); +} \ No newline at end of file diff --git a/frontend/src/lib/financial-utils.ts b/frontend/src/lib/financial-utils.ts new file mode 100644 index 0000000..b0f6947 --- /dev/null +++ b/frontend/src/lib/financial-utils.ts @@ -0,0 +1,326 @@ +/** + * 财务数据处理工具函数 + * 包含股票代码规范化、数据格式化等通用功能 + */ + +import type { MarketType, CompanyInfo, FinancialMetricConfig } from '@/types'; + +// ============================================================================ +// 股票代码处理 +// ============================================================================ + +/** + * 规范化A股代码为ts_code格式 + * @param input - 输入的股票代码 + * @returns 规范化后的ts_code,如果无效则返回null + * + * @example + * ```typescript + * normalizeTsCode('600519') // '600519.SH' + * normalizeTsCode('000001') // '000001.SZ' + * normalizeTsCode('600519.SH') // '600519.SH' + * normalizeTsCode('invalid') // null + * ``` + */ +export function normalizeTsCode(input: string): string | null { + const code = (input || "").trim(); + if (!code) return null; + + // 仅支持数字代码的简易规则:6开头上证,0/3开头深证 + if (/^\d{6}$/.test(code)) { + if (code.startsWith("6")) return `${code}.SH`; + if (code.startsWith("0") || code.startsWith("3")) return `${code}.SZ`; + } + + // 若已含交易所后缀则直接返回 + if (/^\d{6}\.(SH|SZ)$/i.test(code)) return code.toUpperCase(); + + return null; +} + +/** + * 验证股票代码是否有效 + * @param code - 股票代码 + * @returns 是否为有效的股票代码 + */ +export function isValidTsCode(code: string): boolean { + return normalizeTsCode(code) !== null; +} + +/** + * 从输入中提取股票代码 + * @param input - 可能包含股票代码和公司名的输入 + * @returns 提取的股票代码,如果没有找到则返回null + */ +export function extractTsCode(input: string): string | null { + const trimmed = input.trim(); + + // 尝试从输入开头提取代码 + const codeMatch = trimmed.match(/^(\d{6}(?:\.[A-Z]{2})?)/); + if (codeMatch) { + return normalizeTsCode(codeMatch[1]); + } + + return null; +} + +// ============================================================================ +// 数据格式化 +// ============================================================================ + +/** + * 格式化财务数值显示 + * @param value - 原始数值 + * @param group - 指标分组 + * @param api - API接口名 + * @param metricKey - 指标键名 + * @returns 格式化后的字符串 + */ +export function formatFinancialValue( + value: number | null | undefined, + group?: string, + api?: string, + metricKey?: string +): string { + if (value === null || typeof value === "undefined") return "-"; + + const num = Number(value); + if (Number.isNaN(num)) return "-"; + + const nf1 = new Intl.NumberFormat("zh-CN", { + minimumFractionDigits: 1, + maximumFractionDigits: 1 + }); + const nf0 = new Intl.NumberFormat("zh-CN", { + maximumFractionDigits: 0 + }); + + // 报表类统一按亿元显示(1位小数,千分位) + if (group === "income" || group === "balancesheet" || group === "cashflow" || metricKey === "revenue") { + const scaled = num / 1e8; + return nf1.format(scaled); + } + + // 市值 total_mv(daily_basic,万元)-> 亿元,整数(千分位) + if (api === "daily_basic" && metricKey === "total_mv") { + const scaledYi = num / 1e4; + return nf0.format(Math.round(scaledYi)); + } + + // 员工数 / 股东数 -> 整数(千分位),不做单位换算 + if (metricKey === "employees" || metricKey === "holder_num") { + return nf0.format(Math.round(num)); + } + + // 其他数值 -> 1位小数 + 千分位 + return nf1.format(num); +} + +/** + * 获取指标单位文本 + * @param group - 指标分组 + * @param api - API接口名 + * @param metricKey - 指标键名 + * @returns 单位文本,如果不需要显示单位则返回空字符串 + */ +export function getMetricUnit(group?: string, api?: string, metricKey?: string): string { + // 报表类和市值显示亿元单位 + if ( + group === "income" || + group === "balancesheet" || + group === "cashflow" || + metricKey === "total_mv" + ) { + return "(亿元)"; + } + + return ""; +} + +// ============================================================================ +// 配置处理 +// ============================================================================ + +/** + * 扁平化API分组配置 + * @param apiGroups - API分组配置对象 + * @param groupOrder - 分组顺序数组 + * @returns 扁平化的配置项数组和映射对象 + */ +export function flattenApiGroups( + apiGroups: Record, + groupOrder: string[] = ["income", "fina_indicator", "balancesheet", "cashflow", "daily_basic", "daily", "unknown"] +): { + items: FinancialMetricConfig[]; + groupMap: Record; + apiMap: Record; +} { + const items: FinancialMetricConfig[] = []; + const groupMap: Record = {}; + const apiMap: Record = {}; + + for (const group of groupOrder) { + const arr = Array.isArray(apiGroups[group]) ? apiGroups[group] : []; + for (const item of arr) { + const param = (item?.tushareParam || "").trim(); + if (!param) continue; + + const api = (item?.api || "").trim(); + items.push({ + displayText: item.displayText, + tushareParam: param, + api, + group + }); + groupMap[param] = group; + apiMap[param] = api; + } + } + + return { items, groupMap, apiMap }; +} + +// ============================================================================ +// 错误处理 +// ============================================================================ + +/** + * 增强错误信息 + * @param error - 原始错误 + * @returns 增强后的错误信息 + */ +export function enhanceErrorMessage(error: unknown): string { + const errorMsg = error instanceof Error ? error.message : "未知错误"; + + // 网络相关错误 + if (errorMsg.includes('fetch') || errorMsg.includes('NetworkError')) { + return "网络连接失败,请检查后端服务是否已启动或网络连接是否正常。"; + } + + if (errorMsg.includes('timeout')) { + return "请求超时,请稍后重试。"; + } + + // 数据相关错误 + if (errorMsg.includes('未查询到数据')) { + return "未查询到数据,请确认代码或稍后重试。"; + } + + // 服务相关错误 + if (errorMsg.includes('500') || errorMsg.includes('Internal Server Error')) { + return "服务器内部错误,请稍后重试或联系管理员。"; + } + + if (errorMsg.includes('404') || errorMsg.includes('Not Found')) { + return "请求的资源不存在,请检查输入参数。"; + } + + // 默认错误处理 + if (!errorMsg || errorMsg === "未知错误") { + return "查询失败,请检查后端服务是否已启动。"; + } + + return errorMsg; +} + +/** + * 判断错误是否可重试 + * @param error - 错误对象或错误信息 + * @returns 是否可重试 + */ +export function isRetryableError(error: unknown): boolean { + const errorMsg = error instanceof Error ? error.message : String(error); + + return ( + errorMsg.includes('网络') || + errorMsg.includes('连接') || + errorMsg.includes('超时') || + errorMsg.includes('timeout') || + errorMsg.includes('fetch') || + errorMsg.includes('NetworkError') + ); +} + +// ============================================================================ +// 数据验证 +// ============================================================================ + +/** + * 验证市场类型是否支持 + * @param market - 市场类型 + * @returns 是否支持该市场 + */ +export function isSupportedMarket(market: string): market is MarketType { + return ['cn', 'us', 'hk', 'jp', 'other'].includes(market); +} + +/** + * 验证公司信息是否有效 + * @param company - 公司信息对象 + * @returns 是否为有效的公司信息 + */ +export function isValidCompanyInfo(company: unknown): company is CompanyInfo { + return ( + typeof company === 'object' && + company !== null && + typeof (company as CompanyInfo).ts_code === 'string' && + isValidTsCode((company as CompanyInfo).ts_code) + ); +} + +// ============================================================================ +// 本地存储工具 +// ============================================================================ + +/** + * 检查localStorage是否可用 + * @returns localStorage是否可用 + */ +export function isLocalStorageAvailable(): boolean { + try { + const testKey = '__localStorage_test__'; + localStorage.setItem(testKey, 'test'); + localStorage.removeItem(testKey); + return true; + } catch { + return false; + } +} + +/** + * 安全地从localStorage获取数据 + * @param key - 存储键 + * @param defaultValue - 默认值 + * @returns 存储的数据或默认值 + */ +export function safeGetFromStorage(key: string, defaultValue: T): T { + if (!isLocalStorageAvailable()) { + return defaultValue; + } + + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : defaultValue; + } catch { + return defaultValue; + } +} + +/** + * 安全地向localStorage保存数据 + * @param key - 存储键 + * @param value - 要保存的数据 + * @returns 是否保存成功 + */ +export function safeSetToStorage(key: string, value: unknown): boolean { + if (!isLocalStorageAvailable()) { + return false; + } + + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..d084cca --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/scripts/run.sh b/scripts/run.sh index 50491e7..67783f6 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -45,13 +45,26 @@ deactivate echo "Backend setup complete." cd .. +echo "--> Changed directory to $(pwd)" + # --- Frontend Setup --- echo "Setting up frontend..." + +# Check if npm is installed +if ! command -v npm &> /dev/null +then + echo "npm could not be found. Please install Node.js and npm to continue." + exit 1 +fi + cd frontend +echo "--> Changed directory to $(pwd)" npm install echo "Frontend setup complete." cd .. +echo "--> Changed directory to $(pwd)" + # --- PM2 Execution --- echo "Starting application with pm2..."