feat(analysis): integrate analysis templates into report workflow
- Backend: Add template_id to DataRequest and AnalysisResult metadata - Frontend: Add TemplateSelector to Report page - Frontend: Refactor ReportPage to use AnalysisTemplateSets for dynamic tabs - Frontend: Strict mode for report rendering based on template_id - Cleanup: Remove legacy analysis config hooks
This commit is contained in:
parent
c8be576526
commit
e855a16c69
@ -0,0 +1,130 @@
|
||||
# 分析模板集成设计文档
|
||||
|
||||
## 1. 概述
|
||||
系统正在从单一、固定的分析配置架构向多模板架构迁移。目前,后端已支持 `AnalysisTemplateSets` 并能执行特定的模板。然而,前端在渲染报告标签页(Tabs)和触发分析时,仍然依赖于过时的 `AnalysisModulesConfig`(单一配置集)。
|
||||
|
||||
本文档概述了将“分析模板”完全集成到用户工作流中所需的变更,具体包括:
|
||||
1. **触发分析**:在启动新的分析任务时选择特定的模板。
|
||||
2. **报告展示**:根据分析所使用的模板,动态渲染标签页和内容。
|
||||
|
||||
## 2. 当前状态 vs. 目标状态
|
||||
|
||||
| 功能特性 | 当前状态 | 目标状态 |
|
||||
| :--- | :--- | :--- |
|
||||
| **配置管理** | `useAnalysisConfig` (过时,单一模块列表) | `useAnalysisTemplateSets` (多套具名模板) |
|
||||
| **触发分析** | `trigger(symbol, market)` (无模板选择) | `trigger(symbol, market, templateId)` |
|
||||
| **报告标签页** | 硬编码遍历过时的 `analysis_modules` keys | 根据报告使用的**特定模板**动态生成标签页 |
|
||||
| **模块名称** | 从全局默认配置获取 | 从所使用的特定模板配置中获取 |
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1. 后端变更
|
||||
|
||||
#### 3.1.1. API Gateway (`api-gateway`)
|
||||
* **Endpoint**: `POST /api/data-requests`
|
||||
* **变更**: 更新请求 DTO (Data Transfer Object) 以接收可选参数 `template_id: String`。
|
||||
* **逻辑**: 将此 `template_id` 通过 `GenerateReportCommand` 向下传递给 `report-generator-service`。
|
||||
|
||||
#### 3.1.2. 数据持久化 / 报告数据
|
||||
* **需求**: 前端需要知道生成某份报告时具体使用了*哪个*模板,以便正确渲染标签页(包括标题和顺序)。
|
||||
* **变更**: 确保 `GET /api/financials/...` 或 `GET /api/reports/...` 的响应数据中,在 metadata 中包含 `template_id`。
|
||||
* **实现**: 前端 `route.ts` 聚合层通过查询 `analysis-results` 获取最新的 `template_id` 并注入到 `meta` 中。
|
||||
|
||||
### 3.2. 前端变更
|
||||
|
||||
#### 3.2.1. API Hooks (`useApi.ts`)
|
||||
* **`useDataRequest`**: 更新 `trigger` 函数签名:
|
||||
```typescript
|
||||
trigger(symbol: string, market: string, templateId?: string)
|
||||
```
|
||||
* **`useAnalysisTemplateSets`**: 确保此 hook 可用(目前代码中已存在)。
|
||||
|
||||
#### 3.2.2. 触发 UI (报告页侧边栏 / 查询页)
|
||||
* **组件**: 在“触发分析”按钮旁增加一个 `TemplateSelector` (选择框/下拉菜单)。
|
||||
* **数据源**: `useAnalysisTemplateSets`。
|
||||
* **默认值**: 自动选中第一个可用的模板,或者标记为 "default" 的模板。
|
||||
|
||||
#### 3.2.3. 报告页面 (`frontend/src/app/report/[symbol]/page.tsx`)
|
||||
这是最复杂的变更部分。我们需要重构标签页(Tabs)的生成逻辑。
|
||||
|
||||
1. **移除旧逻辑**:
|
||||
* 移除对 `useAnalysisConfig` (全局默认配置) 的依赖。
|
||||
* 弃用/移除 `runAnalysesSequentially` (旧的前端编排流程)。
|
||||
|
||||
2. **识别模板**:
|
||||
* 从获取到的财务/报告数据中读取 `template_id` (例如 `financials.meta.template_id` 或类似位置)。
|
||||
* **Strict Mode**: 如果缺失 `template_id`,则视为严重数据错误,前端直接报错停止渲染,**绝不进行默认值回退或自动推断**。
|
||||
|
||||
3. **动态标签页**:
|
||||
* 使用 `useAnalysisTemplateSets` 获取 `templateSets`。
|
||||
* 从 `templateSets[currentTemplateId].modules` 中推导出 `activeModules` 列表。
|
||||
* 遍历 `activeModules` 来生成 `TabsTrigger` 和 `TabsContent`。
|
||||
* **显示名称**: 使用 `moduleConfig.name`。
|
||||
* **排序**: 严格遵循模板中定义的顺序(或依赖顺序)。
|
||||
|
||||
### 3.3. 数据流
|
||||
|
||||
1. **用户**选择 "标准分析模板 V2" (Standard Analysis V2) 并点击 "运行"。
|
||||
2. **前端**调用 `POST /api/data-requests`,载荷为 `{ ..., template_id: "standard_v2" }`。
|
||||
3. **后端**使用 "standard_v2" 中定义的模块生成报告。
|
||||
4. **前端**轮询任务进度。
|
||||
5. **前端**获取完成的数据。数据包含元数据 `meta: { template_id: "standard_v2" }`。
|
||||
6. **前端**查询 "standard_v2" 的配置详情。
|
||||
7. **前端**渲染标签页:如 "公司简介"、"财务健康"(均来自 V2 配置)。
|
||||
|
||||
## 4. 实施步骤
|
||||
|
||||
1. **后端更新**:
|
||||
* 验证 `api-gateway` 是否正确传递 `template_id`。
|
||||
* 验证报告 API 是否在 metadata 中返回 `template_id`。
|
||||
|
||||
2. **前端 - 触发**:
|
||||
* 更新 `useDataRequest` hook。
|
||||
* 在 `ReportPage` 中添加 `TemplateSelector` 组件。
|
||||
|
||||
3. **前端 - 展示**:
|
||||
* 重构 `ReportPage` 以使用 `templateSets`。
|
||||
* 根据报告中的 `template_id` 动态计算 `analysisTypes`。
|
||||
|
||||
## 5. 待办事项列表 (To-Do List)
|
||||
|
||||
### Phase 1: 后端与接口 (Backend & API)
|
||||
- [x] **1.1 更新请求 DTO (api-gateway)**
|
||||
- 目标: `api-gateway` 的 `DataRequest` 结构体
|
||||
- 动作: 增加 `template_id` 字段 (Option<String>)
|
||||
- 验证: `curl` 请求带 `template_id` 能被解析
|
||||
- [x] **1.2 传递 Command (api-gateway -> report-service)**
|
||||
- 目标: `GenerateReportCommand` 消息
|
||||
- 动作: 确保 `template_id` 被正确透传到消息队列或服务调用中
|
||||
- [x] **1.3 验证报告元数据 (data-persistence)**
|
||||
- 目标: `GET /api/financials/...` 接口
|
||||
- 动作: 检查返回的 JSON 中 `meta` 字段是否包含 `template_id`
|
||||
- 备注: 已通过 frontend `route.ts` 聚合实现
|
||||
|
||||
### Phase 2: 前端逻辑 (Frontend Logic)
|
||||
- [x] **2.1 更新 API Hook**
|
||||
- 文件: `frontend/src/hooks/useApi.ts`
|
||||
- 动作: 修改 `useDataRequest` 的 `trigger` 方法签名,支持 `templateId` 参数
|
||||
- [x] **2.2 移除旧版依赖**
|
||||
- 文件: `frontend/src/app/report/[symbol]/page.tsx`
|
||||
- 动作: 移除 `useAnalysisConfig` 及相关旧版逻辑 (`runAnalysesSequentially`)
|
||||
|
||||
### Phase 3: 前端界面 (Frontend UI)
|
||||
- [x] **3.1 实现模板选择器**
|
||||
- 文件: `frontend/src/app/report/[symbol]/page.tsx` (侧边栏)
|
||||
- 动作: 添加 `<Select>` 组件,数据源为 `useAnalysisTemplateSets`
|
||||
- 逻辑: 默认选中第一个模板,点击"触发分析"时传递选中的 ID
|
||||
- [x] **3.2 动态渲染标签页**
|
||||
- 文件: `frontend/src/app/report/[symbol]/page.tsx` (主区域)
|
||||
- 动作:
|
||||
1. 从 `financials.meta.template_id` 获取当前报告的模板 ID
|
||||
2. 若 ID 缺失直接抛出错误 (Strict Mode)
|
||||
3. 根据 ID 从 `templateSets` 获取模块列表
|
||||
4. 遍历模块列表渲染 `<TabsTrigger>` 和 `<TabsContent>`
|
||||
5. 内容从 `useAnalysisResults` hook 获取
|
||||
|
||||
### Phase 4: 验证与清理 (Verification)
|
||||
- [ ] **4.1 端到端测试**
|
||||
- 动作: 创建新模板 -> 选择该模板触发分析 -> 验证报告页只显示该模板定义的模块
|
||||
- [x] **4.2 代码清理**
|
||||
- 动作: 删除未使用的旧版配置 Hook 和类型定义
|
||||
@ -13,6 +13,82 @@ export async function GET(
|
||||
const url = new URL(req.url);
|
||||
const { slug } = await context.params;
|
||||
const first = slug?.[0];
|
||||
|
||||
// 1. Match /api/financials/{market}/{symbol}
|
||||
// slug[0] = market (e.g., "cn" or "us")
|
||||
// slug[1] = symbol (e.g., "600519" or "AAPL")
|
||||
if (slug.length === 2 && first !== 'analysis-config' && first !== 'config') {
|
||||
const market = slug[0];
|
||||
const symbol = slug[1];
|
||||
const years = url.searchParams.get('years') || '10';
|
||||
|
||||
// Fetch financials from backend
|
||||
const finResp = await fetch(
|
||||
`${BACKEND_BASE}/market-data/financial-statements/${encodeURIComponent(symbol)}?metrics=${encodeURIComponent('')}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!finResp.ok) {
|
||||
if (finResp.status === 404) {
|
||||
return Response.json({}, { status: 200 }); // Return empty for now to not break UI
|
||||
}
|
||||
return new Response(finResp.statusText, { status: finResp.status });
|
||||
}
|
||||
|
||||
const series = await finResp.json();
|
||||
|
||||
// Transform to frontend expected format (BatchFinancialDataResponse)
|
||||
// We group by metric_name
|
||||
const groupedSeries: Record<string, any[]> = {};
|
||||
series.forEach((item: any) => {
|
||||
if (!groupedSeries[item.metric_name]) {
|
||||
groupedSeries[item.metric_name] = [];
|
||||
}
|
||||
groupedSeries[item.metric_name].push({
|
||||
period: item.period_date ? item.period_date.replace(/-/g, '') : null, // YYYY-MM-DD -> YYYYMMDD
|
||||
value: item.value,
|
||||
source: item.source
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch Company Profile to populate name/industry
|
||||
const profileResp = await fetch(`${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`, { cache: 'no-store' });
|
||||
let profileData: any = {};
|
||||
if (profileResp.ok) {
|
||||
profileData = await profileResp.json();
|
||||
}
|
||||
|
||||
// Fetch Latest Analysis Result Metadata (to get template_id)
|
||||
// We search for the most recent analysis result for this symbol
|
||||
const analysisResp = await fetch(`${BACKEND_BASE}/analysis-results?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' });
|
||||
let meta: any = {
|
||||
symbol: symbol,
|
||||
generated_at: new Date().toISOString(), // Fallback
|
||||
template_id: null // Explicitly null if not found
|
||||
};
|
||||
|
||||
if (analysisResp.ok) {
|
||||
const analysisList = await analysisResp.json();
|
||||
if (Array.isArray(analysisList) && analysisList.length > 0) {
|
||||
// Sort by created_at desc (backend should already do this, but to be safe)
|
||||
// Backend returns sorted by created_at DESC
|
||||
const latest = analysisList[0];
|
||||
meta.template_id = latest.template_id || null;
|
||||
meta.generated_at = latest.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
const responsePayload = {
|
||||
name: profileData.name || symbol,
|
||||
symbol: symbol,
|
||||
market: market,
|
||||
series: groupedSeries,
|
||||
meta: meta
|
||||
};
|
||||
|
||||
return Response.json(responsePayload);
|
||||
}
|
||||
|
||||
// 适配旧接口:analysis-config → 新分析模块配置
|
||||
if (first === 'analysis-config') {
|
||||
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' });
|
||||
|
||||
@ -1,208 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
useLlmProviders,
|
||||
updateLlmProviders,
|
||||
discoverProviderModels
|
||||
} from '@/hooks/useApi';
|
||||
import { LlmProvidersConfig, LlmModel } from '@/types';
|
||||
|
||||
export function useAiConfig() {
|
||||
const { data: llmProviders, mutate: mutateLlmProviders } = useLlmProviders();
|
||||
const [localLlmProviders, setLocalLlmProviders] = useState<LlmProvidersConfig>({});
|
||||
|
||||
const [isSavingLlm, setIsSavingLlm] = useState(false);
|
||||
const [llmSaveMessage, setLlmSaveMessage] = useState('');
|
||||
|
||||
// New Provider Form
|
||||
const [newProviderId, setNewProviderId] = useState('');
|
||||
const [newProviderBaseUrl, setNewProviderBaseUrl] = useState('');
|
||||
const [newProviderApiKey, setNewProviderApiKey] = useState('');
|
||||
|
||||
// Provider states
|
||||
const [pendingApiKeys, setPendingApiKeys] = useState<Record<string, string>>({});
|
||||
const [editingApiKey, setEditingApiKey] = useState<Record<string, boolean>>({});
|
||||
const [discoverMessages, setDiscoverMessages] = useState<Record<string, string>>({});
|
||||
|
||||
// Model management states
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState<Record<string, boolean>>({});
|
||||
const [candidateModels, setCandidateModels] = useState<Record<string, string[]>>({});
|
||||
const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
|
||||
const [selectedCandidates, setSelectedCandidates] = useState<Record<string, Record<string, boolean>>>({});
|
||||
|
||||
// New model manual input states
|
||||
const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
|
||||
const [newModelNameInputs, setNewModelNameInputs] = useState<Record<string, string>>({});
|
||||
const [newModelMenuOpen, setNewModelMenuOpen] = useState<Record<string, boolean>>({});
|
||||
const [newModelHighlightIndex, setNewModelHighlightIndex] = useState<Record<string, number>>({});
|
||||
|
||||
// Auto-save refs
|
||||
const hasInitializedLlmRef = useRef(false);
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savingStartedAtRef = useRef<number>(0);
|
||||
const llmDirtyRef = useRef(false);
|
||||
const latestServerPayloadRef = useRef<string>('');
|
||||
const lastSavedPayloadRef = useRef<string>('');
|
||||
|
||||
const markLlmDirty = useCallback(() => {
|
||||
llmDirtyRef.current = true;
|
||||
}, []);
|
||||
|
||||
const normalizeProviders = useCallback((obj: LlmProvidersConfig) => {
|
||||
const cloned: LlmProvidersConfig = JSON.parse(JSON.stringify(obj || {}));
|
||||
Object.keys(cloned).forEach(pid => {
|
||||
if (!cloned[pid].name || cloned[pid].name.trim().length === 0) {
|
||||
cloned[pid].name = pid;
|
||||
}
|
||||
});
|
||||
return cloned;
|
||||
}, []);
|
||||
|
||||
const buildMergedLlmPayload = useCallback(() => {
|
||||
const merged: LlmProvidersConfig = normalizeProviders(localLlmProviders || {});
|
||||
// 待更新的 API Key 覆盖
|
||||
Object.entries(pendingApiKeys || {}).forEach(([pid, key]) => {
|
||||
if (merged[pid]) merged[pid].api_key = key;
|
||||
});
|
||||
return merged;
|
||||
}, [localLlmProviders, pendingApiKeys, normalizeProviders]);
|
||||
|
||||
const flushSaveLlmImmediate = useCallback(async () => {
|
||||
const payload = buildMergedLlmPayload();
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
if (payloadStr === latestServerPayloadRef.current || payloadStr === lastSavedPayloadRef.current) {
|
||||
return;
|
||||
}
|
||||
savingStartedAtRef.current = Date.now();
|
||||
setIsSavingLlm(true);
|
||||
setLlmSaveMessage('自动保存中...');
|
||||
try {
|
||||
const updated = await updateLlmProviders(payload);
|
||||
await mutateLlmProviders(updated, false);
|
||||
lastSavedPayloadRef.current = payloadStr;
|
||||
llmDirtyRef.current = false;
|
||||
setPendingApiKeys({});
|
||||
setEditingApiKey({});
|
||||
setLlmSaveMessage('已自动保存');
|
||||
} catch (e: any) {
|
||||
setLlmSaveMessage(`保存失败: ${e?.message || '未知错误'}`);
|
||||
} finally {
|
||||
const elapsed = Date.now() - (savingStartedAtRef.current || 0);
|
||||
const minMs = 1000;
|
||||
const waitMs = elapsed >= minMs ? 0 : (minMs - elapsed);
|
||||
if (waitMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
setIsSavingLlm(false);
|
||||
setTimeout(() => setLlmSaveMessage(''), 3000);
|
||||
}
|
||||
}, [buildMergedLlmPayload, mutateLlmProviders, setPendingApiKeys, setEditingApiKey]);
|
||||
|
||||
// Sync with server data initially
|
||||
useEffect(() => {
|
||||
if (llmProviders) {
|
||||
setLocalLlmProviders(llmProviders);
|
||||
const normalized = normalizeProviders(llmProviders);
|
||||
latestServerPayloadRef.current = JSON.stringify(normalized);
|
||||
llmDirtyRef.current = false;
|
||||
}
|
||||
}, [llmProviders, normalizeProviders]);
|
||||
|
||||
// Auto-save effect
|
||||
useEffect(() => {
|
||||
if (!hasInitializedLlmRef.current) {
|
||||
hasInitializedLlmRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!llmDirtyRef.current) {
|
||||
return;
|
||||
}
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
void flushSaveLlmImmediate();
|
||||
}, 500);
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [localLlmProviders, pendingApiKeys, flushSaveLlmImmediate]);
|
||||
|
||||
// Actions
|
||||
const handleAddProvider = () => {
|
||||
if (!newProviderId || !newProviderBaseUrl || !newProviderApiKey) {
|
||||
setLlmSaveMessage('请完整填写 Provider 信息');
|
||||
setTimeout(() => setLlmSaveMessage(''), 5000);
|
||||
return;
|
||||
}
|
||||
if (localLlmProviders[newProviderId]) {
|
||||
setLlmSaveMessage('Provider ID 已存在');
|
||||
setTimeout(() => setLlmSaveMessage(''), 5000);
|
||||
return;
|
||||
}
|
||||
markLlmDirty();
|
||||
setLocalLlmProviders(prev => ({
|
||||
...prev,
|
||||
[newProviderId]: {
|
||||
name: newProviderId,
|
||||
api_base_url: newProviderBaseUrl,
|
||||
api_key: newProviderApiKey,
|
||||
models: [],
|
||||
},
|
||||
}));
|
||||
setNewProviderId('');
|
||||
setNewProviderBaseUrl('');
|
||||
setNewProviderApiKey('');
|
||||
};
|
||||
|
||||
const handleDiscoverModels = async (providerId: string) => {
|
||||
try {
|
||||
// 刷新前强制保存,确保后端使用最新配置
|
||||
if (llmDirtyRef.current) {
|
||||
await flushSaveLlmImmediate();
|
||||
}
|
||||
const resp = await discoverProviderModels(providerId);
|
||||
const data = Array.isArray(resp?.data) ? resp.data : null;
|
||||
if (!data) {
|
||||
setDiscoverMessages(prev => ({ ...prev, [providerId]: '候选加载失败:不支持的响应结构' }));
|
||||
setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
|
||||
return;
|
||||
}
|
||||
const discovered: string[] = data
|
||||
.map((x: any) => (typeof x?.id === 'string' ? x.id : null))
|
||||
.filter((x: any) => typeof x === 'string');
|
||||
setCandidateModels(prev => ({ ...prev, [providerId]: discovered }));
|
||||
setDiscoverMessages(prev => ({ ...prev, [providerId]: `已加载候选模型 ${discovered.length} 个` }));
|
||||
setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
|
||||
} catch (e: any) {
|
||||
setDiscoverMessages(prev => ({ ...prev, [providerId]: `加载失败:${e?.message || '未知错误'}` }));
|
||||
setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
localLlmProviders, setLocalLlmProviders,
|
||||
isSavingLlm, llmSaveMessage, setLlmSaveMessage,
|
||||
newProviderId, setNewProviderId,
|
||||
newProviderBaseUrl, setNewProviderBaseUrl,
|
||||
newProviderApiKey, setNewProviderApiKey,
|
||||
pendingApiKeys, setPendingApiKeys,
|
||||
editingApiKey, setEditingApiKey,
|
||||
discoverMessages,
|
||||
modelPickerOpen, setModelPickerOpen,
|
||||
candidateModels, setCandidateModels,
|
||||
modelSearch, setModelSearch,
|
||||
selectedCandidates, setSelectedCandidates,
|
||||
newModelInputs, setNewModelInputs,
|
||||
newModelNameInputs, setNewModelNameInputs,
|
||||
newModelMenuOpen, setNewModelMenuOpen,
|
||||
newModelHighlightIndex, setNewModelHighlightIndex,
|
||||
markLlmDirty,
|
||||
handleAddProvider,
|
||||
handleDiscoverModels,
|
||||
flushSaveLlmImmediate,
|
||||
llmDirtyRef
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,228 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
useAnalysisTemplateSets,
|
||||
updateAnalysisTemplateSets,
|
||||
useLlmProviders
|
||||
} from '@/hooks/useApi';
|
||||
import { AnalysisTemplateSets, LlmModel } from '@/types';
|
||||
|
||||
export function useAnalysisConfig() {
|
||||
const { data: initialAnalysisTemplateSets, mutate: mutateAnalysisTemplateSets } = useAnalysisTemplateSets();
|
||||
const { data: llmProviders } = useLlmProviders();
|
||||
|
||||
const [localTemplateSets, setLocalTemplateSets] = useState<AnalysisTemplateSets>({});
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
|
||||
const [isSavingAnalysis, setIsSavingAnalysis] = useState(false);
|
||||
const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
|
||||
|
||||
const [newTemplateId, setNewTemplateId] = useState('');
|
||||
const [newTemplateName, setNewTemplateName] = useState('');
|
||||
const [isCreatingTemplate, setIsCreatingTemplate] = useState(false);
|
||||
|
||||
const [isCreatingModule, setIsCreatingModule] = useState(false);
|
||||
const [newModuleId, setNewModuleId] = useState('');
|
||||
const [newModuleName, setNewModuleName] = useState('');
|
||||
|
||||
const allModels = useMemo(() => {
|
||||
if (!llmProviders) return [];
|
||||
const models: { providerId: string; providerName: string; model: LlmModel }[] = [];
|
||||
Object.entries(llmProviders).forEach(([pId, provider]) => {
|
||||
provider.models.forEach(m => {
|
||||
if (m.is_active) {
|
||||
models.push({
|
||||
providerId: pId,
|
||||
providerName: provider.name || pId,
|
||||
model: m
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return models;
|
||||
}, [llmProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialAnalysisTemplateSets) return;
|
||||
setLocalTemplateSets(initialAnalysisTemplateSets);
|
||||
// 仅在未选择时,从后端数据中选择第一个模板;避免覆盖本地新增的选择与状态
|
||||
setSelectedTemplateId(prev => prev ?? (Object.keys(initialAnalysisTemplateSets)[0] || null));
|
||||
}, [initialAnalysisTemplateSets]);
|
||||
|
||||
const handleAnalysisChange = (moduleId: string, field: string, value: any) => {
|
||||
if (!selectedTemplateId) return;
|
||||
setLocalTemplateSets(prev => ({
|
||||
...prev,
|
||||
[selectedTemplateId]: {
|
||||
...prev[selectedTemplateId],
|
||||
modules: {
|
||||
...prev[selectedTemplateId].modules,
|
||||
[moduleId]: {
|
||||
...prev[selectedTemplateId].modules[moduleId],
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveAnalysis = async () => {
|
||||
setIsSavingAnalysis(true);
|
||||
setAnalysisSaveMessage('保存中...');
|
||||
try {
|
||||
const updated = await updateAnalysisTemplateSets(localTemplateSets);
|
||||
await mutateAnalysisTemplateSets(updated, false);
|
||||
setAnalysisSaveMessage('分析配置保存成功!');
|
||||
} catch (e: any) {
|
||||
setAnalysisSaveMessage(`保存失败: ${e.message}`);
|
||||
} finally {
|
||||
setIsSavingAnalysis(false);
|
||||
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAnalysisDependencies = (moduleId: string, dependency: string, checked: boolean) => {
|
||||
if (!selectedTemplateId) return;
|
||||
setLocalTemplateSets(prev => {
|
||||
const currentModule = prev[selectedTemplateId].modules[moduleId];
|
||||
const currentDeps = currentModule.dependencies || [];
|
||||
const newDeps = checked
|
||||
? [...currentDeps, dependency]
|
||||
: currentDeps.filter(d => d !== dependency);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[selectedTemplateId]: {
|
||||
...prev[selectedTemplateId],
|
||||
modules: {
|
||||
...prev[selectedTemplateId].modules,
|
||||
[moduleId]: {
|
||||
...currentModule,
|
||||
dependencies: [...new Set(newDeps)],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTemplate = async () => {
|
||||
if (!newTemplateId || !newTemplateName) {
|
||||
setAnalysisSaveMessage('模板 ID 和名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (localTemplateSets[newTemplateId]) {
|
||||
setAnalysisSaveMessage('模板 ID 已存在');
|
||||
return;
|
||||
}
|
||||
const newSet: AnalysisTemplateSets = {
|
||||
...localTemplateSets,
|
||||
[newTemplateId]: {
|
||||
name: newTemplateName,
|
||||
modules: {},
|
||||
},
|
||||
};
|
||||
setLocalTemplateSets(newSet);
|
||||
setSelectedTemplateId(newTemplateId);
|
||||
setNewTemplateId('');
|
||||
setNewTemplateName('');
|
||||
setIsCreatingTemplate(false);
|
||||
|
||||
// 新建后立即持久化
|
||||
setIsSavingAnalysis(true);
|
||||
setAnalysisSaveMessage('保存中...');
|
||||
try {
|
||||
const updated = await updateAnalysisTemplateSets(newSet);
|
||||
await mutateAnalysisTemplateSets(updated, false);
|
||||
setAnalysisSaveMessage('分析配置保存成功!');
|
||||
} catch (e: any) {
|
||||
setAnalysisSaveMessage(`保存失败: ${e?.message || '未知错误'}`);
|
||||
} finally {
|
||||
setIsSavingAnalysis(false);
|
||||
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = () => {
|
||||
if (!selectedTemplateId || !localTemplateSets[selectedTemplateId]) return;
|
||||
if (!window.confirm(`确定要删除模板 "${localTemplateSets[selectedTemplateId].name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
const newSets = { ...localTemplateSets };
|
||||
delete newSets[selectedTemplateId];
|
||||
setLocalTemplateSets(newSets);
|
||||
const firstKey = Object.keys(newSets)[0] || null;
|
||||
setSelectedTemplateId(firstKey);
|
||||
};
|
||||
|
||||
const handleAddNewModule = () => {
|
||||
if (!selectedTemplateId || !newModuleId || !newModuleName) {
|
||||
setAnalysisSaveMessage('模块 ID 和名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (localTemplateSets[selectedTemplateId].modules[newModuleId]) {
|
||||
setAnalysisSaveMessage('模块 ID 已存在');
|
||||
return;
|
||||
}
|
||||
setLocalTemplateSets(prev => ({
|
||||
...prev,
|
||||
[selectedTemplateId]: {
|
||||
...prev[selectedTemplateId],
|
||||
modules: {
|
||||
...prev[selectedTemplateId].modules,
|
||||
[newModuleId]: {
|
||||
name: newModuleName,
|
||||
provider_id: '',
|
||||
model_id: '',
|
||||
prompt_template: '',
|
||||
dependencies: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
setNewModuleId('');
|
||||
setNewModuleName('');
|
||||
setIsCreatingModule(false);
|
||||
};
|
||||
|
||||
const handleDeleteModule = (moduleId: string) => {
|
||||
if (!selectedTemplateId) return;
|
||||
setLocalTemplateSets(prev => {
|
||||
const newModules = { ...prev[selectedTemplateId].modules };
|
||||
delete newModules[moduleId];
|
||||
return {
|
||||
...prev,
|
||||
[selectedTemplateId]: {
|
||||
...prev[selectedTemplateId],
|
||||
modules: newModules,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
localTemplateSets,
|
||||
selectedTemplateId,
|
||||
setSelectedTemplateId,
|
||||
allModels,
|
||||
isSavingAnalysis,
|
||||
analysisSaveMessage,
|
||||
setAnalysisSaveMessage,
|
||||
// Template creation
|
||||
newTemplateId, setNewTemplateId,
|
||||
newTemplateName, setNewTemplateName,
|
||||
isCreatingTemplate, setIsCreatingTemplate,
|
||||
handleAddTemplate,
|
||||
handleDeleteTemplate,
|
||||
// Module creation
|
||||
isCreatingModule, setIsCreatingModule,
|
||||
newModuleId, setNewModuleId,
|
||||
newModuleName, setNewModuleName,
|
||||
handleAddNewModule,
|
||||
handleDeleteModule,
|
||||
// Actions
|
||||
handleAnalysisChange,
|
||||
handleSaveAnalysis,
|
||||
updateAnalysisDependencies
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
useDataSourcesConfig as useDataSourcesConfigApi,
|
||||
updateDataSourcesConfig,
|
||||
testConfig
|
||||
} from '@/hooks/useApi';
|
||||
import { DataSourcesConfig, DataSourceProvider, DataSourceConfig } from '@/types';
|
||||
|
||||
const defaultUrls: Partial<Record<DataSourceProvider, string>> = {
|
||||
tushare: 'http://api.tushare.pro',
|
||||
finnhub: 'https://finnhub.io/api/v1',
|
||||
alphavantage: 'https://mcp.alphavantage.co/mcp',
|
||||
};
|
||||
|
||||
export function useDataSourcesConfigLogic() {
|
||||
const { data: initialDataSources, error: dsError, isLoading: dsLoading, mutate: mutateDataSources } = useDataSourcesConfigApi();
|
||||
const [localDataSources, setLocalDataSources] = useState<DataSourcesConfig>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; summary: string; details?: string } | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialDataSources) {
|
||||
setLocalDataSources(initialDataSources);
|
||||
}
|
||||
}, [initialDataSources]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveMessage('保存中...');
|
||||
|
||||
try {
|
||||
if (initialDataSources) {
|
||||
const finalDataSources = JSON.parse(JSON.stringify(localDataSources));
|
||||
for (const key in finalDataSources) {
|
||||
const providerKey = key as DataSourceProvider;
|
||||
const source = finalDataSources[providerKey];
|
||||
if (source && (source.api_url === null || source.api_url.trim() === '') && defaultUrls[providerKey]) {
|
||||
source.api_url = defaultUrls[providerKey];
|
||||
}
|
||||
}
|
||||
|
||||
await updateDataSourcesConfig(finalDataSources);
|
||||
await mutateDataSources(finalDataSources, false);
|
||||
setLocalDataSources(finalDataSources);
|
||||
}
|
||||
setSaveMessage('保存成功!');
|
||||
} catch (e: any) {
|
||||
setSaveMessage(`保存失败: ${e.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setSaveMessage(''), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (initialDataSources) setLocalDataSources(initialDataSources);
|
||||
setTestResults({});
|
||||
setSaveMessage('');
|
||||
};
|
||||
|
||||
const handleTest = async (type: string, data: any) => {
|
||||
try {
|
||||
const result = await testConfig(type, data);
|
||||
const success = !!result?.success;
|
||||
const summary = typeof result?.message === 'string' && result.message.trim().length > 0
|
||||
? result.message
|
||||
: (success ? '测试成功' : '测试失败');
|
||||
setTestResults(prev => ({ ...prev, [type]: { success, summary } }));
|
||||
} catch (e: any) {
|
||||
const summary: string = (e && typeof e === 'object' && 'summary' in e) ? String(e.summary) : (e?.message || '未知错误');
|
||||
const details: string | undefined = (e && typeof e === 'object' && 'details' in e) ? (e.details ? String(e.details) : undefined) : undefined;
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[type]: { success: false, summary, details }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
localDataSources,
|
||||
setLocalDataSources,
|
||||
dsLoading,
|
||||
dsError,
|
||||
saving,
|
||||
saveMessage,
|
||||
testResults,
|
||||
handleSave,
|
||||
handleReset,
|
||||
handleTest,
|
||||
defaultUrls
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
|
||||
export function useSystemConfig() {
|
||||
const { config } = useConfigStore();
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
|
||||
const handleExportConfig = () => {
|
||||
if (!config) return;
|
||||
|
||||
const configToExport = {
|
||||
new_api: config.new_api,
|
||||
data_sources: config.data_sources,
|
||||
export_time: new Date().toISOString(),
|
||||
version: "1.0"
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(configToExport, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `config-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImportConfig = (event: React.ChangeEvent<HTMLInputElement>, onImportSuccess?: (config: any) => void) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const importedConfig = JSON.parse(e.target?.result as string);
|
||||
|
||||
if (onImportSuccess) {
|
||||
onImportSuccess(importedConfig);
|
||||
}
|
||||
|
||||
setSaveMessage('配置导入成功,请检查并保存');
|
||||
} catch (error) {
|
||||
setSaveMessage('配置文件格式错误,导入失败');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
saveMessage,
|
||||
handleExportConfig,
|
||||
handleImportConfig
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,138 +0,0 @@
|
||||
|
||||
const MAX_REPARSE_DEPTH = 2;
|
||||
|
||||
function tryParseJson(input: string): unknown {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePossiblyNestedJson(raw: string): unknown {
|
||||
let current: unknown = raw;
|
||||
for (let i = 0; i < MAX_REPARSE_DEPTH; i++) {
|
||||
if (typeof current === 'string') {
|
||||
const trimmed = current.trim();
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
current = tryParseJson(trimmed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function indentLines(text: string, indent: string): string {
|
||||
return text.split('\n').map((line) => indent + line).join('\n');
|
||||
}
|
||||
|
||||
function looksLikeStructuredText(s: string): boolean {
|
||||
const t = s.trim();
|
||||
// 具有成对的大括号,或常见的 Rust/reqwest 错误标识,判定为可结构化的调试输出
|
||||
return (t.includes('{') && t.includes('}')) || t.includes('DynamicTransportError') || t.includes('reqwest::Error');
|
||||
}
|
||||
|
||||
function prettyFormatByBraces(input: string): string {
|
||||
let out = '';
|
||||
let indentLevel = 0;
|
||||
const indentUnit = ' ';
|
||||
let inString = false;
|
||||
let stringQuote = '';
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i]!;
|
||||
const prev = i > 0 ? input[i - 1]! : '';
|
||||
if ((ch === '"' || ch === "'") && prev !== '\\') {
|
||||
if (!inString) {
|
||||
inString = true;
|
||||
stringQuote = ch;
|
||||
} else if (stringQuote === ch) {
|
||||
inString = false;
|
||||
}
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '[' || ch === '(') {
|
||||
indentLevel += 1;
|
||||
out += ch + '\n' + indentUnit.repeat(indentLevel);
|
||||
continue;
|
||||
}
|
||||
if (ch === '}' || ch === ']' || ch === ')') {
|
||||
indentLevel = Math.max(0, indentLevel - 1);
|
||||
out += '\n' + indentUnit.repeat(indentLevel) + ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',') {
|
||||
out += ch + '\n' + indentUnit.repeat(indentLevel);
|
||||
continue;
|
||||
}
|
||||
if (ch === ':') {
|
||||
out += ch + ' ';
|
||||
continue;
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toPseudoYaml(value: unknown, indent: string = ''): string {
|
||||
const nextIndent = indent + ' ';
|
||||
if (value === null || value === undefined) {
|
||||
return `${indent}null`;
|
||||
}
|
||||
const t = typeof value;
|
||||
if (t === 'string' || t === 'number' || t === 'boolean') {
|
||||
const s = String(value);
|
||||
const shouldPretty = looksLikeStructuredText(s) || s.includes('\n');
|
||||
if (shouldPretty) {
|
||||
const pretty = looksLikeStructuredText(s) ? prettyFormatByBraces(s) : s;
|
||||
return `${indent}|-\n${indentLines(pretty, nextIndent)}`;
|
||||
}
|
||||
return `${indent}${s}`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return `${indent}[]`;
|
||||
return value.map((item) => {
|
||||
const rendered = toPseudoYaml(item, nextIndent);
|
||||
const lines = rendered.split('\n');
|
||||
if (lines.length === 1) {
|
||||
return `${indent}- ${lines[0].trimStart()}`;
|
||||
}
|
||||
return `${indent}- ${lines[0].trimStart()}\n${lines.slice(1).map((l) => indent + ' ' + l.trimStart()).join('\n')}`;
|
||||
}).join('\n');
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return `${indent}{}`;
|
||||
return keys.map((k) => {
|
||||
const rendered = toPseudoYaml(obj[k], nextIndent);
|
||||
const lines = rendered.split('\n');
|
||||
if (lines.length === 1) {
|
||||
return `${indent}${k}: ${lines[0].trimStart()}`;
|
||||
}
|
||||
return `${indent}${k}:\n${lines.map((l) => l).join('\n')}`;
|
||||
}).join('\n');
|
||||
}
|
||||
// fallback
|
||||
return `${indent}${String(value)}`;
|
||||
}
|
||||
|
||||
export function formatDetailsToYaml(details: string): string {
|
||||
const parsed = parsePossiblyNestedJson(details);
|
||||
if (typeof parsed === 'string') {
|
||||
// 尝试再次解析(有些场景内层 message 也是 JSON 串)
|
||||
const again = parsePossiblyNestedJson(parsed);
|
||||
if (typeof again === 'string') {
|
||||
return toPseudoYaml(again);
|
||||
}
|
||||
return toPseudoYaml(again);
|
||||
}
|
||||
return toPseudoYaml(parsed);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface ReportHeaderProps {
|
||||
unifiedSymbol: string;
|
||||
@ -15,6 +16,10 @@ interface ReportHeaderProps {
|
||||
onStartAnalysis: () => void;
|
||||
onStopAnalysis: () => void;
|
||||
onContinueAnalysis: () => void;
|
||||
// Template props
|
||||
templateSets: any;
|
||||
selectedTemplateId: string;
|
||||
onSelectTemplate: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ReportHeader({
|
||||
@ -30,6 +35,9 @@ export function ReportHeader({
|
||||
onStartAnalysis,
|
||||
onStopAnalysis,
|
||||
onContinueAnalysis,
|
||||
templateSets,
|
||||
selectedTemplateId,
|
||||
onSelectTemplate,
|
||||
}: ReportHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
@ -65,16 +73,50 @@ export function ReportHeader({
|
||||
</Card>
|
||||
|
||||
<Card className="w-80 flex-shrink-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">分析控制</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">选择模板</label>
|
||||
<Select value={selectedTemplateId} onValueChange={onSelectTemplate} disabled={triggering}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择分析模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateSets && Object.entries(templateSets).map(([id, set]: [string, any]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{set.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={onStartAnalysis}
|
||||
disabled={triggering || !selectedTemplateId}
|
||||
className="flex-1"
|
||||
>
|
||||
{triggering ? '触发中…' : '触发分析'}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onStopAnalysis} disabled={!hasRunningTask}>
|
||||
停止
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onContinueAnalysis} disabled={isAnalysisRunning}>
|
||||
继续
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-64 flex-shrink-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">昨日快照</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
<SnapshotItem
|
||||
label="日期"
|
||||
value={snapshot?.trade_date ? `${snapshot.trade_date.slice(0,4)}-${snapshot.trade_date.slice(4,6)}-${snapshot.trade_date.slice(6,8)}` : undefined}
|
||||
loading={snapshotLoading}
|
||||
/>
|
||||
<SnapshotItem
|
||||
label="PB"
|
||||
value={snapshot?.pb != null ? `${Number(snapshot.pb).toFixed(2)}` : undefined}
|
||||
@ -95,31 +137,9 @@ export function ReportHeader({
|
||||
value={snapshot?.total_mv != null ? `${Math.round((snapshot.total_mv as number) / 10000).toLocaleString('zh-CN')} 亿元` : undefined}
|
||||
loading={snapshotLoading}
|
||||
/>
|
||||
<SnapshotItem
|
||||
label="股息率"
|
||||
value={snapshot?.dv_ratio != null ? `${Number(snapshot.dv_ratio).toFixed(2)}%` : undefined}
|
||||
loading={snapshotLoading}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-40 flex-shrink-0">
|
||||
<CardContent className="flex flex-col gap-2 pt-6">
|
||||
<Button
|
||||
onClick={onStartAnalysis}
|
||||
disabled={triggering}
|
||||
>
|
||||
{triggering ? '触发中…' : '触发分析'}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onStopAnalysis} disabled={!hasRunningTask}>
|
||||
停止
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onContinueAnalysis} disabled={isAnalysisRunning}>
|
||||
继续
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -131,8 +151,7 @@ function SnapshotItem({ label, value, loading }: { label: string; value?: string
|
||||
<span className="font-medium">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{label === '日期' && <Spinner className="size-3" />}
|
||||
<span className="text-muted-foreground">{label === '日期' ? '加载中...' : '-'}</span>
|
||||
<Spinner className="size-3" />
|
||||
</span>
|
||||
) : value ? (
|
||||
value
|
||||
@ -143,4 +162,3 @@ function SnapshotItem({ label, value, loading }: { label: string; value?: string
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -25,17 +25,47 @@ interface AnalysisRecord {
|
||||
|
||||
export function useAnalysisRunner(
|
||||
financials: any,
|
||||
analysisConfig: any,
|
||||
financialConfig: any,
|
||||
normalizedMarket: string,
|
||||
unifiedSymbol: string,
|
||||
isLoading: boolean,
|
||||
error: any
|
||||
error: any,
|
||||
templateSets: any // Added templateSets
|
||||
) {
|
||||
// --- Template Logic ---
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('');
|
||||
|
||||
// Set default template
|
||||
useEffect(() => {
|
||||
if (!selectedTemplateId && templateSets && Object.keys(templateSets).length > 0) {
|
||||
const defaultId = Object.keys(templateSets).find(k => k.includes('standard') || k === 'default') || Object.keys(templateSets)[0];
|
||||
setSelectedTemplateId(defaultId);
|
||||
}
|
||||
}, [templateSets, selectedTemplateId]);
|
||||
|
||||
const reportTemplateId = financials?.meta?.template_id;
|
||||
|
||||
// Determine active template set
|
||||
const activeTemplateId = (financials && reportTemplateId) ? reportTemplateId : selectedTemplateId;
|
||||
|
||||
const activeTemplateSet = useMemo(() => {
|
||||
if (!activeTemplateId || !templateSets) return null;
|
||||
return templateSets[activeTemplateId] || null;
|
||||
}, [activeTemplateId, templateSets]);
|
||||
|
||||
// Derive effective analysis config from template set, falling back to global config if needed
|
||||
const activeAnalysisConfig = useMemo(() => {
|
||||
if (activeTemplateSet) {
|
||||
return { analysis_modules: activeTemplateSet.modules };
|
||||
}
|
||||
return financialConfig; // Fallback to global config (legacy behavior)
|
||||
}, [activeTemplateSet, financialConfig]);
|
||||
|
||||
// 分析类型列表
|
||||
const analysisTypes = useMemo(() => {
|
||||
if (!analysisConfig?.analysis_modules) return [];
|
||||
return Object.keys(analysisConfig.analysis_modules);
|
||||
}, [analysisConfig]);
|
||||
if (!activeAnalysisConfig?.analysis_modules) return [];
|
||||
return Object.keys(activeAnalysisConfig.analysis_modules);
|
||||
}, [activeAnalysisConfig]);
|
||||
|
||||
// 分析状态管理
|
||||
const [analysisStates, setAnalysisStates] = useState<Record<string, AnalysisState>>({});
|
||||
@ -109,7 +139,7 @@ export function useAnalysisRunner(
|
||||
}, [startTime]);
|
||||
|
||||
const retryAnalysis = async (analysisType: string) => {
|
||||
if (!financials || !analysisConfig?.analysis_modules) {
|
||||
if (!financials || !activeAnalysisConfig?.analysis_modules) {
|
||||
return;
|
||||
}
|
||||
analysisFetchedRefs.current[analysisType] = false;
|
||||
@ -119,7 +149,7 @@ export function useAnalysisRunner(
|
||||
}));
|
||||
setAnalysisRecords(prev => prev.filter(record => record.type !== analysisType));
|
||||
const analysisName =
|
||||
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||||
activeAnalysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||||
const startTimeISO = new Date().toISOString();
|
||||
setCurrentAnalysisTask(analysisType);
|
||||
setAnalysisRecords(prev => [...prev, {
|
||||
@ -186,6 +216,7 @@ export function useAnalysisRunner(
|
||||
setAnalysisStates(prev => ({
|
||||
...prev,
|
||||
[analysisType]: {
|
||||
...prev[analysisType],
|
||||
content: '',
|
||||
loading: false,
|
||||
error: errorMessage
|
||||
@ -208,7 +239,7 @@ export function useAnalysisRunner(
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || error || !financials || !analysisConfig?.analysis_modules || analysisTypes.length === 0) {
|
||||
if (isLoading || error || !financials || !activeAnalysisConfig?.analysis_modules || analysisTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (isAnalysisRunningRef.current) {
|
||||
@ -231,13 +262,13 @@ export function useAnalysisRunner(
|
||||
if (analysisFetchedRefs.current[analysisType]) {
|
||||
continue;
|
||||
}
|
||||
if (!analysisFetchedRefs.current || !analysisConfig?.analysis_modules) {
|
||||
if (!analysisFetchedRefs.current || !activeAnalysisConfig?.analysis_modules) {
|
||||
console.error("分析配置或refs未初始化,无法进行分析。");
|
||||
continue;
|
||||
}
|
||||
currentAnalysisTypeRef.current = analysisType;
|
||||
const analysisName =
|
||||
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||||
activeAnalysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||||
const startTimeISO = new Date().toISOString();
|
||||
setCurrentAnalysisTask(analysisType);
|
||||
setAnalysisRecords(prev => {
|
||||
@ -360,7 +391,7 @@ export function useAnalysisRunner(
|
||||
}
|
||||
};
|
||||
runAnalysesSequentially();
|
||||
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
|
||||
}, [isLoading, error, financials, activeAnalysisConfig, analysisTypes, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
|
||||
|
||||
const stopAll = () => {
|
||||
stopRequestedRef.current = true;
|
||||
@ -382,11 +413,12 @@ export function useAnalysisRunner(
|
||||
};
|
||||
|
||||
const triggerAnalysis = async () => {
|
||||
const reqId = await triggerAnalysisRequest(unifiedSymbol, normalizedMarket || '');
|
||||
const reqId = await triggerAnalysisRequest(unifiedSymbol, normalizedMarket || '', selectedTemplateId);
|
||||
if (reqId) setRequestId(reqId);
|
||||
};
|
||||
|
||||
return {
|
||||
activeAnalysisConfig, // Exported
|
||||
analysisTypes,
|
||||
analysisStates,
|
||||
analysisRecords,
|
||||
@ -404,11 +436,8 @@ export function useAnalysisRunner(
|
||||
continuePending,
|
||||
retryAnalysis,
|
||||
hasRunningTask,
|
||||
isAnalysisRunning: isAnalysisRunningRef.current, // 注意:这里返回的是 ref.current,可能不是响应式的。通常需要 state。
|
||||
// 但原代码中 isAnalysisRunningRef 仅用于内部逻辑控制,没有直接用于 UI 展示(除了 disabled 状态,但这通常依赖 state 变化触发重渲染)。
|
||||
// 在原 UI 中 `disabled={isAnalysisRunningRef.current}` 可能会有问题,如果仅仅 Ref 变了组件不一定会刷新。
|
||||
// 不过原代码就是这样写的。
|
||||
// 我建议可以用 hasRunningTask 来作为替代。
|
||||
isAnalysisRunning: isAnalysisRunningRef.current,
|
||||
selectedTemplateId, // Exported
|
||||
setSelectedTemplateId, // Exported
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -134,3 +134,5 @@ export default function ReportPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
160
frontend/src/components/ui/command.tsx
Normal file
160
frontend/src/components/ui/command.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<CommandPrimitive.Dialog
|
||||
data-slot="command-dialog"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CommandPrimitive.Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div className="flex items-center border-b px-3" data-slot="command-input-wrapper">
|
||||
<SearchIcon className="mr-2 size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
||||
49
frontend/src/components/ui/popover.tsx
Normal file
49
frontend/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
||||
@ -3,15 +3,13 @@ import {
|
||||
BatchFinancialDataResponse,
|
||||
TodaySnapshotResponse,
|
||||
RealTimeQuoteResponse,
|
||||
AnalysisConfigResponse,
|
||||
LlmProvidersConfig,
|
||||
AnalysisModulesConfig,
|
||||
AnalysisTemplateSets, // New type
|
||||
FinancialConfigResponse,
|
||||
DataSourcesConfig,
|
||||
AnalysisResultDto,
|
||||
} from "@/types";
|
||||
import { useEffect, useState } from "react";
|
||||
// Execution-step types not used currently; keep API minimal and explicit
|
||||
import { useConfigStore } from "@/stores/useConfigStore";
|
||||
import type { SystemConfig } from "@/stores/useConfigStore";
|
||||
|
||||
@ -24,7 +22,7 @@ export function useDataRequest() {
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const trigger = async (symbol: string, market: string): Promise<string | undefined> => {
|
||||
const trigger = async (symbol: string, market: string, templateId?: string): Promise<string | undefined> => {
|
||||
setIsMutating(true);
|
||||
setError(null);
|
||||
try {
|
||||
@ -33,7 +31,7 @@ export function useDataRequest() {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ symbol, market }),
|
||||
body: JSON.stringify({ symbol, market, template_id: templateId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@ -79,6 +77,19 @@ export function useTaskProgress(requestId: string | null, options?: SWRConfigura
|
||||
};
|
||||
}
|
||||
|
||||
// --- Analysis Results Hooks (NEW) ---
|
||||
|
||||
export function useAnalysisResults(symbol?: string) {
|
||||
return useSWR<AnalysisResultDto[]>(
|
||||
symbol ? `/api/analysis-results?symbol=${encodeURIComponent(symbol)}` : null,
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 5000, // Poll for new results
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// --- 保留的旧Hooks (用于查询最终数据) ---
|
||||
|
||||
export function useCompanyProfile(symbol?: string, market?: string) {
|
||||
@ -139,44 +150,6 @@ export function useFinancials(market?: string, stockCode?: string, years: number
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnalysisConfig() {
|
||||
return useSWR<AnalysisConfigResponse>('/api/configs/analysis_modules', fetcher);
|
||||
}
|
||||
|
||||
export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
|
||||
const res = await fetch('/api/configs/analysis_modules', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function generateFullAnalysis(tsCode: string, companyName: string) {
|
||||
const url = `/api/financials/china/${encodeURIComponent(tsCode)}/analysis?company_name=${encodeURIComponent(companyName)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const errorJson = JSON.parse(text);
|
||||
throw new Error(errorJson.detail || text);
|
||||
} catch {
|
||||
throw new Error(text || `Request failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from server.');
|
||||
}
|
||||
}
|
||||
|
||||
export function useChinaSnapshot(ts_code?: string) {
|
||||
return useSWR<TodaySnapshotResponse>(
|
||||
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}/snapshot` : null,
|
||||
@ -397,26 +370,6 @@ export async function updateAnalysisTemplateSets(payload: AnalysisTemplateSets)
|
||||
return res.json() as Promise<AnalysisTemplateSets>;
|
||||
}
|
||||
|
||||
|
||||
// --- Analysis Modules Config Hooks (OLD - DEPRECATED) ---
|
||||
|
||||
export function useAnalysisModules() {
|
||||
return useSWR<AnalysisModulesConfig>('/api/configs/analysis_modules', fetcher);
|
||||
}
|
||||
|
||||
export async function updateAnalysisModules(payload: AnalysisModulesConfig) {
|
||||
const res = await fetch('/api/configs/analysis_modules', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<AnalysisModulesConfig>;
|
||||
}
|
||||
|
||||
// --- Data Sources Config Hooks ---
|
||||
|
||||
export function useDataSourcesConfig() {
|
||||
|
||||
@ -102,6 +102,7 @@ export interface FinancialMeta {
|
||||
api_calls_by_group: Record<string, number>;
|
||||
current_action?: string | null;
|
||||
steps: StepRecord[];
|
||||
template_id?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchFinancialDataResponse {
|
||||
@ -183,6 +184,21 @@ export interface AnalysisResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析结果 DTO (Analysis Result Data Transfer Object)
|
||||
* Corresponds to backend AnalysisResultDto
|
||||
*/
|
||||
export interface AnalysisResultDto {
|
||||
id: number;
|
||||
request_id: string; // UUID
|
||||
symbol: string;
|
||||
template_id: string;
|
||||
module_id: string;
|
||||
content: string;
|
||||
meta_data: any; // JSON
|
||||
created_at: string; // ISO8601
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析配置响应接口
|
||||
*/
|
||||
|
||||
697
package-lock.json
generated
697
package-lock.json
generated
@ -5,10 +5,559 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"cmdk": "^1.1.1",
|
||||
"immer": "^10.2.0",
|
||||
"zustand": "^5.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
|
||||
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
@ -19,6 +568,154 @@
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"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/react-dom": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"cmdk": "^1.1.1",
|
||||
"immer": "^10.2.0",
|
||||
"zustand": "^5.0.8"
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ const ANALYSIS_COMMANDS_QUEUE: &str = "analysis.commands.generate_report";
|
||||
pub struct DataRequest {
|
||||
pub symbol: String,
|
||||
pub market: String,
|
||||
pub template_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@ -102,7 +103,7 @@ async fn trigger_data_fetch(
|
||||
let request_id = Uuid::new_v4();
|
||||
let command = FetchCompanyDataCommand {
|
||||
request_id,
|
||||
symbol: payload.symbol,
|
||||
symbol: payload.symbol.clone(),
|
||||
market: payload.market,
|
||||
};
|
||||
|
||||
@ -116,6 +117,23 @@ async fn trigger_data_fetch(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// If a template_id is provided, trigger the analysis generation workflow as well.
|
||||
if let Some(template_id) = payload.template_id {
|
||||
let analysis_command = GenerateReportCommand {
|
||||
request_id,
|
||||
symbol: payload.symbol,
|
||||
template_id,
|
||||
};
|
||||
info!(request_id = %request_id, template_id = %analysis_command.template_id, "Publishing analysis generation command (auto-triggered)");
|
||||
state
|
||||
.nats_client
|
||||
.publish(
|
||||
ANALYSIS_COMMANDS_QUEUE.to_string(),
|
||||
serde_json::to_vec(&analysis_command).unwrap().into(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(RequestAcceptedResponse { request_id }),
|
||||
|
||||
@ -3,5 +3,6 @@ pub mod models;
|
||||
pub mod observability;
|
||||
pub mod messages;
|
||||
pub mod config_models;
|
||||
pub mod provider;
|
||||
|
||||
|
||||
|
||||
8
services/common-contracts/src/provider.rs
Normal file
8
services/common-contracts/src/provider.rs
Normal file
@ -0,0 +1,8 @@
|
||||
pub trait DataProvider {
|
||||
/// Returns the name of the provider (e.g., "tushare", "yfinance")
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Returns whether the provider is enabled in the configuration
|
||||
fn is_enabled(&self) -> bool;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ use crate::{
|
||||
mapping::{map_financial_dtos, map_profile_dto},
|
||||
};
|
||||
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||
use common_contracts::provider::DataProvider;
|
||||
use tokio;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@ -60,6 +61,16 @@ pub struct FinnhubDataProvider {
|
||||
client: FinnhubClient,
|
||||
}
|
||||
|
||||
impl DataProvider for FinnhubDataProvider {
|
||||
fn name(&self) -> &str {
|
||||
"finnhub"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl FinnhubDataProvider {
|
||||
pub fn new(api_url: String, api_token: String) -> Self {
|
||||
Self {
|
||||
|
||||
@ -9,12 +9,23 @@ use crate::{
|
||||
ts_client::TushareClient,
|
||||
};
|
||||
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||
use common_contracts::provider::DataProvider;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TushareDataProvider {
|
||||
client: TushareClient,
|
||||
}
|
||||
|
||||
impl DataProvider for TushareDataProvider {
|
||||
fn name(&self) -> &str {
|
||||
"tushare"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl TushareDataProvider {
|
||||
pub fn new(api_url: String, api_token: String) -> Self {
|
||||
Self {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user