chore(run.sh): enhance logging and add npm check for frontend setup

This commit is contained in:
xucheng 2025-10-30 16:08:21 +08:00
parent 7d42abea78
commit 515e2d53c9
5 changed files with 779 additions and 1 deletions

1
.gitignore vendored
View File

@ -43,7 +43,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/

View File

@ -0,0 +1,434 @@
/**
* ExecutionStepManager -
*
*
*
*
* -
* -
* -
* -
* -
*
* @author Financial Analysis Platform Team
* @version 1.0.0
*/
// ============================================================================
// 类型定义
// ============================================================================
/**
*
*/
export interface ExecutionStep {
/** 步骤唯一标识符 */
id: string;
/** 步骤显示名称 */
name: string;
/** 步骤详细描述 */
description: string;
/** 执行函数(可选) */
execute?: () => Promise<void>;
}
/**
*
*/
export interface ExecutionOptions {
/** 步骤开始回调 */
onStepStart?: (step: ExecutionStep, index: number, total: number) => void;
/** 步骤完成回调 */
onStepComplete?: (step: ExecutionStep, index: number, total: number) => void;
/** 步骤错误回调 */
onStepError?: (step: ExecutionStep, index: number, total: number, error: Error) => void;
/** 全部完成回调 */
onComplete?: () => void;
/** 执行错误回调 */
onError?: (error: Error) => void;
/** 最大重试次数 */
maxRetries?: number;
/** 重试延迟(毫秒) */
retryDelay?: number;
/** 出错时是否继续执行 */
continueOnError?: boolean;
}
/**
*
*/
export interface ExecutionContext {
/** 当前执行步骤 */
currentStep: ExecutionStep | null;
/** 当前步骤索引 */
stepIndex: number;
/** 总步骤数 */
totalSteps: number;
/** 是否正在运行 */
isRunning: boolean;
/** 是否有错误 */
hasError: boolean;
/** 错误信息 */
errorMessage?: string;
/** 重试次数 */
retryCount: number;
/** 最大重试次数 */
maxRetries: number;
/** 是否可重试 */
canRetry: boolean;
}
export class ExecutionStepManager {
private steps: ExecutionStep[] = [];
private context: ExecutionContext = {
currentStep: null,
stepIndex: 0,
totalSteps: 0,
isRunning: false,
hasError: false,
errorMessage: undefined,
retryCount: 0,
maxRetries: 0,
canRetry: false
};
private options: ExecutionOptions = {};
constructor(steps: ExecutionStep[] = [], options: ExecutionOptions = {}) {
this.steps = [...steps];
this.options = {
maxRetries: 2,
retryDelay: 1000,
continueOnError: false,
...options
};
this.updateContext();
}
/**
*
*/
addStep(step: ExecutionStep): void {
this.steps.push(step);
this.updateContext();
}
/**
*
*/
addSteps(steps: ExecutionStep[]): void {
this.steps.push(...steps);
this.updateContext();
}
/**
*
*/
insertStep(index: number, step: ExecutionStep): void {
this.steps.splice(index, 0, step);
this.updateContext();
}
/**
*
*/
removeStep(stepId: string): boolean {
const index = this.steps.findIndex(step => step.id === stepId);
if (index !== -1) {
this.steps.splice(index, 1);
this.updateContext();
return true;
}
return false;
}
/**
*
*/
clearSteps(): void {
this.steps = [];
this.updateContext();
}
/**
*
*/
getSteps(): ExecutionStep[] {
return [...this.steps];
}
/**
*
*/
getContext(): ExecutionContext {
return { ...this.context };
}
/**
*
*/
setOptions(options: ExecutionOptions): void {
this.options = { ...this.options, ...options };
}
/**
*
*/
async execute(): Promise<void> {
if (this.context.isRunning) {
throw new Error('Execution is already in progress');
}
if (this.steps.length === 0) {
throw new Error('No steps to execute');
}
this.context.isRunning = true;
this.context.hasError = false;
this.context.errorMessage = undefined;
this.context.stepIndex = 0;
this.context.retryCount = 0;
this.context.maxRetries = this.options.maxRetries || 2;
try {
for (let i = 0; i < this.steps.length; i++) {
const step = this.steps[i];
this.context.currentStep = step;
this.context.stepIndex = i;
// 通知步骤开始
this.options.onStepStart?.(step, i, this.steps.length);
let stepSuccess = false;
let lastError: Error | null = null;
// 重试逻辑
for (let retryAttempt = 0; retryAttempt <= this.context.maxRetries; retryAttempt++) {
try {
this.context.retryCount = retryAttempt;
// 如果是重试,等待一段时间
if (retryAttempt > 0 && this.options.retryDelay) {
await new Promise(resolve => setTimeout(resolve, this.options.retryDelay));
}
// 执行步骤(如果有执行函数)
if (step.execute) {
await step.execute();
}
stepSuccess = true;
break; // 成功执行,跳出重试循环
} catch (stepError) {
lastError = stepError instanceof Error ? stepError : new Error(String(stepError));
// 如果还有重试机会,继续重试
if (retryAttempt < this.context.maxRetries) {
console.warn(`Step "${step.name}" failed, retrying (${retryAttempt + 1}/${this.context.maxRetries + 1}):`, lastError.message);
continue;
}
}
}
if (stepSuccess) {
// 通知步骤完成
this.options.onStepComplete?.(step, i, this.steps.length);
} else {
// 所有重试都失败了
const error = lastError || new Error('Step execution failed');
// 更新错误状态
this.context.hasError = true;
this.context.errorMessage = error.message;
this.context.canRetry = true;
// 通知步骤错误
this.options.onStepError?.(step, i, this.steps.length, error);
// 如果不继续执行,抛出错误
if (!this.options.continueOnError) {
throw error;
}
}
}
// 所有步骤执行完成
this.options.onComplete?.();
} catch (error) {
const execError = error instanceof Error ? error : new Error(String(error));
// 通知执行错误
this.options.onError?.(execError);
// 重新抛出错误
throw execError;
} finally {
this.context.isRunning = false;
}
}
/**
*
*/
async executeStep(stepId: string): Promise<void> {
const stepIndex = this.steps.findIndex(step => step.id === stepId);
if (stepIndex === -1) {
throw new Error(`Step with id '${stepId}' not found`);
}
const step = this.steps[stepIndex];
this.context.currentStep = step;
this.context.stepIndex = stepIndex;
this.context.isRunning = true;
this.context.hasError = false;
this.context.errorMessage = undefined;
try {
// 通知步骤开始
this.options.onStepStart?.(step, stepIndex, this.steps.length);
// 执行步骤
if (step.execute) {
await step.execute();
}
// 通知步骤完成
this.options.onStepComplete?.(step, stepIndex, this.steps.length);
} catch (stepError) {
const error = stepError instanceof Error ? stepError : new Error(String(stepError));
// 更新错误状态
this.context.hasError = true;
this.context.errorMessage = error.message;
// 通知步骤错误
this.options.onStepError?.(step, stepIndex, this.steps.length, error);
throw error;
} finally {
this.context.isRunning = false;
}
}
/**
*
*/
stop(): void {
this.context.isRunning = false;
}
/**
*
*/
async retry(): Promise<void> {
if (!this.context.hasError || !this.context.canRetry) {
throw new Error('No failed step to retry');
}
if (this.context.isRunning) {
throw new Error('Execution is already in progress');
}
// 重置错误状态
this.context.hasError = false;
this.context.errorMessage = undefined;
this.context.canRetry = false;
// 重新执行从当前步骤开始
try {
await this.execute();
} catch (error) {
// 错误已经在execute方法中处理
throw error;
}
}
/**
*
*/
reset(): void {
this.context = {
currentStep: null,
stepIndex: 0,
totalSteps: this.steps.length,
isRunning: false,
hasError: false,
errorMessage: undefined,
retryCount: 0,
maxRetries: this.options.maxRetries || 2,
canRetry: false
};
}
/**
*
*/
isRunning(): boolean {
return this.context.isRunning;
}
/**
*
*/
hasError(): boolean {
return this.context.hasError;
}
/**
*
*/
getErrorMessage(): string | undefined {
return this.context.errorMessage;
}
/**
*
*/
canRetry(): boolean {
return this.context.canRetry;
}
/**
*
*/
private updateContext(): void {
this.context.totalSteps = this.steps.length;
this.context.maxRetries = this.options.maxRetries || 2;
if (!this.context.isRunning) {
this.context.stepIndex = 0;
this.context.currentStep = null;
this.context.retryCount = 0;
}
}
/**
*
*/
static createWithSteps(steps: ExecutionStep[], options: ExecutionOptions = {}): ExecutionStepManager {
return new ExecutionStepManager(steps, options);
}
/**
*
*/
static create(options: ExecutionOptions = {}): ExecutionStepManager {
return new ExecutionStepManager([], options);
}
}
/**
*
*/
export const DEFAULT_EXECUTION_STEPS: ExecutionStep[] = [
{
id: 'fetch_financial_data',
name: '正在读取财务数据',
description: '从Tushare API获取公司财务指标数据'
}
];
/**
*
*/
export function createDefaultStepManager(options: ExecutionOptions = {}): ExecutionStepManager {
return ExecutionStepManager.createWithSteps(DEFAULT_EXECUTION_STEPS, options);
}

View File

@ -0,0 +1,326 @@
/**
*
*
*/
import type { MarketType, CompanyInfo, FinancialMetricConfig } from '@/types';
// ============================================================================
// 股票代码处理
// ============================================================================
/**
* A股代码为ts_code格式
* @param input -
* @returns ts_codenull
*
* @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_mvdaily_basic万元-> 亿元,整数(千分位)
if (api === "daily_basic" && metricKey === "total_mv") {
const scaledYi = num / 1e4;
return nf0.format(Math.round(scaledYi));
}
// 员工数 / 股东数 -> 整数(千分位),不做单位换算
if (metricKey === "employees" || metricKey === "holder_num") {
return nf0.format(Math.round(num));
}
// 其他数值 -> 1位小数 + 千分位
return nf1.format(num);
}
/**
*
* @param group -
* @param api - API接口名
* @param metricKey -
* @returns
*/
export function getMetricUnit(group?: string, api?: string, metricKey?: string): string {
// 报表类和市值显示亿元单位
if (
group === "income" ||
group === "balancesheet" ||
group === "cashflow" ||
metricKey === "total_mv"
) {
return "(亿元)";
}
return "";
}
// ============================================================================
// 配置处理
// ============================================================================
/**
* API分组配置
* @param apiGroups - API分组配置对象
* @param groupOrder -
* @returns
*/
export function flattenApiGroups(
apiGroups: Record<string, FinancialMetricConfig[]>,
groupOrder: string[] = ["income", "fina_indicator", "balancesheet", "cashflow", "daily_basic", "daily", "unknown"]
): {
items: FinancialMetricConfig[];
groupMap: Record<string, string>;
apiMap: Record<string, string>;
} {
const items: FinancialMetricConfig[] = [];
const groupMap: Record<string, string> = {};
const apiMap: Record<string, string> = {};
for (const group of groupOrder) {
const arr = Array.isArray(apiGroups[group]) ? apiGroups[group] : [];
for (const item of arr) {
const param = (item?.tushareParam || "").trim();
if (!param) continue;
const api = (item?.api || "").trim();
items.push({
displayText: item.displayText,
tushareParam: param,
api,
group
});
groupMap[param] = group;
apiMap[param] = api;
}
}
return { items, groupMap, apiMap };
}
// ============================================================================
// 错误处理
// ============================================================================
/**
*
* @param error -
* @returns
*/
export function enhanceErrorMessage(error: unknown): string {
const errorMsg = error instanceof Error ? error.message : "未知错误";
// 网络相关错误
if (errorMsg.includes('fetch') || errorMsg.includes('NetworkError')) {
return "网络连接失败,请检查后端服务是否已启动或网络连接是否正常。";
}
if (errorMsg.includes('timeout')) {
return "请求超时,请稍后重试。";
}
// 数据相关错误
if (errorMsg.includes('未查询到数据')) {
return "未查询到数据,请确认代码或稍后重试。";
}
// 服务相关错误
if (errorMsg.includes('500') || errorMsg.includes('Internal Server Error')) {
return "服务器内部错误,请稍后重试或联系管理员。";
}
if (errorMsg.includes('404') || errorMsg.includes('Not Found')) {
return "请求的资源不存在,请检查输入参数。";
}
// 默认错误处理
if (!errorMsg || errorMsg === "未知错误") {
return "查询失败,请检查后端服务是否已启动。";
}
return errorMsg;
}
/**
*
* @param error -
* @returns
*/
export function isRetryableError(error: unknown): boolean {
const errorMsg = error instanceof Error ? error.message : String(error);
return (
errorMsg.includes('网络') ||
errorMsg.includes('连接') ||
errorMsg.includes('超时') ||
errorMsg.includes('timeout') ||
errorMsg.includes('fetch') ||
errorMsg.includes('NetworkError')
);
}
// ============================================================================
// 数据验证
// ============================================================================
/**
*
* @param market -
* @returns
*/
export function isSupportedMarket(market: string): market is MarketType {
return ['cn', 'us', 'hk', 'jp', 'other'].includes(market);
}
/**
*
* @param company -
* @returns
*/
export function isValidCompanyInfo(company: unknown): company is CompanyInfo {
return (
typeof company === 'object' &&
company !== null &&
typeof (company as CompanyInfo).ts_code === 'string' &&
isValidTsCode((company as CompanyInfo).ts_code)
);
}
// ============================================================================
// 本地存储工具
// ============================================================================
/**
* localStorage是否可用
* @returns localStorage是否可用
*/
export function isLocalStorageAvailable(): boolean {
try {
const testKey = '__localStorage_test__';
localStorage.setItem(testKey, 'test');
localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
}
/**
* localStorage获取数据
* @param key -
* @param defaultValue -
* @returns
*/
export function safeGetFromStorage<T>(key: string, defaultValue: T): T {
if (!isLocalStorageAvailable()) {
return defaultValue;
}
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : defaultValue;
} catch {
return defaultValue;
}
}
/**
* localStorage保存数据
* @param key -
* @param value -
* @returns
*/
export function safeSetToStorage(key: string, value: unknown): boolean {
if (!isLocalStorageAvailable()) {
return false;
}
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -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..."