复制 local 代码到 serve
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
/**
|
||||
* AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。
|
||||
* 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。
|
||||
* 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。
|
||||
* (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离)
|
||||
* 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。
|
||||
* 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。
|
||||
*/
|
||||
|
||||
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ActiveTask {
|
||||
id: string // 任务 id(前端生成,与最终 report id 解耦)
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
phase: Phase
|
||||
content: string // 累积的 Markdown
|
||||
error: string
|
||||
meta: { summary?: string; periods?: number } | null
|
||||
createdAt: number // ms 时间戳
|
||||
savedReportId?: string // 完成后存到后端的报告 id
|
||||
doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理)
|
||||
dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示
|
||||
}
|
||||
|
||||
export interface HistoryReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
periods?: number
|
||||
summary?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
// ===== 全局状态 =====
|
||||
let activeTasks: ActiveTask[] = []
|
||||
let history: HistoryReport[] = []
|
||||
let historyLoaded = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
// 当前"前台"展示的任务:
|
||||
// - 活跃任务 id(正在生成/刚完成,对话框打开)
|
||||
// - 或 'history:<id>'(查看历史报告)
|
||||
// - 或 null(对话框关闭/最小化)
|
||||
let activeDialogTaskId: string | null = null
|
||||
let dialogMinimized = false // 对话框是否最小化为气泡
|
||||
|
||||
function emit() { listeners.forEach(fn => fn()) }
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
}
|
||||
|
||||
function normalizeAiError(msg: string) {
|
||||
return msg.includes('API Key') || msg.includes('api_key')
|
||||
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
|
||||
: msg
|
||||
}
|
||||
|
||||
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
|
||||
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
|
||||
let _activeSnap: ActiveTask[] = []
|
||||
let _historySnap: HistoryReport[] = []
|
||||
interface DialogSnap { taskId: string | null; minimized: boolean }
|
||||
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
|
||||
function rebuildSnap() {
|
||||
_activeSnap = activeTasks
|
||||
_historySnap = history
|
||||
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
}
|
||||
|
||||
function getActiveSnapshot() { return _activeSnap }
|
||||
function getHistorySnapshot() { return _historySnap }
|
||||
function getDialogSnapshot() { return _dialogSnap }
|
||||
|
||||
function patchTask(id: string, patch: Partial<ActiveTask>) {
|
||||
activeTasks = activeTasks.map(t => {
|
||||
if (t.id !== id) return t
|
||||
const next = { ...t, ...patch }
|
||||
// 首次进入 done/error 态时记录 doneAt
|
||||
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
|
||||
next.doneAt = Date.now()
|
||||
}
|
||||
return next
|
||||
})
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
// ===== 公开:查询 hooks =====
|
||||
|
||||
export function useBubbleTasks(): ActiveTask[] {
|
||||
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
// 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。
|
||||
// (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现)
|
||||
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
const ds = _dialogSnap
|
||||
return all.filter(t => {
|
||||
// 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化)
|
||||
if (t.phase === 'loading' || t.phase === 'streaming') {
|
||||
// 除非对话框正打开看着它(非最小化)
|
||||
return !(ds.taskId === t.id && !ds.minimized)
|
||||
}
|
||||
// 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。
|
||||
// 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。
|
||||
if (t.dismissed) return false // 用户已点击查看过 → 移除
|
||||
if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 兼容旧调用名(Layout 等处可能引用) */
|
||||
export const useActiveTasks = useBubbleTasks
|
||||
|
||||
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
|
||||
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
return { reports, loaded: historyLoaded }
|
||||
}
|
||||
|
||||
export function useDialogState() {
|
||||
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
}
|
||||
|
||||
/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */
|
||||
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
|
||||
const ds = useDialogState()
|
||||
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
if (!ds.taskId) return { task: null, mode: null }
|
||||
if (ds.taskId.startsWith('history:')) {
|
||||
const rid = ds.taskId.slice('history:'.length)
|
||||
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
|
||||
}
|
||||
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
|
||||
}
|
||||
|
||||
// ===== 公开:动作 =====
|
||||
|
||||
/** 拉取历史报告(惰性,首次需要时调用)。 */
|
||||
export async function loadHistory(): Promise<void> {
|
||||
try {
|
||||
const res = await api.financialReportsList()
|
||||
history = res.reports ?? []
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch {
|
||||
// 静默失败,列表会显示空
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
|
||||
* 若历史未加载,先触发拉取。
|
||||
* @returns 最近一条报告,或 null
|
||||
*/
|
||||
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
// history 已按 created_at 降序,取第一条匹配
|
||||
return history.find(r => r.symbol === symbol) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个新的 AI 分析任务。
|
||||
* @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。
|
||||
*/
|
||||
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
||||
// 同 symbol 已有活跃任务 → 直接聚焦它
|
||||
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
|
||||
if (existing) {
|
||||
activeDialogTaskId = existing.id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
return { id: existing.id }
|
||||
}
|
||||
// 上限检查
|
||||
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
|
||||
if (ongoing.length >= MAX_ACTIVE) {
|
||||
return { error: `同时进行的分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
|
||||
}
|
||||
|
||||
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const task: ActiveTask = {
|
||||
id, symbol, name, focus,
|
||||
phase: 'loading', content: '', error: '',
|
||||
meta: null, createdAt: Date.now(),
|
||||
}
|
||||
activeTasks = [...activeTasks, task]
|
||||
activeDialogTaskId = id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
|
||||
// 启动流式接收(后台运行,不阻塞)
|
||||
runStream(id, symbol, focus)
|
||||
return { id }
|
||||
}
|
||||
|
||||
async function runStream(id: string, symbol: string, focus: string) {
|
||||
try {
|
||||
let firstDelta = true
|
||||
for await (const chunk of api.financialAnalyzeStream(symbol, focus)) {
|
||||
// 任务可能已被取消(不在列表里了)→ 终止
|
||||
const cur = activeTasks.find(t => t.id === id)
|
||||
if (!cur) return
|
||||
switch (chunk.type) {
|
||||
case 'meta':
|
||||
patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } })
|
||||
break
|
||||
case 'delta':
|
||||
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
|
||||
patchTask(id, { content: cur.content + (chunk.content ?? '') })
|
||||
break
|
||||
case 'error':
|
||||
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
|
||||
return
|
||||
case 'done':
|
||||
// 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准)
|
||||
patchTask(id, { phase: 'done' })
|
||||
break
|
||||
}
|
||||
}
|
||||
// 流正常结束 → 持久化报告
|
||||
const final = activeTasks.find(t => t.id === id)
|
||||
if (final && final.phase !== 'error' && final.content) {
|
||||
try {
|
||||
const res = await api.financialReportSave({
|
||||
symbol: final.symbol,
|
||||
name: final.name,
|
||||
focus: final.focus,
|
||||
content: final.content,
|
||||
periods: final.meta?.periods,
|
||||
summary: final.meta?.summary ?? '',
|
||||
})
|
||||
if (res.report) {
|
||||
patchTask(id, { savedReportId: res.report.id })
|
||||
// 加到历史列表头部
|
||||
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
// 任务完成:不自动弹出对话框,只在胶囊显示"已完成"态,用户想看再点。
|
||||
// (若对话框正打开看此任务,内容已实时更新;最小化/在别处则胶囊亮起完成态)
|
||||
}
|
||||
} catch {
|
||||
// 持久化失败不影响前端已展示的内容
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? '分析失败')
|
||||
patchTask(id, {
|
||||
phase: 'error',
|
||||
error: normalizeAiError(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开对话框(活跃任务或历史报告)。 */
|
||||
export function openDialog(taskId: string) {
|
||||
activeDialogTaskId = taskId
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 最小化对话框 → 变成气泡。 */
|
||||
export function minimizeDialog() {
|
||||
dialogMinimized = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 关闭对话框(活跃任务继续在后台跑,仅移除对话框视图)。
|
||||
* 对历史报告:仅关闭视图。
|
||||
*/
|
||||
export function closeDialog() {
|
||||
activeDialogTaskId = null
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 从气泡恢复对话框。
|
||||
* 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹);
|
||||
* 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。
|
||||
*/
|
||||
export function restoreDialog(taskId: string) {
|
||||
const t = activeTasks.find(x => x.id === taskId)
|
||||
if (t && (t.phase === 'done' || t.phase === 'error')) {
|
||||
patchTask(taskId, { dismissed: true })
|
||||
}
|
||||
activeDialogTaskId = taskId
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 重试一个失败/已完成的任务(以新任务方式重新分析)。 */
|
||||
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
|
||||
return startAnalysis(task.symbol, task.name, task.focus)
|
||||
}
|
||||
|
||||
/** 删除历史报告。 */
|
||||
export async function deleteReport(reportId: string): Promise<void> {
|
||||
try {
|
||||
await api.financialReportDelete(reportId)
|
||||
history = history.filter(r => r.id !== reportId)
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开历史报告到对话框。 */
|
||||
export function openHistoryReport(reportId: string) {
|
||||
activeDialogTaskId = `history:${reportId}`
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 概念/行业分析 — 数据适配层
|
||||
*
|
||||
* 处理两种扩展数据结构:
|
||||
* - 结构 A(个股维度):每行一只股票,维度字段(如 concept)存该股票所属的概念/行业
|
||||
* - 结构 B(板块维度):每行一个概念/行业,带成分股列表(如 constituents: [...])
|
||||
*
|
||||
* 两种结构统一输出为 DimensionGroup[],供页面组件消费。
|
||||
*/
|
||||
|
||||
import type { ExtDataConfig, ExtDataField, ExtDataRowsResult } from '@/lib/api'
|
||||
|
||||
// ===== 公共类型 =====
|
||||
|
||||
export interface StockRow {
|
||||
symbol: string
|
||||
code?: string
|
||||
name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DimensionGroup {
|
||||
/** 维度名称(概念名/行业名) */
|
||||
key: string
|
||||
/** 成分股数量 */
|
||||
count: number
|
||||
/** 成分股原始行 */
|
||||
stocks: StockRow[]
|
||||
/** 聚合指标(如涨跌幅均值等) */
|
||||
metrics: Record<string, number | null>
|
||||
}
|
||||
|
||||
export interface ResolvedDimension {
|
||||
/** 是否成功解析 */
|
||||
ok: boolean
|
||||
/** 数据结构类型 */
|
||||
structure: 'per_stock' | 'per_dimension' | 'unknown'
|
||||
/** 维度字段名 */
|
||||
dimensionField: string
|
||||
/** 所有解析出的分组 */
|
||||
groups: DimensionGroup[]
|
||||
/** 原始全部行(结构 A 下为原始行,结构 B 下展平后的全部成分股) */
|
||||
allStocks: StockRow[]
|
||||
/** 解析提示 */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
// ===== 结构探测 =====
|
||||
|
||||
const SEPARATORS = /[、,,;;|/\s]+/
|
||||
const CONSTITUENT_KEYS = [
|
||||
'constituents', '成分股', 'stocks', 'members', 'codes', 'list',
|
||||
'symbol_list', 'stock_list', 'member_list',
|
||||
]
|
||||
const DIMENSION_NAME_KEYS = [
|
||||
'name', '概念名称', '概念', '行业名称', '行业', '板块名称', '板块',
|
||||
'concept', 'industry', 'sector', 'theme', 'title', 'label',
|
||||
]
|
||||
|
||||
/** 检测行是否是"板块维度"结构(含成分股列表字段) */
|
||||
function detectConstituentField(fields: ExtDataField[]): string | null {
|
||||
return fields.find(f =>
|
||||
CONSTITUENT_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
|
||||
)?.name ?? null
|
||||
}
|
||||
|
||||
/** 检测维度名称字段 */
|
||||
function detectDimensionNameField(fields: ExtDataField[]): string | null {
|
||||
return fields.find(f =>
|
||||
DIMENSION_NAME_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
|
||||
)?.name ?? null
|
||||
}
|
||||
|
||||
/** 从候选名中选取最佳维度字段(结构 A) */
|
||||
export function pickDimensionField(
|
||||
fields: ExtDataField[],
|
||||
candidates: string[],
|
||||
): string {
|
||||
const nonMeta = fields.filter(f =>
|
||||
!['symbol', 'code', 'name', '股票简称', '股票代码', 'date'].includes(f.name)
|
||||
)
|
||||
for (const c of candidates) {
|
||||
const m = nonMeta.find(f =>
|
||||
f.name.toLowerCase().includes(c.toLowerCase()) ||
|
||||
f.label?.toLowerCase().includes(c.toLowerCase())
|
||||
)
|
||||
if (m) return m.name
|
||||
}
|
||||
// 回退:第一个非数值字段
|
||||
return nonMeta.find(f => f.dtype !== 'int' && f.dtype !== 'float')?.name ?? nonMeta[0]?.name ?? ''
|
||||
}
|
||||
|
||||
/** 判断字段是否为数值类型 */
|
||||
function isNumericField(f: ExtDataField): boolean {
|
||||
return f.dtype === 'int' || f.dtype === 'float'
|
||||
}
|
||||
|
||||
// ===== 结构 A 解析:个股维度 =====
|
||||
|
||||
function parsePerStock(
|
||||
rows: Record<string, any>[],
|
||||
dimensionField: string,
|
||||
numericFields: string[],
|
||||
): DimensionGroup[] {
|
||||
const map = new Map<string, StockRow[]>()
|
||||
|
||||
for (const row of rows) {
|
||||
const raw = row[dimensionField]
|
||||
if (raw == null) continue
|
||||
const text = String(raw).trim()
|
||||
if (!text) continue
|
||||
|
||||
// 支持多值分隔(如 "人工智能,芯片,5G")
|
||||
const values = text.split(SEPARATORS).map(s => s.trim()).filter(Boolean)
|
||||
const stock: StockRow = { ...row, symbol: row.symbol ?? row.code ?? '' }
|
||||
|
||||
for (const v of values) {
|
||||
const list = map.get(v) ?? []
|
||||
list.push(stock)
|
||||
map.set(v, list)
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.map(([key, stocks]) => ({
|
||||
key,
|
||||
count: stocks.length,
|
||||
stocks,
|
||||
metrics: computeMetrics(stocks, numericFields),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
// ===== 结构 B 解析:板块维度 =====
|
||||
|
||||
function parsePerDimension(
|
||||
rows: Record<string, any>[],
|
||||
constituentField: string,
|
||||
nameField: string,
|
||||
numericFields: string[],
|
||||
): DimensionGroup[] {
|
||||
const allStocks: StockRow[] = []
|
||||
|
||||
const groups = rows.map(row => {
|
||||
const key = String(row[nameField] ?? row[constituentField] ?? '').trim()
|
||||
if (!key) return null
|
||||
|
||||
// 成分股可能是字符串数组、对象数组、逗号分隔字符串
|
||||
const rawList = row[constituentField]
|
||||
const stocks = parseConstituents(rawList)
|
||||
|
||||
stocks.forEach(s => { if (s.symbol) allStocks.push(s) })
|
||||
|
||||
// 维度自身的数值指标也保留
|
||||
const metrics = computeMetrics(stocks, numericFields)
|
||||
// 补上行级别的数值
|
||||
for (const f of numericFields) {
|
||||
if (typeof row[f] === 'number') {
|
||||
metrics[`__dim_${f}`] = row[f]
|
||||
}
|
||||
}
|
||||
|
||||
return { key, count: stocks.length, stocks, metrics } as DimensionGroup
|
||||
}).filter((g): g is DimensionGroup => g !== null && g.key !== '')
|
||||
|
||||
return groups.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
/** 解析成分股字段(支持多种格式) */
|
||||
function parseConstituents(raw: unknown): StockRow[] {
|
||||
if (raw == null) return []
|
||||
if (typeof raw === 'string') {
|
||||
// 逗号/分隔符分隔的股票代码字符串
|
||||
return raw.split(SEPARATORS).map(s => s.trim()).filter(Boolean).map(s => ({
|
||||
symbol: normalizeSymbol(s),
|
||||
code: s,
|
||||
}))
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return { symbol: normalizeSymbol(item), code: item }
|
||||
}
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
const obj = item as Record<string, any>
|
||||
return {
|
||||
symbol: obj.symbol ?? obj.code ?? obj.股票代码 ?? '',
|
||||
code: obj.code ?? obj.symbol ?? '',
|
||||
name: obj.name ?? obj.股票简称 ?? obj.名称 ?? '',
|
||||
...obj,
|
||||
}
|
||||
}
|
||||
return { symbol: String(item) }
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeSymbol(s: string): string {
|
||||
// 尝试补全为 6 位代码
|
||||
if (/^\d{6}$/.test(s)) return s
|
||||
return s
|
||||
}
|
||||
|
||||
// ===== 聚合指标计算 =====
|
||||
|
||||
function computeMetrics(
|
||||
stocks: StockRow[],
|
||||
numericFields: string[],
|
||||
): Record<string, number | null> {
|
||||
const result: Record<string, number | null> = {}
|
||||
for (const f of numericFields) {
|
||||
const vals = stocks
|
||||
.map(s => s[f])
|
||||
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
|
||||
if (vals.length === 0) {
|
||||
result[f] = null
|
||||
} else {
|
||||
result[f] = vals.reduce((a, b) => a + b, 0) / vals.length
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ===== 主入口:自动探测 + 解析 =====
|
||||
|
||||
export function resolveDimension(
|
||||
data: ExtDataRowsResult | null | undefined,
|
||||
config: ExtDataConfig | null | undefined,
|
||||
candidateFields: string[],
|
||||
): ResolvedDimension {
|
||||
if (!data || !config || !data.rows.length) {
|
||||
return { ok: false, structure: 'unknown', dimensionField: '', groups: [], allStocks: [] }
|
||||
}
|
||||
|
||||
const fields = data.fields ?? config.fields
|
||||
const rows = data.rows
|
||||
const numericFields = fields.filter(f => isNumericField(f)).map(f => f.name)
|
||||
|
||||
// 先检测是否为结构 B(板块维度)
|
||||
const constituentField = detectConstituentField(fields)
|
||||
if (constituentField) {
|
||||
const nameField = detectDimensionNameField(fields) ?? 'name'
|
||||
const groups = parsePerDimension(rows, constituentField, nameField, numericFields)
|
||||
const allStocks = groups.flatMap(g => g.stocks)
|
||||
return {
|
||||
ok: true,
|
||||
structure: 'per_dimension',
|
||||
dimensionField: nameField,
|
||||
groups,
|
||||
allStocks,
|
||||
hint: `检测到板块维度结构(成分股字段: ${constituentField})`,
|
||||
}
|
||||
}
|
||||
|
||||
// 结构 A(个股维度)
|
||||
const dimensionField = pickDimensionField(fields, candidateFields)
|
||||
if (!dimensionField) {
|
||||
return {
|
||||
ok: false,
|
||||
structure: 'unknown',
|
||||
dimensionField: '',
|
||||
groups: [],
|
||||
allStocks: rows as StockRow[],
|
||||
hint: '未找到合适的维度字段',
|
||||
}
|
||||
}
|
||||
|
||||
const groups = parsePerStock(rows, dimensionField, numericFields)
|
||||
const allStocks = rows as StockRow[]
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
structure: 'per_stock',
|
||||
dimensionField,
|
||||
groups,
|
||||
allStocks,
|
||||
hint: `按 ${dimensionField} 分组,共 ${groups.length} 个维度`,
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 行情数据关联 =====
|
||||
|
||||
export interface QuoteMap {
|
||||
symbol: string
|
||||
price?: number
|
||||
pct?: number
|
||||
change_pct?: number
|
||||
name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 构建 symbol → quote 的快速查找 */
|
||||
export function buildQuoteMap(quotes: QuoteMap[]): Map<string, QuoteMap> {
|
||||
const map = new Map<string, QuoteMap>()
|
||||
for (const q of quotes) {
|
||||
if (q.symbol) map.set(q.symbol, q)
|
||||
// 也用纯数字代码做索引
|
||||
const code = q.symbol?.replace(/\.\w+$/, '')
|
||||
if (code) map.set(code, q)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 为分组计算行情聚合指标 */
|
||||
export function computeQuoteMetrics(
|
||||
stocks: StockRow[],
|
||||
quoteMap: Map<string, QuoteMap>,
|
||||
): {
|
||||
avgPct: number | null
|
||||
upCount: number
|
||||
downCount: number
|
||||
flatCount: number
|
||||
totalVolume: number
|
||||
} {
|
||||
let up = 0, down = 0, flat = 0, totalVol = 0
|
||||
let sumPct = 0, countPct = 0
|
||||
|
||||
for (const s of stocks) {
|
||||
const sym = String(s.symbol ?? '')
|
||||
const q = quoteMap.get(sym) ?? quoteMap.get(sym.replace(/\.\w+$/, ''))
|
||||
if (!q) continue
|
||||
const pct = q.pct ?? q.change_pct
|
||||
if (pct != null && typeof pct === 'number' && Number.isFinite(pct)) {
|
||||
sumPct += pct
|
||||
countPct++
|
||||
if (pct > 0) up++
|
||||
else if (pct < 0) down++
|
||||
else flat++
|
||||
}
|
||||
totalVol += (typeof q.price === 'number' ? 1 : 0) // 简化计数
|
||||
}
|
||||
|
||||
return {
|
||||
avgPct: countPct > 0 ? sumPct / countPct : null,
|
||||
upCount: up,
|
||||
downCount: down,
|
||||
flatCount: flat,
|
||||
totalVolume: totalVol,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { StrategyBacktestResult } from './api'
|
||||
|
||||
/**
|
||||
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
|
||||
*
|
||||
* 特性:
|
||||
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
|
||||
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
|
||||
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
|
||||
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
|
||||
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
|
||||
*/
|
||||
|
||||
export interface BacktestProgress {
|
||||
day: number
|
||||
total: number
|
||||
date: string
|
||||
equity: number
|
||||
}
|
||||
|
||||
export interface BacktestTask {
|
||||
id: number
|
||||
isPending: boolean
|
||||
result: StrategyBacktestResult | null
|
||||
progress: BacktestProgress | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
let current: BacktestTask | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
let taskSeq = 0
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
const RECONNECT_KEY = 'backtest_reconnect'
|
||||
|
||||
function emit() {
|
||||
listeners.forEach(fn => fn())
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return current
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 查询字符串构建 */
|
||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const sp = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') sp.set(k, String(v))
|
||||
}
|
||||
return sp.toString()
|
||||
}
|
||||
|
||||
/** 连接 SSE (新建或重连都用这个) */
|
||||
function connectSSE(url: string): void {
|
||||
const id = current?.id ?? ++taskSeq
|
||||
|
||||
// 关闭旧连接
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
const es = new EventSource(url)
|
||||
eventSource = es
|
||||
|
||||
es.addEventListener('progress', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
try {
|
||||
const prog = JSON.parse(e.data) as BacktestProgress
|
||||
current = { ...current, progress: prog }
|
||||
emit()
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
es.addEventListener('done', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
try {
|
||||
const result = JSON.parse(e.data) as StrategyBacktestResult
|
||||
current = { ...current, isPending: false, result, error: null }
|
||||
emit()
|
||||
} catch {
|
||||
current = { ...current, isPending: false, error: '结果解析失败' }
|
||||
emit()
|
||||
}
|
||||
es.close()
|
||||
eventSource = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
})
|
||||
|
||||
es.addEventListener('error', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
|
||||
if (e.data) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)?.message ?? '回测出错'
|
||||
current = { ...current, isPending: false, error: msg }
|
||||
emit()
|
||||
} catch {
|
||||
current = { ...current, isPending: false, error: '回测出错' }
|
||||
emit()
|
||||
}
|
||||
es.close()
|
||||
eventSource = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
}
|
||||
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
|
||||
})
|
||||
}
|
||||
|
||||
/** 启动一次 SSE 回测任务 */
|
||||
export function startBacktest(params: {
|
||||
strategy_id: string
|
||||
symbols?: string[] | null
|
||||
start?: string | null
|
||||
end?: string | null
|
||||
matching?: string
|
||||
entry_fill?: string
|
||||
exit_fill?: string
|
||||
fees_pct?: number
|
||||
slippage_bps?: number
|
||||
max_positions?: number
|
||||
max_exposure_pct?: number
|
||||
initial_capital?: number
|
||||
position_sizing?: string
|
||||
params?: Record<string, any> | null
|
||||
overrides?: Record<string, any> | null
|
||||
mode?: 'position' | 'full'
|
||||
holding_days?: number
|
||||
}): void {
|
||||
// 取消之前的任务状态
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
const id = ++taskSeq
|
||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
||||
emit()
|
||||
|
||||
const qs = buildQuery({
|
||||
strategy_id: params.strategy_id,
|
||||
symbols: params.symbols?.join(','),
|
||||
start: params.start ?? undefined,
|
||||
end: params.end ?? undefined,
|
||||
matching: params.matching,
|
||||
entry_fill: params.entry_fill,
|
||||
exit_fill: params.exit_fill,
|
||||
fees_pct: params.fees_pct,
|
||||
slippage_bps: params.slippage_bps,
|
||||
max_positions: params.max_positions,
|
||||
max_exposure_pct: params.max_exposure_pct,
|
||||
initial_capital: params.initial_capital,
|
||||
position_sizing: params.position_sizing,
|
||||
params: params.params ? JSON.stringify(params.params) : undefined,
|
||||
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
|
||||
mode: params.mode,
|
||||
holding_days: params.holding_days,
|
||||
})
|
||||
|
||||
// 存 reconnect 信息 (刷新后用)
|
||||
localStorage.setItem(RECONNECT_KEY, qs)
|
||||
|
||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
||||
}
|
||||
|
||||
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
|
||||
export async function stopBacktest(): Promise<void> {
|
||||
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
|
||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
||||
if (qs) {
|
||||
// 解析出参数, 用 fetch 调 cancel
|
||||
try {
|
||||
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
|
||||
// 后端会找到同一个 job 并返回它的 job_key? 不行。
|
||||
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
|
||||
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
|
||||
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
|
||||
await fetch('/api/backtest/strategy/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ qs }),
|
||||
}).catch(() => {})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
if (current?.isPending) {
|
||||
current = { ...current, isPending: false, error: '已取消' }
|
||||
emit()
|
||||
}
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
}
|
||||
|
||||
/** 清除任务状态 (隐藏提示) */
|
||||
export function clearBacktest(): void {
|
||||
current = null
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
|
||||
export function tryReconnect(): boolean {
|
||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
||||
if (!qs) return false
|
||||
// 有未完成的任务, 重连
|
||||
const id = ++taskSeq
|
||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
||||
emit()
|
||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/** React hook: 读取当前全局回测任务状态 */
|
||||
export function useBacktestTask(): BacktestTask | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 板块判断工具函数
|
||||
|
||||
export const BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] as const
|
||||
export type BoardType = (typeof BOARDS)[number]
|
||||
|
||||
/** 根据股票代码判断板块 */
|
||||
export function getBoardType(symbol: string): BoardType | null {
|
||||
if (/^(300|301)/.test(symbol)) return '创业板'
|
||||
if (/^688/.test(symbol)) return '科创板'
|
||||
if (/\.BJ$/.test(symbol)) return '北交所'
|
||||
if (/^60[0135]/.test(symbol)) return '沪主板'
|
||||
if (/^00[012]/.test(symbol)) return '深主板'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 板块简称标签: 主板返回空字符串(不显示), 创/科/北 等返回简称 */
|
||||
export function boardTag(symbol: string): string {
|
||||
const b = getBoardType(symbol)
|
||||
if (!b) return ''
|
||||
if (b === '沪主板' || b === '深主板') return ''
|
||||
if (b === '创业板') return '创'
|
||||
if (b === '科创板') return '科'
|
||||
if (b === '北交所') return '北'
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// capability 内部名 → 用户能理解的中文标签
|
||||
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
|
||||
'quote.by_symbol': { name: '自选股实时监控', hint: 'Free 可按标的查询实时行情,用于少量自选股监控' },
|
||||
'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' },
|
||||
'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' },
|
||||
'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' },
|
||||
'kline.daily.batch': { name: '日 K(批量)', hint: '一次拿多只股票的日 K — 选股 / 信号扫描 必需' },
|
||||
'kline.minute.by_symbol': { name: '分钟 K(按标的)', hint: '单股 1m/5m/15m/30m/60m K 线' },
|
||||
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
|
||||
|
||||
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
|
||||
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
|
||||
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
|
||||
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
|
||||
}
|
||||
|
||||
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
|
||||
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
|
||||
// none = None 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
|
||||
export const TIER_RANK: Record<string, number> = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 }
|
||||
export const EXPERT_RANK = TIER_RANK.expert
|
||||
|
||||
export function tierRank(label: string): number {
|
||||
const base = (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
|
||||
return TIER_RANK[base] ?? -1
|
||||
}
|
||||
|
||||
export function isExpertOrAbove(label: string): boolean {
|
||||
return tierRank(label) >= EXPERT_RANK
|
||||
}
|
||||
|
||||
/** 档位完整样式(tag 背景 + 圆点 + 文字渐变), 与左侧菜单 TierBadge 一致 */
|
||||
export interface TierStyle {
|
||||
tagBg: { background: string }
|
||||
dotStyle: { background: string }
|
||||
labelTextStyle: { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string }
|
||||
desc: string
|
||||
}
|
||||
|
||||
const TIER_STYLE: Record<string, TierStyle> = {
|
||||
none: {
|
||||
desc: '未配置 Key · 仅历史日K',
|
||||
tagBg: { background: 'rgba(113,113,122,0.15)' },
|
||||
dotStyle: { background: '#52525b' },
|
||||
labelTextStyle: { color: '#71717a' },
|
||||
},
|
||||
free: {
|
||||
desc: '历史日K · 自选实时',
|
||||
tagBg: { background: 'rgba(113,113,122,0.3)' },
|
||||
dotStyle: { background: '#71717a' },
|
||||
labelTextStyle: { color: '#a1a1aa' },
|
||||
},
|
||||
starter: {
|
||||
desc: '除权因子 · 全市场实时',
|
||||
tagBg: { background: 'rgba(59,130,246,0.2)' },
|
||||
dotStyle: { background: '#3b82f6' },
|
||||
labelTextStyle: { color: '#60a5fa' },
|
||||
},
|
||||
pro: {
|
||||
desc: '分钟K · 盘口',
|
||||
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
|
||||
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
|
||||
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||
},
|
||||
expert: {
|
||||
desc: 'WebSocket · 财务数据',
|
||||
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
|
||||
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
|
||||
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||
},
|
||||
}
|
||||
|
||||
/** 从档位 label 提取基础档位名(小写): "Expert +" → "expert" */
|
||||
export function tierBaseName(label: string): string {
|
||||
return (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** 返回档位完整样式 */
|
||||
export function tierStyle(label: string): TierStyle {
|
||||
return TIER_STYLE[tierBaseName(label)] ?? TIER_STYLE.free
|
||||
}
|
||||
|
||||
/** 所有档位(有序, 供档位列表渲染) */
|
||||
export const ALL_TIERS = ['none', 'free', 'starter', 'pro', 'expert'] as const
|
||||
|
||||
/** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */
|
||||
export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } {
|
||||
return tierStyle(label).labelTextStyle
|
||||
}
|
||||
|
||||
/** 渲染档位 tag(与左侧菜单一致的胶囊样式) */
|
||||
export function TierTag({ label, className = '' }: { label: string; className?: string }) {
|
||||
const t = tierStyle(label)
|
||||
const base = tierBaseName(label)
|
||||
// none 档显示英文「None」,其余档显示英文档名
|
||||
const display = base === 'none' ? 'None' : base
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
|
||||
style={t.tagBg}
|
||||
>
|
||||
<span className="truncate capitalize" style={t.labelTextStyle}>{display}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 全局颜色别名 — 集中管理项目中重复使用的色彩组合。
|
||||
*
|
||||
* 不修改 Tailwind 配置和 CSS 变量,仅作为语义化引用。
|
||||
* 使用方式:
|
||||
* import { color } from '@/lib/colors'
|
||||
* className={`${color.select.bg} ${color.select.text}`}
|
||||
* // → bg-sky-500 text-sky-400
|
||||
*/
|
||||
|
||||
export const color = {
|
||||
/** 选中/激活态 — sky 色系 */
|
||||
select: {
|
||||
bg: 'bg-sky-500',
|
||||
text: 'text-sky-400',
|
||||
border: 'border-sky-400/40',
|
||||
bgLight: 'bg-sky-400/10',
|
||||
borderHover: 'hover:border-sky-400/30',
|
||||
},
|
||||
/** 选股条件区 section 标题色 */
|
||||
filterSection: 'text-sky-400',
|
||||
|
||||
/** 评分权重正常指示 — emerald 色系 */
|
||||
ok: 'text-emerald-400',
|
||||
|
||||
/** 评分权重异常警告 — amber 色系 */
|
||||
scoreWarn: 'text-amber-400',
|
||||
|
||||
/** 交易参数区 section 标题色 */
|
||||
tradeSection: 'text-emerald-400',
|
||||
} as const
|
||||
@@ -0,0 +1,81 @@
|
||||
// 数字 / 价格 / 涨跌幅 格式化(§6.0.2 等宽数字)
|
||||
|
||||
export function fmtPrice(v: number | null | undefined, digits = 2): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return v.toFixed(digits)
|
||||
}
|
||||
|
||||
export function fmtPct(v: number | null | undefined, digits = 2): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return `${sign}${(v * 100).toFixed(digits)}%`
|
||||
}
|
||||
|
||||
export function fmtVolume(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
if (v >= 1e8) return `${(v / 1e8).toFixed(2)}亿`
|
||||
if (v >= 1e4) return `${(v / 1e4).toFixed(2)}万`
|
||||
return v.toFixed(0)
|
||||
}
|
||||
|
||||
// A 股语义色:红涨绿跌 → 仅用于价格相关元素
|
||||
export function priceColorClass(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v) || v === 0) return 'text-muted'
|
||||
return v > 0 ? 'text-bull' : 'text-bear'
|
||||
}
|
||||
|
||||
export function fmtBigNum(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
if (v >= 1_000_000_000_000) return `${(v / 1_000_000_000_000).toFixed(2)}万亿`
|
||||
if (v >= 100_000_000) return `${(v / 100_000_000).toFixed(2)}亿`
|
||||
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}万`
|
||||
return v.toFixed(0)
|
||||
}
|
||||
|
||||
export function fmtDate(s: string | Date | null | undefined): string {
|
||||
if (s == null) return '—'
|
||||
const d = typeof s === 'string' ? new Date(s) : s
|
||||
if (isNaN(d.getTime())) return String(s)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ===== Data 页面工具函数 =====
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1)}亿`
|
||||
if (n >= 10_000) return `${(n / 10_000).toFixed(1)}万`
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
const rm = m % 60
|
||||
return rm > 0 ? `${h}h ${rm}m` : `${h}h`
|
||||
}
|
||||
|
||||
export function formatScheduleDatePart(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatScheduleTimePart(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function isToday(iso: string): boolean {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate()
|
||||
}
|
||||
|
||||
export function formatLogTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 通用列表列配置底座。
|
||||
*
|
||||
* 业务页面只负责定义内置列、分组和持久化 adapter;拖拽、显隐、扩展列参数等
|
||||
* 公共能力集中在这里,避免每个股票列表重复实现。
|
||||
*/
|
||||
|
||||
export type ColumnSource =
|
||||
| { type: 'builtin'; key: string }
|
||||
| { type: 'ext'; configId: string; fieldName: string; fieldLabel?: string }
|
||||
| { type: 'computed'; key: string }
|
||||
|
||||
/** 扩展列字符串值渲染配置 */
|
||||
export interface ExtColumnDisplayConfig {
|
||||
/** 显示模式: tag=分隔为标签, text=纯文本 */
|
||||
displayMode: 'tag' | 'text'
|
||||
/** 自定义分隔符,留空使用默认 [、,,;;-] */
|
||||
separator?: string
|
||||
/** 列最大宽度 CSS 值,如 "200px" */
|
||||
maxWidth?: string
|
||||
/** 标签显示上限,0 或 undefined=全部显示 */
|
||||
maxTags?: number
|
||||
/** 隐藏的标签索引(0-based),在 maxTags 范围内按位置隐藏 */
|
||||
hiddenIndices?: number[]
|
||||
/** 标签排列方向: horizontal=横向(默认), vertical=竖向 */
|
||||
tagLayout?: 'horizontal' | 'vertical'
|
||||
}
|
||||
|
||||
/** 日k列渲染配置(builtin: candle 列专用) */
|
||||
export interface CandleColumnConfig {
|
||||
/** 开启(显示蜡烛图)时单元格宽度 px */
|
||||
enabledWidth?: number
|
||||
/** 开启时单元格高度 px */
|
||||
enabledHeight?: number
|
||||
/** 关闭(收起)时单元格宽度 px */
|
||||
disabledWidth?: number
|
||||
/** 关闭时单元格高度 px */
|
||||
disabledHeight?: number
|
||||
/** 显示最近多少个交易日的日k */
|
||||
days?: number
|
||||
}
|
||||
|
||||
/** 日k列配置默认值(与改造前 MiniCandlestick 硬编码值一致) */
|
||||
export const DEFAULT_CANDLE_CONFIG: Required<CandleColumnConfig> = {
|
||||
enabledWidth: 100,
|
||||
enabledHeight: 80,
|
||||
disabledWidth: 40,
|
||||
disabledHeight: 40,
|
||||
days: 12,
|
||||
}
|
||||
|
||||
/** 数值边界(设置过大取上限,过小取最小值) */
|
||||
const CANDLE_BOUNDS = {
|
||||
enabledWidth: { min: 40, max: 300 },
|
||||
enabledHeight: { min: 32, max: 200 },
|
||||
disabledWidth: { min: 20, max: 200 },
|
||||
disabledHeight:{ min: 20, max: 200 },
|
||||
days: { min: 1, max: 60 },
|
||||
} as const
|
||||
|
||||
function clampNum(v: unknown, bounds: { min: number; max: number }, fallback: number): number {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
||||
return Math.min(bounds.max, Math.max(bounds.min, n))
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并用户配置与默认值,并对越界数值做钳制(过大取上限,过小取最小值)。
|
||||
* 返回字段齐全的配置,调用方可直接解构使用。
|
||||
*/
|
||||
export function resolveCandleConfig(cfg: CandleColumnConfig | undefined): Required<CandleColumnConfig> {
|
||||
const c = cfg ?? {}
|
||||
return {
|
||||
enabledWidth: clampNum(c.enabledWidth, CANDLE_BOUNDS.enabledWidth, DEFAULT_CANDLE_CONFIG.enabledWidth),
|
||||
enabledHeight: clampNum(c.enabledHeight, CANDLE_BOUNDS.enabledHeight, DEFAULT_CANDLE_CONFIG.enabledHeight),
|
||||
disabledWidth: clampNum(c.disabledWidth, CANDLE_BOUNDS.disabledWidth, DEFAULT_CANDLE_CONFIG.disabledWidth),
|
||||
disabledHeight: clampNum(c.disabledHeight, CANDLE_BOUNDS.disabledHeight, DEFAULT_CANDLE_CONFIG.disabledHeight),
|
||||
days: clampNum(c.days, CANDLE_BOUNDS.days, DEFAULT_CANDLE_CONFIG.days),
|
||||
}
|
||||
}
|
||||
|
||||
export interface ColumnConfig {
|
||||
id: string // 唯一标识,如 "builtin:price" 或 "ext:my_table:score"
|
||||
source: ColumnSource
|
||||
label: string // 用户看到的表头名
|
||||
visible: boolean // 是否显示
|
||||
pinned?: boolean // 固定列不可隐藏(代码/名称、操作)
|
||||
align?: 'left' | 'center' | 'right'
|
||||
/** 扩展列显示配置(仅 ext 类型生效) */
|
||||
extDisplay?: ExtColumnDisplayConfig
|
||||
/** 日k列渲染配置(仅 builtin: candle 列生效) */
|
||||
candleConfig?: CandleColumnConfig
|
||||
/** 信息条场景:是否单独占一行显示(仅 StockInfoBar 生效,表格场景忽略) */
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
export interface ColumnGroup {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
/** builtin/computed source key 列表 */
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
export const DEFAULT_ACTION_COLUMN_ID = 'builtin:action'
|
||||
|
||||
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action) */
|
||||
export function serializeColumns(
|
||||
columns: ColumnConfig[],
|
||||
actionColumnId = DEFAULT_ACTION_COLUMN_ID,
|
||||
): ColumnConfig[] {
|
||||
return columns.filter(c => !c.pinned && c.id !== actionColumnId)
|
||||
}
|
||||
|
||||
export interface MergeColumnsOptions {
|
||||
actionColumnId?: string
|
||||
pinnedFirstIds?: string[]
|
||||
}
|
||||
|
||||
/** 合并用户保存的列与默认列,保留用户顺序并补齐新增默认列。 */
|
||||
export function mergeColumns(
|
||||
saved: ColumnConfig[] | null | undefined,
|
||||
defaults: ColumnConfig[],
|
||||
options: MergeColumnsOptions = {},
|
||||
): ColumnConfig[] {
|
||||
const actionColumnId = options.actionColumnId ?? DEFAULT_ACTION_COLUMN_ID
|
||||
const pinnedFirstIds = options.pinnedFirstIds ?? ['builtin:symbol']
|
||||
const normalizedSaved = Array.isArray(saved) ? saved : []
|
||||
const result: ColumnConfig[] = []
|
||||
const savedMap = new Map(normalizedSaved.map(c => [c.id, c]))
|
||||
const defaultMap = new Map(defaults.map(c => [c.id, c]))
|
||||
|
||||
// 1. 按用户保存顺序排列
|
||||
for (const col of normalizedSaved) {
|
||||
if (!col || col.id === actionColumnId) continue
|
||||
const def = defaultMap.get(col.id)
|
||||
if (def) {
|
||||
// 内置列: label/source/align/pinned 以默认定义为准;visible 使用用户配置;
|
||||
// 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失
|
||||
result.push({
|
||||
...def,
|
||||
visible: col.visible,
|
||||
...(col.candleConfig ? { candleConfig: col.candleConfig } : {}),
|
||||
...(col.extDisplay ? { extDisplay: col.extDisplay } : {}),
|
||||
...(col.standalone ? { standalone: col.standalone } : {}),
|
||||
})
|
||||
} else if (col.source?.type === 'ext') {
|
||||
// ext 列: 保留用户配置,清理旧 label 中的括号后缀
|
||||
let extCol = col
|
||||
if (col.label.includes('(') || col.label.includes('(')) {
|
||||
extCol = {
|
||||
...col,
|
||||
label: col.source.fieldLabel || col.label.replace(/[((].*/, '').trim() || col.source.fieldName,
|
||||
}
|
||||
}
|
||||
result.push(extCol)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 补充新增的默认列
|
||||
for (const def of defaults) {
|
||||
if (!savedMap.has(def.id)) result.push(def)
|
||||
}
|
||||
|
||||
// 3. 固定优先列放到最前,例如代码/名称
|
||||
for (let i = pinnedFirstIds.length - 1; i >= 0; i -= 1) {
|
||||
const id = pinnedFirstIds[i]
|
||||
const idx = result.findIndex(c => c.id === id)
|
||||
if (idx > 0) {
|
||||
const [col] = result.splice(idx, 1)
|
||||
result.unshift(col)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口。 */
|
||||
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
|
||||
return columns
|
||||
.filter(c => c.visible && c.source.type === 'ext')
|
||||
.map(c => `${(c.source as { type: 'ext'; configId: string; fieldName: string }).configId}.${(c.source as { type: 'ext'; configId: string; fieldName: string }).fieldName}`)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** 根据 ext schema 数据创建 ext 列配置。 */
|
||||
export function createExtColumn(
|
||||
configId: string,
|
||||
_configLabel: string,
|
||||
fieldName: string,
|
||||
fieldLabel?: string,
|
||||
): ColumnConfig {
|
||||
return {
|
||||
id: `ext:${configId}:${fieldName}`,
|
||||
source: { type: 'ext', configId, fieldName, fieldLabel },
|
||||
label: fieldLabel || fieldName,
|
||||
visible: false,
|
||||
align: 'center',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/**
|
||||
* 监控中心未读触发记录徽标 — 全局 store + localStorage 持久化。
|
||||
*
|
||||
* 核心逻辑:
|
||||
* - 在监控中心页面时, 每次收到新推送都同步更新 lastSeen (看到=已读)
|
||||
* - 离开监控中心后, 新推送才计入未读
|
||||
* - 刷新页面从 localStorage 恢复 lastSeen, 未读 = 期间新增
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'monitor_last_seen_total'
|
||||
|
||||
let currentTotal = 0
|
||||
let lastSeenTotal = readSeen()
|
||||
let onMonitorPage = false // 当前是否在监控中心页面
|
||||
let pendingSeen = false // Monitor mount 请求 markSeen, 等 currentTotal 就绪
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function readSeen(): number {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
if (v === null) return -1 // 从未设置过 → 未初始化
|
||||
return parseInt(v, 10) || 0
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
function writeSeen(v: number) {
|
||||
try { localStorage.setItem(STORAGE_KEY, String(v)) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function syncSeen() {
|
||||
if (lastSeenTotal !== currentTotal) {
|
||||
lastSeenTotal = currentTotal
|
||||
writeSeen(currentTotal)
|
||||
}
|
||||
}
|
||||
|
||||
function emit() {
|
||||
listeners.forEach(fn => fn())
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return Math.max(0, currentTotal - lastSeenTotal)
|
||||
}
|
||||
|
||||
/** 轮询更新最新总数 (Layout 层调用)。 */
|
||||
export function setCurrentTotal(total: number): void {
|
||||
// total=0 视为未初始化, 不更新 (避免渲染期 data=undefined 传 0 重置 lastSeen)
|
||||
if (total <= 0) return
|
||||
|
||||
// 首次初始化: lastSeen <= 0 (从未设置 -1, 或历史为 0) → 把已读基线设为当前总数
|
||||
// 否则 lastSeen=0 + total=1 会被误算成"1条未读" (首次进入就显示徽标的 bug)
|
||||
if (lastSeenTotal <= 0) {
|
||||
lastSeenTotal = total
|
||||
writeSeen(total)
|
||||
}
|
||||
// 总数减少 (清空) → 同步重置
|
||||
if (total < lastSeenTotal) {
|
||||
lastSeenTotal = total
|
||||
writeSeen(total)
|
||||
}
|
||||
|
||||
const changed = total !== currentTotal
|
||||
currentTotal = total
|
||||
|
||||
// 消费 pending markSeen
|
||||
if (pendingSeen) {
|
||||
pendingSeen = false
|
||||
syncSeen()
|
||||
}
|
||||
// ★ 在监控中心页面期间: 收到新推送立即同步 (看到=已读, 不计入未读)
|
||||
else if (onMonitorPage && changed) {
|
||||
syncSeen()
|
||||
}
|
||||
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 进入监控页时调用。 */
|
||||
export function markSeen(): void {
|
||||
onMonitorPage = true
|
||||
if (currentTotal > 0) {
|
||||
syncSeen()
|
||||
emit()
|
||||
} else {
|
||||
pendingSeen = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 离开监控页时调用 (停止同步, 之后新增才计入未读)。 */
|
||||
export function leaveMonitorPage(): void {
|
||||
onMonitorPage = false
|
||||
pendingSeen = false
|
||||
// 不在此处 syncSeen — lastSeen 保持页面期间最后一次同步的值即可
|
||||
// (避免 currentTotal 此刻还没刷新到最新, 写入偏小的值)
|
||||
}
|
||||
|
||||
/** 记录被清空时调用。 */
|
||||
export function resetBadge(): void {
|
||||
currentTotal = 0
|
||||
lastSeenTotal = 0
|
||||
pendingSeen = false
|
||||
writeSeen(0)
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 读取当前未读数。 */
|
||||
export function useUnreadAlerts(): number {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => 0)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 通知声效 — 用 Web Audio API 合成, 无需音频文件。
|
||||
*
|
||||
* 声效列表 (纯代码合成, 不同频率/波形/节奏):
|
||||
* - ding: 清脆"叮" (默认, 适合一般提醒)
|
||||
* - chime: 两音阶风铃 (适合策略信号)
|
||||
* - alert: 急促警报 (适合重要/异动)
|
||||
* - soft: 柔和低音 (适合价格提醒)
|
||||
* - none: 无声
|
||||
*/
|
||||
|
||||
let _audioCtx: AudioContext | null = null
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
try {
|
||||
if (!_audioCtx) {
|
||||
_audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
}
|
||||
if (_audioCtx.state === 'suspended') _audioCtx.resume()
|
||||
return _audioCtx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 播放单个音符 */
|
||||
function playTone(ctx: AudioContext, freq: number, start: number, duration: number, type: OscillatorType = 'sine', gain: number = 0.15) {
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.type = type
|
||||
osc.frequency.value = freq
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
const t = ctx.currentTime + start
|
||||
g.gain.setValueAtTime(0, t)
|
||||
g.gain.linearRampToValueAtTime(gain, t + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
||||
osc.start(t)
|
||||
osc.stop(t + duration + 0.05)
|
||||
}
|
||||
|
||||
const SOUND_PRESETS: Record<string, (ctx: AudioContext) => void> = {
|
||||
// 清脆"叮" — 一个正弦波短音
|
||||
ding: (ctx) => {
|
||||
playTone(ctx, 880, 0, 0.3, 'sine', 0.2)
|
||||
},
|
||||
// 两音阶风铃 — 高低两个音
|
||||
chime: (ctx) => {
|
||||
playTone(ctx, 660, 0, 0.25, 'sine', 0.15)
|
||||
playTone(ctx, 990, 0.12, 0.35, 'sine', 0.15)
|
||||
},
|
||||
// 急促警报 — 三个快速方波
|
||||
alert: (ctx) => {
|
||||
playTone(ctx, 800, 0, 0.1, 'square', 0.12)
|
||||
playTone(ctx, 800, 0.15, 0.1, 'square', 0.12)
|
||||
playTone(ctx, 1000, 0.3, 0.15, 'square', 0.12)
|
||||
},
|
||||
// 柔和低音 — 低频三角波
|
||||
soft: (ctx) => {
|
||||
playTone(ctx, 440, 0, 0.5, 'triangle', 0.15)
|
||||
playTone(ctx, 330, 0.2, 0.5, 'triangle', 0.12)
|
||||
},
|
||||
// 上升音阶 — C-E-G-C 递进 (积极感)
|
||||
rise: (ctx) => {
|
||||
playTone(ctx, 523, 0, 0.12, 'sine', 0.18) // C5
|
||||
playTone(ctx, 659, 0.1, 0.12, 'sine', 0.18) // E5
|
||||
playTone(ctx, 784, 0.2, 0.12, 'sine', 0.18) // G5
|
||||
playTone(ctx, 1047, 0.3, 0.3, 'sine', 0.2) // C6
|
||||
},
|
||||
// 下降音阶 — C-A-F-D (消极/警示感)
|
||||
fall: (ctx) => {
|
||||
playTone(ctx, 523, 0, 0.15, 'sine', 0.18) // C5
|
||||
playTone(ctx, 440, 0.12, 0.15, 'sine', 0.18) // A4
|
||||
playTone(ctx, 349, 0.24, 0.15, 'sine', 0.18) // F4
|
||||
playTone(ctx, 294, 0.36, 0.3, 'sine', 0.18) // D4
|
||||
},
|
||||
// 电子提示音 — 锯齿波短促
|
||||
electronic: (ctx) => {
|
||||
playTone(ctx, 1200, 0, 0.08, 'sawtooth', 0.1)
|
||||
playTone(ctx, 1600, 0.06, 0.08, 'sawtooth', 0.1)
|
||||
playTone(ctx, 1200, 0.12, 0.15, 'sawtooth', 0.1)
|
||||
},
|
||||
// 水滴 — 极高频短音, 清脆
|
||||
drop: (ctx) => {
|
||||
playTone(ctx, 1800, 0, 0.06, 'sine', 0.15)
|
||||
playTone(ctx, 2400, 0.04, 0.1, 'sine', 0.12)
|
||||
},
|
||||
// 钟声 — 低频持续共鸣
|
||||
bell: (ctx) => {
|
||||
playTone(ctx, 523, 0, 0.8, 'sine', 0.15)
|
||||
playTone(ctx, 784, 0.02, 0.8, 'sine', 0.1) // 泛音
|
||||
playTone(ctx, 1047, 0.04, 0.6, 'sine', 0.06) // 高泛音
|
||||
},
|
||||
// 乒乓 — 两个交替音
|
||||
pingpong: (ctx) => {
|
||||
playTone(ctx, 1000, 0, 0.08, 'sine', 0.15)
|
||||
playTone(ctx, 700, 0.1, 0.08, 'sine', 0.15)
|
||||
playTone(ctx, 1000, 0.2, 0.08, 'sine', 0.15)
|
||||
playTone(ctx, 700, 0.3, 0.15, 'sine', 0.15)
|
||||
},
|
||||
// 魔法 — 快速上升扫频感
|
||||
magic: (ctx) => {
|
||||
playTone(ctx, 400, 0, 0.05, 'sine', 0.12)
|
||||
playTone(ctx, 600, 0.04, 0.05, 'sine', 0.12)
|
||||
playTone(ctx, 800, 0.08, 0.05, 'sine', 0.12)
|
||||
playTone(ctx, 1100, 0.12, 0.05, 'sine', 0.12)
|
||||
playTone(ctx, 1400, 0.16, 0.2, 'sine', 0.15)
|
||||
},
|
||||
}
|
||||
|
||||
/** 播放通知声效 (从 localStorage 读配置) */
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const enabled = localStorage.getItem('alert_sound_enabled')
|
||||
if (enabled === '0') return // 关闭声效
|
||||
|
||||
const sound = localStorage.getItem('alert_sound') || 'ding'
|
||||
if (sound === 'none') return
|
||||
|
||||
const ctx = getCtx()
|
||||
if (!ctx) return
|
||||
|
||||
const preset = SOUND_PRESETS[sound]
|
||||
if (preset) preset(ctx)
|
||||
} catch {
|
||||
// 音频不可用时静默
|
||||
}
|
||||
}
|
||||
|
||||
/** 声效选项 (供设置页下拉) */
|
||||
export const SOUND_OPTIONS = [
|
||||
{ key: 'ding', label: '清脆叮' },
|
||||
{ key: 'chime', label: '风铃' },
|
||||
{ key: 'rise', label: '上升音阶' },
|
||||
{ key: 'fall', label: '下降音阶' },
|
||||
{ key: 'alert', label: '急促警报' },
|
||||
{ key: 'electronic', label: '电子音' },
|
||||
{ key: 'drop', label: '水滴' },
|
||||
{ key: 'bell', label: '钟声' },
|
||||
{ key: 'pingpong', label: '乒乓' },
|
||||
{ key: 'magic', label: '魔法' },
|
||||
{ key: 'soft', label: '柔和' },
|
||||
{ key: 'none', label: '无声' },
|
||||
]
|
||||
|
||||
/** 预览声效 (设置页点"试听"用) */
|
||||
export function previewSound(sound: string) {
|
||||
try {
|
||||
const ctx = getCtx()
|
||||
if (!ctx) return
|
||||
const preset = SOUND_PRESETS[sound]
|
||||
if (preset) preset(ctx)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 集中管理所有 React Query key。
|
||||
*
|
||||
* - 新增查询只需在此加一行,所有消费方自动引用。
|
||||
* - SSE invalidation 基于 SSE_INVALIDATE_PREFIXES 列表,新增 key 无需改 useQuoteStream。
|
||||
*/
|
||||
|
||||
// ===== Query Key 工厂 =====
|
||||
|
||||
export const QK = {
|
||||
// 全局 / 共享 (Layout 预取)
|
||||
capabilities: ['capabilities'] as const,
|
||||
settings: ['settings'] as const,
|
||||
endpoints: ['endpoints'] as const,
|
||||
version: ['version'] as const,
|
||||
preferences: ['preferences'] as const,
|
||||
quoteStatus: ['quote-status'] as const,
|
||||
quoteInterval: ['quote-interval'] as const,
|
||||
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
||||
indexQuotes: ['index-quotes'] as const,
|
||||
indexList: ['index-list'] as const,
|
||||
|
||||
// Watchlist
|
||||
watchlist: ['watchlist'] as const,
|
||||
watchlistQuotes: ['watchlist-quotes'] as const,
|
||||
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
|
||||
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
|
||||
instrumentSearch: (q: string) => ['instrument-search', q] as const,
|
||||
|
||||
// Screener
|
||||
screener: ['screener'] as const,
|
||||
screenerStrategies: ['screener-strategies'] as const,
|
||||
screenerCached: (ext?: string) => ['screener-cached', ext] as const,
|
||||
screenerKlineBatch: (symbols: string) => ['screener-kline-batch', symbols] as const,
|
||||
marketSnapshot: ['market-snapshot'] as const,
|
||||
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
|
||||
|
||||
// Backtest
|
||||
backtestStatus: ['backtest-status'] as const,
|
||||
|
||||
// Data / Pipeline
|
||||
dataStatus: ['data-status'] as const,
|
||||
pipelineJobs: ['pipeline-jobs'] as const,
|
||||
pipelineJob: (id: string) => ['pipeline-job', id] as const,
|
||||
extData: ['ext-data'] as const,
|
||||
extDataRows: (id: string, date?: string, limit?: number, columns?: string) => ['ext-data-rows', id, date, limit, columns] as const,
|
||||
analysisMenus: ['analysis-menus'] as const,
|
||||
analysisMenu: (id: string) => ['analysis-menu', id] as const,
|
||||
|
||||
// Kline
|
||||
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
|
||||
['kline', symbol, start, end, extColumns ?? ''] as const,
|
||||
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
|
||||
klineMinute: (symbol: string, date: string) =>
|
||||
['kline-minute', symbol, date] as const,
|
||||
indexDaily: (symbol: string, start: string, end: string) =>
|
||||
['index-daily', symbol, start, end] as const,
|
||||
indexMinute: (symbol: string, date: string) =>
|
||||
['index-minute', symbol, date] as const,
|
||||
|
||||
// Schema
|
||||
extDataSchemaAll: ['ext-data-schema-all'] as const,
|
||||
tableSchema: (table: string) => ['table-schema', table] as const,
|
||||
|
||||
// Custom Signals
|
||||
customSignals: ['custom-signals'] as const,
|
||||
customSignalsOptions: ['custom-signals-options'] as const,
|
||||
|
||||
// Monitor (监控规则 + 触发记录)
|
||||
monitorRules: ['monitor-rules'] as const,
|
||||
monitorRuleOptions: ['monitor-rule-options'] as const,
|
||||
alerts: (source?: string) => ['alerts', source ?? ''] as const,
|
||||
|
||||
// AI 大盘复盘
|
||||
reviewReports: ['review-reports'] as const,
|
||||
|
||||
// 概念涨幅轮动矩阵
|
||||
rpsRotation: (days: number) => ['rps-rotation', days] as const,
|
||||
} as const
|
||||
|
||||
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
|
||||
// 新增需要 SSE 推送的查询,只需在此加一行
|
||||
|
||||
export const SSE_INVALIDATE_PREFIXES = [
|
||||
'watchlist',
|
||||
'quote-status',
|
||||
'index-quotes',
|
||||
'overview-market',
|
||||
'limit-ladder',
|
||||
'screener',
|
||||
] as const
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 复盘生成状态的全局单例 store —— 脱离 Review 组件生命周期。
|
||||
*
|
||||
* 解决的问题:生成中切换到其他页面,Review 组件卸载会丢失 phase/content。
|
||||
* 本 store 把流式生成的状态提到模块级,组件卸载后流仍在后台继续跑,
|
||||
* 回到页面订阅即可恢复显示。
|
||||
*
|
||||
* 设计:
|
||||
* - 模块级 state(phase/content/meta/focus),唯一的生成实例
|
||||
* - AbortController 存模块级 ref,组件卸载不中断流
|
||||
* - 订阅者列表(notify 机制),Review mount 时订阅、unmount 时退订
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export type ReviewPhase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ReviewMeta {
|
||||
as_of?: string
|
||||
emotion_score?: number
|
||||
emotion_label?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export interface ReviewState {
|
||||
phase: ReviewPhase
|
||||
content: string
|
||||
error: string
|
||||
meta: ReviewMeta | null
|
||||
focus: string
|
||||
}
|
||||
|
||||
const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null, focus: '' }
|
||||
|
||||
// ===== 模块级单例状态(组件卸载不销毁)=====
|
||||
let state: ReviewState = { ...INITIAL }
|
||||
let abortCtrl: AbortController | null = null
|
||||
|
||||
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
|
||||
// 用于区分两条流, 避免互相丢弃事件或重复归档。
|
||||
let generatingSource: 'manual' | 'sse' | null = null
|
||||
|
||||
// ===== 订阅机制 =====
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function notify() {
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
export function getReviewState(): ReviewState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function subscribeReview(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
// 暴露给组件直接读取最新 meta(用于自动归档,避免闭包取旧值)
|
||||
export function getReviewMeta(): ReviewMeta | null {
|
||||
return state.meta
|
||||
}
|
||||
|
||||
/** 是否正在生成(loading 或 streaming) */
|
||||
export function isReviewGenerating(): boolean {
|
||||
return state.phase === 'loading' || state.phase === 'streaming'
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动复盘生成。返回后流在后台独立运行,组件卸载不影响。
|
||||
* @param asOf 复盘日期
|
||||
* @param focus 用户追加的复盘关注点
|
||||
* @param onDone 完成回调(供调用方做自动归档)
|
||||
*/
|
||||
export async function startReviewGeneration(
|
||||
asOf: string | undefined,
|
||||
focus: string,
|
||||
onDone?: (fullContent: string, meta: ReviewMeta | null) => void,
|
||||
): Promise<void> {
|
||||
// 已在生成中,不重复启动
|
||||
if (isReviewGenerating()) return
|
||||
|
||||
generatingSource = 'manual'
|
||||
state = { phase: 'loading', content: '', error: '', meta: null, focus }
|
||||
notify()
|
||||
|
||||
abortCtrl = new AbortController()
|
||||
let buf = ''
|
||||
let failed = false
|
||||
let doneMeta: ReviewMeta | null = null
|
||||
|
||||
try {
|
||||
for await (const evt of api.reviewStream(asOf, focus)) {
|
||||
if (abortCtrl.signal.aborted) break
|
||||
if (evt.type === 'meta') {
|
||||
doneMeta = evt
|
||||
state = { ...state, meta: evt }
|
||||
notify()
|
||||
} else if (evt.type === 'delta' && evt.content) {
|
||||
buf += evt.content
|
||||
state = { ...state, content: buf, phase: 'streaming' }
|
||||
notify()
|
||||
} else if (evt.type === 'error') {
|
||||
failed = true
|
||||
state = { ...state, error: evt.message ?? '复盘失败', phase: 'error' }
|
||||
notify()
|
||||
return
|
||||
} else if (evt.type === 'done') {
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
}
|
||||
}
|
||||
// 流正常结束但无 done 事件,按 done 处理
|
||||
if (buf && !failed) {
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
|
||||
if (buf && !failed) {
|
||||
onDone?.(buf, doneMeta)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!abortCtrl.signal.aborted) {
|
||||
state = { ...state, error: e?.message ?? '复盘失败', phase: 'error' }
|
||||
notify()
|
||||
}
|
||||
} finally {
|
||||
abortCtrl = null
|
||||
generatingSource = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 中断当前生成(供"查看历史"等场景主动中断流)。 */
|
||||
export function abortReviewGeneration(): void {
|
||||
abortCtrl?.abort()
|
||||
abortCtrl = null
|
||||
}
|
||||
|
||||
/** 设置当前查看的历史报告(把 store 状态切到 done + 该报告内容)。 */
|
||||
export function setViewingReport(report: {
|
||||
content: string
|
||||
as_of?: string
|
||||
emotion_score?: number | null
|
||||
emotion_label?: string
|
||||
summary?: string
|
||||
}): void {
|
||||
abortCtrl?.abort()
|
||||
abortCtrl = null
|
||||
state = {
|
||||
phase: 'done',
|
||||
content: report.content,
|
||||
error: '',
|
||||
meta: {
|
||||
as_of: report.as_of,
|
||||
emotion_score: report.emotion_score ?? undefined,
|
||||
emotion_label: report.emotion_label,
|
||||
summary: report.summary,
|
||||
},
|
||||
focus: state.focus,
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
/** 重置到 idle(清空当前显示)。 */
|
||||
export function resetReview(): void {
|
||||
state = { ...INITIAL }
|
||||
notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
|
||||
*
|
||||
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
|
||||
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
|
||||
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
|
||||
*
|
||||
* 事件格式与 recap_market_stream 产出一致:
|
||||
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
|
||||
*
|
||||
* 与手动生成的并发:
|
||||
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
|
||||
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
|
||||
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
|
||||
*/
|
||||
export function feedReviewEvent(evt: any): void {
|
||||
if (!evt || typeof evt !== 'object') return
|
||||
const t = evt.type
|
||||
|
||||
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
|
||||
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
|
||||
if (generatingSource === 'manual') return
|
||||
|
||||
if (t === 'meta') {
|
||||
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
|
||||
generatingSource = 'sse'
|
||||
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
|
||||
notify()
|
||||
} else if (t === 'delta' && evt.content) {
|
||||
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
|
||||
if (generatingSource !== 'sse') return
|
||||
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
|
||||
notify()
|
||||
} else if (t === 'retry') {
|
||||
if (generatingSource !== 'sse') return
|
||||
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
|
||||
state = { ...state, content: '', phase: 'streaming' }
|
||||
notify()
|
||||
} else if (t === 'error') {
|
||||
if (generatingSource !== 'sse') return
|
||||
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
|
||||
notify()
|
||||
generatingSource = null
|
||||
} else if (t === 'done') {
|
||||
if (generatingSource !== 'sse') return
|
||||
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
generatingSource = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 策略结果列表自定义列配置。
|
||||
*
|
||||
* 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的
|
||||
* 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。
|
||||
*/
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
buildExtColumnsParam,
|
||||
mergeColumns,
|
||||
serializeColumns,
|
||||
type ColumnConfig,
|
||||
type ColumnGroup,
|
||||
type ColumnSource,
|
||||
type ExtColumnDisplayConfig,
|
||||
type CandleColumnConfig,
|
||||
} from '@/lib/list-columns'
|
||||
|
||||
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
|
||||
export { buildExtColumnsParam, mergeColumns, serializeColumns }
|
||||
|
||||
export const SCREENER_BUILTIN_COLUMNS: ColumnConfig[] = [
|
||||
// 固定列
|
||||
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '标的', visible: true, pinned: true, align: 'left' },
|
||||
// 策略特有列
|
||||
{ id: 'builtin:strategies', source: { type: 'builtin', key: 'strategies' }, label: '策略', visible: true, align: 'left' },
|
||||
{ id: 'builtin:score', source: { type: 'builtin', key: 'score' }, label: '评分', visible: true, align: 'right' },
|
||||
// 价格
|
||||
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'right' },
|
||||
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'right' },
|
||||
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'right' },
|
||||
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'right' },
|
||||
// 成交
|
||||
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: false, align: 'right' },
|
||||
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: true, align: 'right' },
|
||||
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'right' },
|
||||
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'right' },
|
||||
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'right' },
|
||||
// 均线
|
||||
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'right' },
|
||||
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'right' },
|
||||
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'right' },
|
||||
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'right' },
|
||||
// 区间
|
||||
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'right' },
|
||||
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'right' },
|
||||
// 技术指标
|
||||
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'right' },
|
||||
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'right' },
|
||||
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'right' },
|
||||
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'right' },
|
||||
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'right' },
|
||||
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'right' },
|
||||
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'right' },
|
||||
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'right' },
|
||||
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'right' },
|
||||
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'right' },
|
||||
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'right' },
|
||||
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'right' },
|
||||
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'right' },
|
||||
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'right' },
|
||||
// 动量
|
||||
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'right' },
|
||||
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'right' },
|
||||
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'right' },
|
||||
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'right' },
|
||||
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum_60d' }, label: '60D 动量', visible: true, align: 'right' },
|
||||
// 连板
|
||||
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
|
||||
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
|
||||
// 信号 & 图表
|
||||
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'left' },
|
||||
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
|
||||
// 财务指标 (与自选页对齐; 当前后端 enriched 未返回这些字段,默认隐藏)
|
||||
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'right' },
|
||||
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'right' },
|
||||
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'right' },
|
||||
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'right' },
|
||||
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'right' },
|
||||
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'right' },
|
||||
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'right' },
|
||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'right' },
|
||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'right' },
|
||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'right' },
|
||||
]
|
||||
|
||||
export const SCREENER_COLUMN_GROUPS: ColumnGroup[] = [
|
||||
{ id: 'core', label: '核心', icon: '🎯', keys: ['strategies', 'score', 'signals'] },
|
||||
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
|
||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
|
||||
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
|
||||
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
|
||||
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
|
||||
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
|
||||
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
|
||||
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
|
||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
|
||||
]
|
||||
|
||||
export async function saveScreenerColumnConfig(columns: ColumnConfig[]): Promise<void> {
|
||||
const saveable = serializeColumns(columns)
|
||||
storage.screenerResultColumns.set(saveable)
|
||||
try {
|
||||
const { api } = await import('@/lib/api')
|
||||
await api.updateScreenerResultColumns(saveable)
|
||||
} catch {
|
||||
// 后端不可用时 localStorage 仍有效
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadScreenerColumnConfig(): Promise<ColumnConfig[]> {
|
||||
try {
|
||||
const { api } = await import('@/lib/api')
|
||||
const res = await api.screenerResultColumns()
|
||||
if (res.columns && res.columns.length > 0) {
|
||||
const merged = mergeColumns(res.columns, SCREENER_BUILTIN_COLUMNS)
|
||||
storage.screenerResultColumns.set(serializeColumns(merged))
|
||||
return merged
|
||||
}
|
||||
} catch {
|
||||
// 后端不可用,继续尝试 localStorage
|
||||
}
|
||||
|
||||
const saved = storage.screenerResultColumns.get([]) as ColumnConfig[]
|
||||
if (saved.length > 0) return mergeColumns(saved, SCREENER_BUILTIN_COLUMNS)
|
||||
|
||||
return [...SCREENER_BUILTIN_COLUMNS]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。
|
||||
*
|
||||
* 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐
|
||||
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
|
||||
*/
|
||||
|
||||
export type SignalKind = 'entry' | 'exit' | 'both'
|
||||
|
||||
export interface BuiltinSignalDefinition {
|
||||
id: string
|
||||
name: string
|
||||
kind: SignalKind
|
||||
category: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 内置原子信号清单 (权威展示来源, 两页统一) */
|
||||
export const BUILTIN_SIGNAL_DEFINITIONS: BuiltinSignalDefinition[] = [
|
||||
{
|
||||
id: 'signal_ma_golden_5_20',
|
||||
name: 'MA5上穿MA20',
|
||||
kind: 'entry',
|
||||
category: '均线',
|
||||
description: '短期均线 MA5 上穿中期均线 MA20,常用于趋势转强确认。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma_dead_5_20',
|
||||
name: 'MA5下穿MA20',
|
||||
kind: 'exit',
|
||||
category: '均线',
|
||||
description: '短期均线 MA5 下穿中期均线 MA20,常用于趋势转弱或止盈止损。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma_golden_20_60',
|
||||
name: 'MA20上穿MA60',
|
||||
kind: 'entry',
|
||||
category: '均线',
|
||||
description: '中期均线 MA20 上穿长期均线 MA60,偏中线趋势信号。',
|
||||
},
|
||||
{
|
||||
id: 'signal_macd_golden',
|
||||
name: 'MACD金叉',
|
||||
kind: 'entry',
|
||||
category: 'MACD',
|
||||
description: 'MACD DIF 上穿 DEA,表示动能可能由弱转强。',
|
||||
},
|
||||
{
|
||||
id: 'signal_macd_dead',
|
||||
name: 'MACD死叉',
|
||||
kind: 'exit',
|
||||
category: 'MACD',
|
||||
description: 'MACD DIF 下穿 DEA,表示动能可能由强转弱。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma20_breakout',
|
||||
name: '突破MA20',
|
||||
kind: 'entry',
|
||||
category: '趋势',
|
||||
description: '收盘价向上突破 MA20,常用于趋势突破买点。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma20_breakdown',
|
||||
name: '跌破MA20',
|
||||
kind: 'exit',
|
||||
category: '趋势',
|
||||
description: '收盘价向下跌破 MA20,常用于趋势破位卖点。',
|
||||
},
|
||||
{
|
||||
id: 'signal_n_day_high',
|
||||
name: '60日新高',
|
||||
kind: 'entry',
|
||||
category: '趋势',
|
||||
description: '收盘价创近 60 日新高,表示阶段强势或突破。',
|
||||
},
|
||||
{
|
||||
id: 'signal_n_day_low',
|
||||
name: '60日新低',
|
||||
kind: 'exit',
|
||||
category: '趋势',
|
||||
description: '收盘价创近 60 日新低,表示阶段弱势或风险释放。',
|
||||
},
|
||||
{
|
||||
id: 'signal_boll_breakout_upper',
|
||||
name: '突破布林上轨',
|
||||
kind: 'entry',
|
||||
category: 'BOLL',
|
||||
description: '价格突破布林上轨,偏强势突破或加速信号。',
|
||||
},
|
||||
{
|
||||
id: 'signal_boll_breakdown_lower',
|
||||
name: '跌破布林下轨',
|
||||
kind: 'exit',
|
||||
category: 'BOLL',
|
||||
description: '价格跌破布林下轨,偏弱势破位或超跌风险信号。',
|
||||
},
|
||||
{
|
||||
id: 'signal_volume_surge',
|
||||
name: '放量',
|
||||
kind: 'both',
|
||||
category: '量价',
|
||||
description: '成交量显著放大,可作为买入确认、卖出确认或告警条件。',
|
||||
},
|
||||
{
|
||||
id: 'signal_limit_up',
|
||||
name: '涨停',
|
||||
kind: 'entry',
|
||||
category: '涨跌停',
|
||||
description: '收盘封住涨停,用于强势股、连板与市场情绪监控。',
|
||||
},
|
||||
{
|
||||
id: 'signal_limit_down',
|
||||
name: '跌停',
|
||||
kind: 'exit',
|
||||
category: '涨跌停',
|
||||
description: '收盘触及跌停,用于风险控制与弱势监控。',
|
||||
},
|
||||
{
|
||||
id: 'signal_limit_down_recovery',
|
||||
name: '跌停翘板',
|
||||
kind: 'entry',
|
||||
category: '涨跌停',
|
||||
description: '盘中触及跌停后回升,常用于短线情绪修复观察。',
|
||||
},
|
||||
{
|
||||
id: 'signal_broken_limit_up',
|
||||
name: '炸板',
|
||||
kind: 'exit',
|
||||
category: '涨跌停',
|
||||
description: '盘中触及涨停但收盘未封住,用于强转弱或分歧监控。',
|
||||
},
|
||||
]
|
||||
|
||||
/** 内置原子信号 → 中文标签 */
|
||||
export const SIGNAL_LABELS: Record<string, string> = BUILTIN_SIGNAL_DEFINITIONS.reduce<Record<string, string>>((acc, sig) => {
|
||||
acc[sig.id] = sig.name
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/** 内置信号 ID 列表 */
|
||||
export const SIGNAL_OPTIONS = BUILTIN_SIGNAL_DEFINITIONS.map(sig => sig.id)
|
||||
|
||||
/** 常用技术指标/字段 → 中文 (阈值条件展示用, 与后端 ENRICHED_COLUMNS 对齐) */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
|
||||
change_pct: '涨跌幅', change_amount: '涨跌额', amplitude: '振幅',
|
||||
turnover_rate: '换手率', volume: '成交量', amount: '成交额',
|
||||
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
|
||||
ema5: 'EMA5', ema10: 'EMA10', ema20: 'EMA20',
|
||||
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
|
||||
boll_upper: '布林上轨', boll_lower: '布林下轨',
|
||||
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
|
||||
rsi_6: 'RSI6', rsi_14: 'RSI14', rsi_24: 'RSI24',
|
||||
vol_ratio_5d: '5日量比', vol_ratio_20d: '20日量比',
|
||||
vol_ma5: '5日均量', vol_ma10: '10日均量',
|
||||
high_60d: '60日最高', low_60d: '60日最低',
|
||||
momentum_5d: '5日动量', momentum_20d: '20日动量', momentum_60d: '60日动量',
|
||||
atr_14: 'ATR14', annual_vol_20d: '20日年化波动',
|
||||
consecutive_limit_ups: '连板数', consecutive_limit_downs: '跌停连板',
|
||||
}
|
||||
|
||||
/**
|
||||
* 信号/字段 ID → 中文显示名。
|
||||
* 内置信号查 SIGNAL_LABELS; csg_ 前缀查传入的自定义信号名称映射;
|
||||
* 技术指标查 FIELD_LABELS; 都找不到则原样返回。
|
||||
*/
|
||||
export function cnSignal(name: string, customNames?: Record<string, string>): string {
|
||||
if (customNames && name in customNames) return customNames[name]
|
||||
return SIGNAL_LABELS[name] ?? FIELD_LABELS[name] ?? name
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 个股日K信息条(StockInfoBar Row 2)的指标自定义配置。
|
||||
*
|
||||
* 与自选列表列配置同源:复用 list-columns 的通用列模型与合并/序列化底座,
|
||||
* 仅做纯 localStorage 同步持久化(无后端双写)。个股预览弹窗与回测成交K线
|
||||
* Modal 共用同一份配置。
|
||||
*/
|
||||
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
buildExtColumnsParam as buildExtColumnsParamBase,
|
||||
mergeColumns as mergeColumnsBase,
|
||||
serializeColumns as serializeColumnsBase,
|
||||
type ColumnConfig,
|
||||
type ColumnGroup,
|
||||
} from '@/lib/list-columns'
|
||||
|
||||
export type { ColumnConfig, ColumnGroup }
|
||||
|
||||
// ===== 内置指标注册表 =====
|
||||
|
||||
export const BUILTIN_INFO_FIELDS: ColumnConfig[] = [
|
||||
// 规模
|
||||
{ id: 'builtin:market_cap', source: { type: 'builtin', key: 'market_cap' }, label: '市值', visible: true, align: 'left' },
|
||||
{ id: 'builtin:float_market_cap', source: { type: 'builtin', key: 'float_market_cap' }, label: '流通值', visible: true, align: 'left' },
|
||||
// 成交
|
||||
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手', visible: true, align: 'left' },
|
||||
{ id: 'builtin:volume', source: { type: 'builtin', key: 'volume' }, label: '成交量', visible: false, align: 'left' },
|
||||
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'left' },
|
||||
// 行情
|
||||
{ id: 'builtin:open', source: { type: 'builtin', key: 'open' }, label: '开盘', visible: false, align: 'left' },
|
||||
{ id: 'builtin:high', source: { type: 'builtin', key: 'high' }, label: '最高', visible: false, align: 'left' },
|
||||
{ id: 'builtin:low', source: { type: 'builtin', key: 'low' }, label: '最低', visible: false, align: 'left' },
|
||||
// 财务(数据来自 financials metrics 接口,默认隐藏;pe_ttm/pb 用 close 现算)
|
||||
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'left' },
|
||||
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'left' },
|
||||
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'left' },
|
||||
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE', visible: false, align: 'left' },
|
||||
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'left' },
|
||||
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'left' },
|
||||
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'left' },
|
||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'left' },
|
||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'left' },
|
||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'left' },
|
||||
]
|
||||
|
||||
export const INFO_GROUPS: ColumnGroup[] = [
|
||||
{ id: 'scale', label: '规模', icon: '🏦', keys: ['market_cap', 'float_market_cap'] },
|
||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amplitude'] },
|
||||
{ id: 'quote', label: '行情', icon: '📈', keys: ['open', 'high', 'low'] },
|
||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'] },
|
||||
]
|
||||
|
||||
// ===== localStorage 持久化 =====
|
||||
|
||||
/** 加载信息条指标配置:localStorage → 默认值,自动补齐新增默认项。 */
|
||||
export function loadInfoFields(): ColumnConfig[] {
|
||||
const saved = storage.stockInfoBarFields.get([]) as ColumnConfig[]
|
||||
if (saved.length === 0) return [...BUILTIN_INFO_FIELDS]
|
||||
return mergeFields(saved, BUILTIN_INFO_FIELDS)
|
||||
}
|
||||
|
||||
/** 保存信息条指标配置到 localStorage。 */
|
||||
export function saveInfoFields(columns: ColumnConfig[]): void {
|
||||
storage.stockInfoBarFields.set(serializeFields(columns))
|
||||
}
|
||||
|
||||
/** 序列化(此处无 pinned/action 列,直接用底座默认实现)。 */
|
||||
function serializeFields(columns: ColumnConfig[]): ColumnConfig[] {
|
||||
return serializeColumnsBase(columns)
|
||||
}
|
||||
|
||||
/** 从信息条字段配置中提取 ext 列参数(逗号分隔 config_id.field_name),用于 klineDaily 接口。 */
|
||||
export function buildInfoExtColumnsParam(columns: ColumnConfig[]): string {
|
||||
return buildExtColumnsParamBase(columns)
|
||||
}
|
||||
|
||||
/** 合并用户保存的配置与默认配置。 */
|
||||
function mergeFields(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
|
||||
// 无固定列,传入空的 pinnedFirstIds 跳过「代码置顶」逻辑
|
||||
return mergeColumnsBase(saved, defaults, { pinnedFirstIds: [] })
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 股票列表共享逻辑(无 JSX):信号提取、排序取值、不可排序列集合。
|
||||
*
|
||||
* 自选页与策略页共用,避免两边各维护一份导致口径漂移(历史上策略页 14 个信号、
|
||||
* 自选页 13 个,缺一个均线金叉/死叉变体)。
|
||||
*/
|
||||
import type { ColumnConfig } from '@/lib/list-columns'
|
||||
|
||||
// ===== 信号 =====
|
||||
|
||||
export type SignalType = 'bull' | 'bear' | 'neutral'
|
||||
|
||||
export interface Signal {
|
||||
label: string
|
||||
type: SignalType
|
||||
}
|
||||
|
||||
/** 信号字段定义:[行字段名, 展示标签, 信号类型] */
|
||||
export const SIGNAL_FIELDS: [string, string, SignalType][] = [
|
||||
['signal_limit_up', '涨停', 'bull'],
|
||||
['signal_broken_board_recovery', '反包', 'bull'],
|
||||
['signal_volume_surge', '放量', 'neutral'],
|
||||
['signal_ma_golden_5_20', 'MA金叉', 'bull'],
|
||||
['signal_ma_dead_5_20', 'MA死叉', 'bear'],
|
||||
['signal_ma_golden_20_60', '均线金叉', 'bull'],
|
||||
['signal_macd_golden', 'MACD金叉', 'bull'],
|
||||
['signal_macd_dead', 'MACD死叉', 'bear'],
|
||||
['signal_ma20_breakout', '站上MA20', 'bull'],
|
||||
['signal_ma20_breakdown', '跌破MA20', 'bear'],
|
||||
['signal_n_day_high', '60日新高', 'bull'],
|
||||
['signal_n_day_low', '60日新低', 'bear'],
|
||||
['signal_boll_breakout_upper', '布林突破', 'bull'],
|
||||
['signal_boll_breakdown_lower', '布林下破', 'bear'],
|
||||
]
|
||||
|
||||
/** 从一行数据中提取已命中的信号列表 */
|
||||
export function getSignals(r: Record<string, any>): Signal[] {
|
||||
return SIGNAL_FIELDS.filter(([key]) => r[key]).map(([, label, type]) => ({ label, type }))
|
||||
}
|
||||
|
||||
/** 信号类型 → tailwind 颜色类 */
|
||||
export function signalCls(type: SignalType): string {
|
||||
if (type === 'bull') return 'text-bull bg-bull/10'
|
||||
if (type === 'bear') return 'text-bear bg-bear/10'
|
||||
return 'text-accent bg-accent/10'
|
||||
}
|
||||
|
||||
// ===== 排序 =====
|
||||
|
||||
/** 不可参与数值/文本排序的内置列 key(渲染为标签/图表,无单一标量值) */
|
||||
export const UNSORTABLE_KEYS = new Set(['signals', 'candle', 'strategies'])
|
||||
|
||||
/**
|
||||
* 取一列在某行上的排序标量值。builtin 列按 key 映射到行字段;ext 列走
|
||||
* `${configId}__${fieldName}`;不可排序列返回 null。
|
||||
*/
|
||||
export function getSortValue(r: any, col: ColumnConfig): any {
|
||||
if (col.source.type === 'ext') {
|
||||
return r[`${col.source.configId}__${col.source.fieldName}`]
|
||||
}
|
||||
const key = col.source.key
|
||||
switch (key) {
|
||||
case 'symbol': return r.symbol
|
||||
case 'price': return r.rt_price ?? r.close
|
||||
case 'pct': return r.rt_pct ?? r.change_pct
|
||||
case 'change_amount': return r.change_amount
|
||||
case 'amplitude': return r.amplitude
|
||||
case 'turnover': return r.turnover_rate
|
||||
case 'amount': return r.rt_amount ?? r.amount
|
||||
case 'float_val': return r.float_shares && (r.rt_price ?? r.close) ? r.float_shares * (r.rt_price ?? r.close) : null
|
||||
case 'vol_ratio': return r.vol_ratio_5d
|
||||
case 'annual_vol': return r.annual_vol_20d
|
||||
case 'ma5': return r.ma5
|
||||
case 'ma10': return r.ma10
|
||||
case 'ma20': return r.ma20
|
||||
case 'ma60': return r.ma60
|
||||
case 'high_60d': return r.high_60d
|
||||
case 'low_60d': return r.low_60d
|
||||
case 'rsi6': return r.rsi_6
|
||||
case 'rsi14': return r.rsi_14
|
||||
case 'rsi24': return r.rsi_24
|
||||
case 'macd_dif': return r.macd_dif
|
||||
case 'macd_dea': return r.macd_dea
|
||||
case 'macd_hist': return r.macd_hist
|
||||
case 'kdj_k': return r.kdj_k
|
||||
case 'kdj_d': return r.kdj_d
|
||||
case 'kdj_j': return r.kdj_j
|
||||
case 'boll_upper': return r.boll_upper
|
||||
case 'boll_lower': return r.boll_lower
|
||||
case 'atr14': return r.atr_14
|
||||
case 'vol_ma5': return r.vol_ma5
|
||||
case 'vol_ma10': return r.vol_ma10
|
||||
case 'momentum_5d': return r.momentum_5d
|
||||
case 'momentum_10d': return r.momentum_10d
|
||||
case 'momentum_20d': return r.momentum_20d
|
||||
case 'momentum_30d': return r.momentum_30d
|
||||
case 'momentum_60d': return r.momentum_60d
|
||||
case 'limit_ups': return r.consecutive_limit_ups ?? 0
|
||||
case 'limit_downs': return r.consecutive_limit_downs ?? 0
|
||||
case 'score': return r.score
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 共享样式 =====
|
||||
|
||||
/** 数值单元格统一样式(含 tabular-nums 等宽数字) */
|
||||
export const NUM_CELL_CLASS = 'num tabular-nums'
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, type PriceLevel, type LevelType } from './api'
|
||||
|
||||
/**
|
||||
* AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
|
||||
*
|
||||
* 与财务分析 store 的区别:
|
||||
* - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
|
||||
* - meta 额外带 levels(关键价位),供图表回放
|
||||
* - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
|
||||
*
|
||||
* 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
|
||||
*/
|
||||
|
||||
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ActiveTask {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
phase: Phase
|
||||
content: string
|
||||
error: string
|
||||
meta: {
|
||||
summary?: string
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
close?: number | null
|
||||
} | null
|
||||
createdAt: number
|
||||
savedReportId?: string
|
||||
doneAt?: number
|
||||
dismissed?: boolean
|
||||
}
|
||||
|
||||
export interface HistoryReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
summary?: string
|
||||
close?: number | null
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
let activeTasks: ActiveTask[] = []
|
||||
let history: HistoryReport[] = []
|
||||
let historyLoaded = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
let activeDialogTaskId: string | null = null
|
||||
let dialogMinimized = false
|
||||
|
||||
function emit() { listeners.forEach(fn => fn()) }
|
||||
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
|
||||
|
||||
function normalizeAiError(msg: string) {
|
||||
return msg.includes('API Key') || msg.includes('api_key')
|
||||
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
|
||||
: msg
|
||||
}
|
||||
|
||||
let _activeSnap: ActiveTask[] = []
|
||||
let _historySnap: HistoryReport[] = []
|
||||
interface DialogSnap { taskId: string | null; minimized: boolean }
|
||||
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
|
||||
function rebuildSnap() {
|
||||
_activeSnap = activeTasks
|
||||
_historySnap = history
|
||||
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
}
|
||||
|
||||
function getActiveSnapshot() { return _activeSnap }
|
||||
function getHistorySnapshot() { return _historySnap }
|
||||
function getDialogSnapshot() { return _dialogSnap }
|
||||
|
||||
function patchTask(id: string, patch: Partial<ActiveTask>) {
|
||||
activeTasks = activeTasks.map(t => {
|
||||
if (t.id !== id) return t
|
||||
const next = { ...t, ...patch }
|
||||
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
|
||||
next.doneAt = Date.now()
|
||||
}
|
||||
return next
|
||||
})
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
// ===== 查询 hooks =====
|
||||
|
||||
export function useBubbleTasks(): ActiveTask[] {
|
||||
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
const ds = _dialogSnap
|
||||
return all.filter(t => {
|
||||
if (t.phase === 'loading' || t.phase === 'streaming') {
|
||||
return !(ds.taskId === t.id && !ds.minimized)
|
||||
}
|
||||
if (t.dismissed) return false
|
||||
if (!ds.minimized && ds.taskId === t.id) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
|
||||
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
return { reports, loaded: historyLoaded }
|
||||
}
|
||||
|
||||
export function useDialogState() {
|
||||
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
}
|
||||
|
||||
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
|
||||
const ds = useDialogState()
|
||||
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
if (!ds.taskId) return { task: null, mode: null }
|
||||
if (ds.taskId.startsWith('history:')) {
|
||||
const rid = ds.taskId.slice('history:'.length)
|
||||
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
|
||||
}
|
||||
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
|
||||
}
|
||||
|
||||
// ===== 动作 =====
|
||||
|
||||
export async function loadHistory(): Promise<void> {
|
||||
try {
|
||||
const res = await api.stockAnalysisReportsList()
|
||||
history = res.reports ?? []
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
return history.find(r => r.symbol === symbol) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
|
||||
* 判断依据:created_at 的日期部分 == 本地今天。
|
||||
* @returns 当天最近一条报告,或 null
|
||||
*/
|
||||
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
||||
return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
|
||||
}
|
||||
|
||||
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
||||
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
|
||||
if (existing) {
|
||||
activeDialogTaskId = existing.id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
return { id: existing.id }
|
||||
}
|
||||
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
|
||||
if (ongoing.length >= MAX_ACTIVE) {
|
||||
return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
|
||||
}
|
||||
|
||||
const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const task: ActiveTask = {
|
||||
id, symbol, name, focus,
|
||||
phase: 'loading', content: '', error: '',
|
||||
meta: null, createdAt: Date.now(),
|
||||
}
|
||||
activeTasks = [...activeTasks, task]
|
||||
activeDialogTaskId = id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
|
||||
runStream(id, symbol, name, focus)
|
||||
return { id }
|
||||
}
|
||||
|
||||
async function runStream(id: string, symbol: string, _name: string, focus: string) {
|
||||
try {
|
||||
let firstDelta = true
|
||||
for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
|
||||
const cur = activeTasks.find(t => t.id === id)
|
||||
if (!cur) return
|
||||
switch (chunk.type) {
|
||||
case 'meta':
|
||||
patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
|
||||
break
|
||||
case 'delta':
|
||||
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
|
||||
patchTask(id, { content: cur.content + (chunk.content ?? '') })
|
||||
break
|
||||
case 'error':
|
||||
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
|
||||
return
|
||||
case 'done':
|
||||
patchTask(id, { phase: 'done' })
|
||||
break
|
||||
}
|
||||
}
|
||||
const final = activeTasks.find(t => t.id === id)
|
||||
if (final && final.phase !== 'error') {
|
||||
// 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
|
||||
if (!final.content) {
|
||||
patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.stockAnalysisReportSave({
|
||||
symbol: final.symbol, name: final.name, focus: final.focus,
|
||||
content: final.content, summary: final.meta?.summary ?? '',
|
||||
close: final.meta?.close ?? null, levels: final.meta?.levels,
|
||||
})
|
||||
if (res.report) {
|
||||
patchTask(id, { savedReportId: res.report.id })
|
||||
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
} catch { /* 持久化失败不影响展示 */ }
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? '分析失败')
|
||||
patchTask(id, {
|
||||
phase: 'error',
|
||||
error: normalizeAiError(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function openDialog(taskId: string) {
|
||||
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export function minimizeDialog() {
|
||||
dialogMinimized = true; rebuildSnap(); emit()
|
||||
}
|
||||
export function closeDialog() {
|
||||
activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export function restoreDialog(taskId: string) {
|
||||
const t = activeTasks.find(x => x.id === taskId)
|
||||
if (t && (t.phase === 'done' || t.phase === 'error')) {
|
||||
patchTask(taskId, { dismissed: true })
|
||||
}
|
||||
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
|
||||
return startAnalysis(task.symbol, task.name, task.focus)
|
||||
}
|
||||
export async function deleteReport(reportId: string): Promise<void> {
|
||||
try {
|
||||
await api.stockAnalysisReportDelete(reportId)
|
||||
history = history.filter(r => r.id !== reportId)
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
export function openHistoryReport(reportId: string) {
|
||||
activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 集中管理所有 localStorage 持久化。
|
||||
*
|
||||
* - key 在此注册,各页面只通过 storage.xxx.get/set 调用。
|
||||
* - 类型安全,不再散落 try/catch。
|
||||
*/
|
||||
|
||||
function kv<T>(key: string) {
|
||||
return {
|
||||
get(fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw !== null) return JSON.parse(raw) as T
|
||||
} catch { /* ignore */ }
|
||||
return fallback
|
||||
},
|
||||
set(val: T) {
|
||||
try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
/** 查询轮询 / SSE 配置 */
|
||||
queryConfig: kv<unknown>('tf-stocks-query-config'),
|
||||
|
||||
/** 策略池 (screener) */
|
||||
strategyPool: kv<string[]>('strategy-pool'),
|
||||
|
||||
/** 自选列表列配置 */
|
||||
watchlistColumns: kv<unknown[]>('watchlist_columns'),
|
||||
|
||||
/** 个股日K信息条指标配置 */
|
||||
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
|
||||
|
||||
/** 策略结果列表列配置 */
|
||||
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
||||
|
||||
/** 自选列表视图模式 table | card */
|
||||
watchlistView: kv<string>('watchlist_view'),
|
||||
|
||||
/** 自选列表日K蜡烛图显示状态 */
|
||||
watchlistCandle: kv<boolean>('watchlist_showCandle'),
|
||||
|
||||
/** 策略结果列表日K蜡烛图显示状态 */
|
||||
screenerCandle: kv<boolean>('screener_showCandle'),
|
||||
|
||||
/** 自选列表板块筛选 */
|
||||
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
|
||||
|
||||
/** Screener 卡片尺寸 */
|
||||
screenerCardSize: kv<string>('screener-card-size'),
|
||||
|
||||
/** 连板梯队板块筛选 */
|
||||
limitLadderBoard: kv<string[]>('limit-ladder-board-filter'),
|
||||
|
||||
/** 连板梯队 ext 字段配置 */
|
||||
limitLadderExtFields: kv<Record<string, any>>('limit-ladder-ext-fields'),
|
||||
|
||||
/** 连板梯队 概念/行业 显示开关 */
|
||||
limitLadderShowExt: kv<{ concept: boolean; industry: boolean }>('limit-ladder-show-ext'),
|
||||
|
||||
/** 连板梯队 涨停/跌停 切换方向 */
|
||||
limitLadderDirection: kv<'up' | 'down'>('limit-ladder-direction'),
|
||||
|
||||
/** 连板梯队 封单显示模式: vol=按成交量(手), amount=按金额(元) */
|
||||
limitLadderSealMode: kv<'vol' | 'amount'>('limit-ladder-seal-mode'),
|
||||
|
||||
/** 策略创建草稿(新建专用) */
|
||||
strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-draft'),
|
||||
|
||||
/** 策略修改草稿(AI修改专用,不影响创建按钮) */
|
||||
strategyModify: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-modify'),
|
||||
|
||||
/** 策略构建器草稿(旧版兼容,逐渐废弃) */
|
||||
strategyBuilderDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-builder-draft'),
|
||||
|
||||
/** 已保存策略的原始规则(策略ID → 规则文本) */
|
||||
strategyRules: kv<Record<string, string>>('strategy-rules'),
|
||||
|
||||
/** 策略回测快捷区间按钮配置 */
|
||||
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
|
||||
|
||||
/** 策略回测最后一次成功结果和参数 */
|
||||
strategyBacktestLast: kv<{
|
||||
selectedStrategy: string | null
|
||||
symbols: string
|
||||
start: string
|
||||
end: string
|
||||
matching: 'close_t' | 'open_t+1'
|
||||
entryFill: 'close_t' | 'open_t+1'
|
||||
exitFill: 'close_t' | 'open_t+1'
|
||||
fees: string
|
||||
slippage: string
|
||||
maxPositions: string
|
||||
maxExposure: string
|
||||
initialCapital: string
|
||||
positionSizing: 'equal' | 'score_weight'
|
||||
mode: 'position' | 'full'
|
||||
holdingDays: string
|
||||
params?: Record<string, any>
|
||||
overrides?: Record<string, any>
|
||||
result: any
|
||||
} | null>('strategy-backtest-last'),
|
||||
|
||||
/** 概念分析页面字段配置 */
|
||||
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
|
||||
|
||||
/** 行业分析页面字段配置 */
|
||||
industryAnalysisConfig: kv<Record<string, any>>('industry-analysis-config'),
|
||||
|
||||
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
|
||||
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
|
||||
/** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */
|
||||
dataCardOrder: kv<string[]>('data-card-order'),
|
||||
} as const
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export const FINANCIAL_QK = {
|
||||
status: ['financials', 'status'],
|
||||
metrics: (symbol?: string) => ['financials', 'metrics', symbol],
|
||||
income: (symbol?: string) => ['financials', 'income', symbol],
|
||||
balanceSheet: (symbol?: string) => ['financials', 'balance-sheet', symbol],
|
||||
cashFlow: (symbol?: string) => ['financials', 'cash-flow', symbol],
|
||||
}
|
||||
|
||||
export function useFinancialStatus() {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.status,
|
||||
queryFn: () => api.financialStatus(),
|
||||
staleTime: 60_000,
|
||||
// 同步进行中时每 3s 轮询,及时反映表数变化与同步完成;空闲时不轮询。
|
||||
refetchInterval: (query) => (query.state.data?.syncing ? 3_000 : false),
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialMetrics(symbol?: string) {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.metrics(symbol),
|
||||
queryFn: () => api.financialMetrics(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialIncome(symbol?: string) {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.income(symbol),
|
||||
queryFn: () => api.financialIncome(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialBalanceSheet(symbol?: string) {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.balanceSheet(symbol),
|
||||
queryFn: () => api.financialBalanceSheet(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialCashFlow(symbol?: string) {
|
||||
return useQuery({
|
||||
queryKey: FINANCIAL_QK.cashFlow(symbol),
|
||||
queryFn: () => api.financialCashFlow(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialSync() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (table: string) => api.financialSync(table),
|
||||
// 点击瞬间立即刷新 status: 让后端 is_syncing=True 马上反映到 UI,
|
||||
// 避免 mutation 阻塞(全量同步需数分钟)期间界面无变化。
|
||||
onMutate: () => {
|
||||
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
|
||||
qc.invalidateQueries({ queryKey: ['financials'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
|
||||
*
|
||||
* 两个分析页(财务 / 个股)各自独立记忆,key 区分:
|
||||
* - financials: 最后查看的财务分析个股
|
||||
* - stock-analysis: 最后查看的个股分析个股
|
||||
*
|
||||
* 用法:
|
||||
* const { last, remember } = useLastStock('stock-analysis')
|
||||
* remember('000001.SZ', '平安银行') // 选中股票时调用
|
||||
* <LastStockChip stock={last} ... /> // 渲染在 PageHeader 右侧
|
||||
*/
|
||||
|
||||
export interface StockRef { symbol: string; name: string }
|
||||
|
||||
const PREFIX = 'last_stock:'
|
||||
|
||||
export function useLastStock(scope: string) {
|
||||
const [last, setLast] = useState<StockRef | null>(() => load(scope))
|
||||
|
||||
const remember = useCallback((symbol: string, name: string) => {
|
||||
const ref = { symbol, name }
|
||||
setLast(ref)
|
||||
save(scope, ref)
|
||||
}, [scope])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setLast(null)
|
||||
save(scope, null)
|
||||
}, [scope])
|
||||
|
||||
return { last, remember, clear }
|
||||
}
|
||||
|
||||
function load(scope: string): StockRef | null {
|
||||
try {
|
||||
const v = localStorage.getItem(PREFIX + scope)
|
||||
if (!v) return null
|
||||
const p = JSON.parse(v)
|
||||
if (p && typeof p.symbol === 'string' && typeof p.name === 'string') return p
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function save(scope: string, ref: StockRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(PREFIX + scope, JSON.stringify(ref))
|
||||
else localStorage.removeItem(PREFIX + scope)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* SSE 配置 — 运行时参数。
|
||||
*
|
||||
* 目前只保留 SSE 重连延迟,其他数据刷新全部走 SSE invalidation。
|
||||
* 存储在 localStorage。
|
||||
*/
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
// ===== 配置结构 =====
|
||||
|
||||
export interface QueryConfig {
|
||||
/** SSE 配置 */
|
||||
sse: {
|
||||
reconnectDelay: number
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_QUERY_CONFIG: QueryConfig = {
|
||||
sse: {
|
||||
reconnectDelay: 5_000,
|
||||
},
|
||||
}
|
||||
|
||||
// ===== localStorage 持久化 =====
|
||||
|
||||
function loadConfig(): QueryConfig {
|
||||
const raw = storage.queryConfig.get(null) as QueryConfig | null
|
||||
if (!raw) return DEFAULT_QUERY_CONFIG
|
||||
return {
|
||||
sse: { ...DEFAULT_QUERY_CONFIG.sse, ...raw.sse },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量版:只读取当前配置。
|
||||
* 供 useQuoteStream 使用。
|
||||
*/
|
||||
export function getQueryConfig(): QueryConfig {
|
||||
return loadConfig()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
|
||||
import { getQueryConfig } from './useQueryConfig'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { pushAlertToasts } from '@/components/AlertToast'
|
||||
import { feedReviewEvent } from './reviewStore'
|
||||
import type { StrategyAlertEvent } from './api'
|
||||
|
||||
/**
|
||||
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
|
||||
*
|
||||
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
|
||||
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
|
||||
*
|
||||
* 应在顶层 Layout 中调用一次。
|
||||
*/
|
||||
export function useQuoteStream(
|
||||
enabled: boolean,
|
||||
sseRefreshPages: Record<string, boolean> | undefined,
|
||||
onAlert?: (alerts: StrategyAlertEvent[]) => void,
|
||||
) {
|
||||
const qc = useQueryClient()
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const pagesRef = useRef(sseRefreshPages)
|
||||
pagesRef.current = sseRefreshPages
|
||||
|
||||
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
|
||||
// depth 系统接管通知: 单独处理, 不走 strategy 回调
|
||||
const depthAlerts = alerts.filter(a => a.source === 'depth')
|
||||
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
|
||||
|
||||
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
|
||||
for (const a of depthAlerts.slice(0, 1)) {
|
||||
toast(a.message, 'success')
|
||||
}
|
||||
|
||||
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
|
||||
if (strategyAlerts.length > 0) {
|
||||
// 有 onAlert 回调时走回调, 否则弹 AlertToast
|
||||
if (onAlert) {
|
||||
onAlert(strategyAlerts)
|
||||
}
|
||||
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
|
||||
pushAlertToasts(strategyAlerts as any)
|
||||
}
|
||||
}, [onAlert])
|
||||
|
||||
const enabledRef = useRef(enabled)
|
||||
enabledRef.current = enabled
|
||||
|
||||
useEffect(() => {
|
||||
// SSE 始终连接 — 监控告警不依赖实时行情开关
|
||||
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
|
||||
|
||||
const connect = () => {
|
||||
const es = new EventSource('/api/intraday/stream')
|
||||
esRef.current = es
|
||||
|
||||
// sse-starlette ping 心跳走 SSE comment,不会到达这里
|
||||
|
||||
es.addEventListener('quotes_updated', () => {
|
||||
// 实时行情未开启时不处理行情刷新
|
||||
if (!enabledRef.current) return
|
||||
// 根据用户配置过滤 invalidation
|
||||
const pages = pagesRef.current
|
||||
if (pages) {
|
||||
// 只 invalidate 开启的页面对应的 prefix
|
||||
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
|
||||
// 'quote-status' 始终刷新 (全局状态)
|
||||
if (p === 'quote-status') return true
|
||||
return pages[p] !== false
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
activePrefixes.some(
|
||||
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
||||
),
|
||||
})
|
||||
} else {
|
||||
// 无配置时全部刷新 (向后兼容)
|
||||
qc.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
SSE_INVALIDATE_PREFIXES.some(
|
||||
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('depth_updated', () => {
|
||||
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
|
||||
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
|
||||
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
|
||||
qc.invalidateQueries({ queryKey: ['overview-market'] })
|
||||
})
|
||||
|
||||
es.addEventListener('strategy_alert', (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
const alerts: StrategyAlertEvent[] = data.alerts || []
|
||||
if (alerts.length > 0) {
|
||||
handleAlerts(alerts)
|
||||
// 实时刷新触发记录列表 + 监控中心徽标
|
||||
qc.invalidateQueries({ queryKey: ['alerts'] })
|
||||
qc.invalidateQueries({ queryKey: ['alerts-total'] })
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
})
|
||||
|
||||
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
|
||||
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
|
||||
es.addEventListener('review_progress', (e: MessageEvent) => {
|
||||
try {
|
||||
const evt = JSON.parse(e.data)
|
||||
feedReviewEvent(evt)
|
||||
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
|
||||
if (evt.type === 'done') {
|
||||
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
esRef.current = null
|
||||
const delay = getQueryConfig().sse.reconnectDelay
|
||||
retryRef.current = setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
clearTimeout(retryRef.current)
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
}
|
||||
}, [qc, handleAlerts])
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 订阅 reviewStore 的 React hook。
|
||||
*
|
||||
* mount 时订阅,store 变化触发 re-render;unmount 时退订(但 store 流继续跑)。
|
||||
* 用 useSyncExternalStore 保证与 React 18 并发模式兼容。
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
getReviewState, subscribeReview, type ReviewState,
|
||||
} from '@/lib/reviewStore'
|
||||
|
||||
export function useReviewState(): ReviewState {
|
||||
return useSyncExternalStore(subscribeReview, getReviewState, getReviewState)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
|
||||
*/
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from './api'
|
||||
import { QK } from './queryKeys'
|
||||
|
||||
/** 切换实时行情 — Layout / Data 共用 */
|
||||
export function useToggleRealtimeQuotes() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新行情轮询间隔 — Layout / Data 共用 */
|
||||
export function useUpdateQuoteInterval() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (v: number) => api.updateQuoteInterval(v),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(QK.quoteInterval, data)
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量添加自选 — Screener / Intraday 共用 */
|
||||
export function useWatchlistBatchAdd() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
|
||||
*
|
||||
* 实时数据走 SSE invalidation,无需前端轮询。
|
||||
* 只有管线进度等非 SSE 数据才用 refetchInterval。
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from './api'
|
||||
import { QK } from './queryKeys'
|
||||
|
||||
// ===== 全局共享 =====
|
||||
|
||||
/** 能力检测 — Layout / Data / Keys 共用 */
|
||||
export function useCapabilities() {
|
||||
return useQuery({
|
||||
queryKey: QK.capabilities,
|
||||
queryFn: api.capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
/** 设置状态 — Layout / Data / Keys 共用 */
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
queryKey: QK.settings,
|
||||
queryFn: api.settings,
|
||||
})
|
||||
}
|
||||
|
||||
/** 用户偏好 — Layout / Data / Intraday 共用 */
|
||||
export function usePreferences() {
|
||||
return useQuery({
|
||||
queryKey: QK.preferences,
|
||||
queryFn: api.preferences,
|
||||
})
|
||||
}
|
||||
|
||||
/** 行情状态 — SSE quotes_updated 自动刷新。
|
||||
|
||||
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
|
||||
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
|
||||
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
|
||||
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
|
||||
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
|
||||
*/
|
||||
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: QK.quoteStatus,
|
||||
queryFn: api.quoteStatus,
|
||||
enabled: opts?.enabled ?? true,
|
||||
refetchInterval: opts?.poll
|
||||
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
|
||||
: false,
|
||||
})
|
||||
}
|
||||
|
||||
/** 行情间隔 — Layout / Data 共用 */
|
||||
export function useQuoteInterval() {
|
||||
return useQuery({
|
||||
queryKey: QK.quoteInterval,
|
||||
queryFn: api.quoteInterval,
|
||||
})
|
||||
}
|
||||
|
||||
/** 版本号 — Layout 专用 */
|
||||
export function useVersion() {
|
||||
return useQuery({
|
||||
queryKey: QK.version,
|
||||
queryFn: api.version,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
/** 数据状态 — Data / Screener 共用 */
|
||||
export function useDataStatus(opts?: { staleTime?: number; refetchInterval?: number | false }) {
|
||||
return useQuery({
|
||||
queryKey: QK.dataStatus,
|
||||
queryFn: api.dataStatus,
|
||||
staleTime: opts?.staleTime,
|
||||
refetchInterval: opts?.refetchInterval,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
export function useStrategyPool() {
|
||||
const [pool, setPool] = useState<string[]>(() => storage.strategyPool.get([]))
|
||||
|
||||
// 同步写入 localStorage
|
||||
useEffect(() => { storage.strategyPool.set(pool) }, [pool])
|
||||
|
||||
const addToPool = useCallback((id: string) => {
|
||||
setPool(prev => prev.includes(id) ? prev : [...prev, id])
|
||||
}, [])
|
||||
|
||||
const removeFromPool = useCallback((id: string) => {
|
||||
setPool(prev => prev.filter(x => x !== id))
|
||||
}, [])
|
||||
|
||||
const reorderPool = useCallback((newOrder: string[]) => {
|
||||
setPool(newOrder)
|
||||
}, [])
|
||||
|
||||
// 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。
|
||||
// 仅当确实有失效项时才更新,避免无谓重渲染。
|
||||
const prune = useCallback((validIds: Iterable<string>) => {
|
||||
const validSet = validIds instanceof Set ? validIds : new Set(validIds)
|
||||
setPool(prev => {
|
||||
if (prev.length === 0) return prev
|
||||
const next = prev.filter(id => validSet.has(id))
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isInPool = useCallback((id: string) => pool.includes(id), [pool])
|
||||
|
||||
return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 自选列表自定义列配置。
|
||||
*
|
||||
* 自选页只保留业务内置列、分组和偏好持久化;通用列模型/合并/扩展列参数
|
||||
* 来自 list-columns,策略页等其它股票列表可复用同一底座。
|
||||
*/
|
||||
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
buildExtColumnsParam as buildExtColumnsParamBase,
|
||||
createExtColumn as createExtColumnBase,
|
||||
mergeColumns as mergeColumnsBase,
|
||||
serializeColumns as serializeColumnsBase,
|
||||
type ColumnConfig,
|
||||
type ColumnGroup,
|
||||
type ColumnSource,
|
||||
type ExtColumnDisplayConfig,
|
||||
type CandleColumnConfig,
|
||||
} from '@/lib/list-columns'
|
||||
|
||||
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
|
||||
|
||||
// ===== 内置列注册表(与当前硬编码一一对应) =====
|
||||
|
||||
export const BUILTIN_COLUMNS: ColumnConfig[] = [
|
||||
// 固定列
|
||||
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
|
||||
// 价格
|
||||
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
|
||||
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
|
||||
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
|
||||
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
|
||||
// 成交
|
||||
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
|
||||
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
|
||||
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
|
||||
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
|
||||
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
|
||||
// 均线
|
||||
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
|
||||
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
|
||||
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
|
||||
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
|
||||
// 区间
|
||||
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
|
||||
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
|
||||
// 技术指标
|
||||
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
|
||||
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
|
||||
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
|
||||
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
|
||||
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
|
||||
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
|
||||
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
|
||||
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
|
||||
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
|
||||
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
|
||||
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
|
||||
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
|
||||
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
|
||||
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
|
||||
// 动量
|
||||
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
|
||||
// 连板
|
||||
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
|
||||
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
|
||||
// 信号 & 图表
|
||||
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
|
||||
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
|
||||
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
|
||||
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
|
||||
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
|
||||
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
|
||||
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
|
||||
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
|
||||
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
|
||||
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
|
||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
|
||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
|
||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
|
||||
]
|
||||
|
||||
export const COLUMN_GROUPS: ColumnGroup[] = [
|
||||
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
|
||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
|
||||
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
|
||||
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
|
||||
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
|
||||
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
|
||||
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
|
||||
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
|
||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
|
||||
]
|
||||
|
||||
// 操作列(始终显示,不参与自定义)
|
||||
export const ACTION_COLUMN_ID = 'builtin:action'
|
||||
|
||||
// ===== localStorage 持久化 =====
|
||||
|
||||
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action) */
|
||||
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
|
||||
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
|
||||
}
|
||||
|
||||
/** 序列化并保存到后端 + localStorage */
|
||||
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
|
||||
const saveable = serializeColumns(columns)
|
||||
// 同时写 localStorage(即时)和后端(持久化)
|
||||
storage.watchlistColumns.set(saveable)
|
||||
try {
|
||||
const { api } = await import('@/lib/api')
|
||||
await api.updateWatchlistColumns(saveable)
|
||||
} catch {
|
||||
// 后端不可用时 localStorage 仍有效
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
|
||||
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
|
||||
// 1. 尝试从后端加载
|
||||
try {
|
||||
const { api } = await import('@/lib/api')
|
||||
const res = await api.watchlistColumns()
|
||||
if (res.columns && res.columns.length > 0) {
|
||||
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
|
||||
// 同步到 localStorage
|
||||
storage.watchlistColumns.set(serializeColumns(merged))
|
||||
return merged
|
||||
}
|
||||
} catch {
|
||||
// 后端不可用,继续尝试 localStorage
|
||||
}
|
||||
|
||||
// 2. 尝试从 localStorage 加载
|
||||
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
|
||||
if (saved.length > 0) {
|
||||
return mergeColumns(saved, BUILTIN_COLUMNS)
|
||||
}
|
||||
|
||||
// 3. 默认值
|
||||
return [...BUILTIN_COLUMNS]
|
||||
}
|
||||
|
||||
/** 合并用户保存的列与默认列 */
|
||||
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
|
||||
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
|
||||
}
|
||||
|
||||
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
|
||||
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
|
||||
return buildExtColumnsParamBase(columns)
|
||||
}
|
||||
|
||||
/** 根据 ext schema 数据创建 ext 列配置 */
|
||||
export function createExtColumn(
|
||||
configId: string,
|
||||
configLabel: string,
|
||||
fieldName: string,
|
||||
fieldLabel?: string,
|
||||
): ColumnConfig {
|
||||
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
|
||||
}
|
||||
Reference in New Issue
Block a user