mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(v0.2): 双击价格创建点位监控 + 分钟K补齐修复 + 超时设置独立
点位监控(双击图表): - 日K/单日分时/多日分时价格主图支持双击, 自动预填目标价并按最新价判断上穿/下穿 - 成交量区与指标子图不触发; 自动生成通知文案, 用户手改后不再覆盖 - 已启用点位监控以横虚线显示 (上穿红/下穿绿), 并纳入三图 Y 轴范围 - 复用现有 PriceAlertDialog, 未改变后端 close>=/<= 规则语义与冷却机制 分钟K数据: - 个股补齐强制回溯请求天数 (force_full_days), 修复切换日期范围只显示3天 - 单股并发补齐复用仓库写锁, 修复 Windows Parquet 临时文件替换失败 - DuckDB 读路径改用短生命周期 cursor, 释放对 Parquet 文件的占用 设置页: - 数据任务超时从 DataSources 抽出为独立"超时设置"页签 - 设置侧栏垂直居中 验证: pytest 38 passed, pnpm build ok, git diff --check ok, 浏览器端到端 GUI 验证通过
This commit is contained in:
@@ -1056,7 +1056,7 @@ async def sync_minute_single(request: Request, body: dict):
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _run():
|
||||
return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days)
|
||||
return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days, force_full_days=True)
|
||||
|
||||
written = await loop.run_in_executor(_long_task_executor, _run)
|
||||
|
||||
|
||||
@@ -998,11 +998,13 @@ def sync_and_persist_minute(
|
||||
days: int = 5,
|
||||
on_chunk_done: Callable[[int, int, str], None] | None = None,
|
||||
extend_backward: bool = False,
|
||||
force_full_days: bool = False,
|
||||
) -> int:
|
||||
"""同步分钟 K 并存到 Parquet(前复权价格, SDK 端 adjust=qfq)。返回写入行数。
|
||||
|
||||
使用 start_time / end_time 区间拉取, 确保所有标的覆盖同一时间段。
|
||||
on_chunk_done(current, total) 每个 chunk 完成后回调。
|
||||
force_full_days=True 时强制回溯 days 自然日 (不增量补, 用于个股补齐历史)。
|
||||
"""
|
||||
minute_provider = preferences.get_minute_data_provider()
|
||||
# resolver 调用统一走 _resolve_minute_provider, 与 _try_custom_minute 共用异常边界。
|
||||
@@ -1042,8 +1044,13 @@ def sync_and_persist_minute(
|
||||
end_time = now
|
||||
else:
|
||||
# 默认增量模式: 首次拉取回溯 N 天, 已有数据则从最新时间增量补到今天
|
||||
# force_full_days=True: 强制回溯 days 自然日 (个股补齐历史, 不增量)
|
||||
last_dt = _latest_minute_datetime(repo)
|
||||
if last_dt:
|
||||
if force_full_days:
|
||||
# 按交易日换算自然日 (7/5 系数), 确保覆盖足够交易日
|
||||
calendar_days = int(days * 7 / 5) + 5
|
||||
start_time = now - timedelta(days=calendar_days)
|
||||
elif last_dt:
|
||||
start_time = last_dt
|
||||
else:
|
||||
start_time = now - timedelta(days=days)
|
||||
@@ -1063,6 +1070,9 @@ def sync_and_persist_minute(
|
||||
written_box = [0] # list 闭包, 绕过 Python 闭包外层赋值
|
||||
|
||||
def _persist(seg_df: pl.DataFrame) -> None:
|
||||
# 单股自动补齐可能与另一个补齐请求同时写同一日期分区。Windows 不允许
|
||||
# 替换仍被另一写入占用的临时文件,因此读-改-写必须复用仓库写锁。
|
||||
with repo._write_lock:
|
||||
written_box[0] += _write_minute_partition(seg_df, minute_dir)
|
||||
|
||||
segment_days = preferences.get_minute_sync_segment_days()
|
||||
|
||||
@@ -355,12 +355,20 @@ class KlineRepository:
|
||||
def execute_all(self, sql: str, params: list | None = None) -> list[tuple]:
|
||||
"""线程安全的 SELECT → fetchall。DuckDB 单 connection 非线程安全,所有读路径须走此方法。"""
|
||||
with self._lock:
|
||||
return self.db.execute(sql, params or []).fetchall()
|
||||
cursor = self.db.cursor()
|
||||
try:
|
||||
return cursor.execute(sql, params or []).fetchall()
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def execute_one(self, sql: str, params: list | None = None) -> tuple | None:
|
||||
"""线程安全的 SELECT → fetchone。"""
|
||||
with self._lock:
|
||||
return self.db.execute(sql, params or []).fetchone()
|
||||
cursor = self.db.cursor()
|
||||
try:
|
||||
return cursor.execute(sql, params or []).fetchone()
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
# ================================================================
|
||||
# Polars 缓存管理
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_sync_minute_single_uses_requested_days(monkeypatch):
|
||||
))
|
||||
|
||||
assert result["rows"] == 2400
|
||||
sync.assert_called_once_with(["600000.SH"], repo, capset, days=10)
|
||||
sync.assert_called_once_with(["600000.SH"], repo, capset, days=10, force_full_days=True)
|
||||
refresh.assert_called_once_with(repo, "kline_minute")
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ mock 范式沿用 test_stocksdk_provider.py (monkeypatch 模块属性)。
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from threading import Lock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
@@ -411,6 +412,38 @@ def test_sync_and_persist_minute_custom_persists(monkeypatch, tmp_path):
|
||||
get_client_spy.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_and_persist_minute_holds_repository_write_lock(monkeypatch, tmp_path):
|
||||
expected_df = _mock_minute_df()
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.get_minute.return_value = expected_df
|
||||
_setup_custom_provider(monkeypatch, mock_provider, has_dataset=True)
|
||||
|
||||
monkeypatch.setattr(kline_sync, "_cleanup_null_datetime_minute", lambda repo: None)
|
||||
monkeypatch.setattr(kline_sync, "_migrate_symbol_to_date_partition", lambda repo: None)
|
||||
monkeypatch.setattr(kline_sync, "_latest_minute_datetime", lambda repo: None)
|
||||
monkeypatch.setattr(kline_sync, "resolve_limit", lambda *a, **kw: MagicMock(batch=100, rpm=30))
|
||||
monkeypatch.setattr(kline_sync.preferences, "get_minute_sync_segment_days", lambda: 20)
|
||||
|
||||
write_lock = Lock()
|
||||
|
||||
def assert_locked(df, minute_dir):
|
||||
assert not write_lock.acquire(blocking=False)
|
||||
return df.height
|
||||
|
||||
monkeypatch.setattr(kline_sync, "_write_minute_partition", assert_locked)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.store.data_dir = tmp_path
|
||||
mock_repo.db.execute = MagicMock()
|
||||
mock_repo._write_lock = write_lock
|
||||
|
||||
written = kline_sync.sync_and_persist_minute(
|
||||
["600519.SH"], mock_repo, MagicMock(),
|
||||
)
|
||||
|
||||
assert written == expected_df.height
|
||||
|
||||
|
||||
# ---------- 测试 13: get_provider 异常时 fall through TickFlow (Issue 2) ----------
|
||||
|
||||
def test_get_provider_exception_falls_back_to_tickflow(monkeypatch):
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""指数资产路由 — repository 层测试。"""
|
||||
import os
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
@@ -47,6 +49,25 @@ def test_name_map_stock_beats_index(repo):
|
||||
import datetime as _dt
|
||||
|
||||
|
||||
def test_execute_one_releases_parquet_file(repo):
|
||||
minute_dir = repo.store.data_dir / "kline_minute" / "date=2026-07-23"
|
||||
minute_dir.mkdir(parents=True, exist_ok=True)
|
||||
part = minute_dir / "part.parquet"
|
||||
replacement = minute_dir / "part.parquet.tmp"
|
||||
minute = pl.DataFrame({
|
||||
"symbol": ["600000.SH"],
|
||||
"datetime": [_dt.datetime(2026, 7, 23, 9, 30)],
|
||||
"close": [10.0],
|
||||
})
|
||||
minute.write_parquet(part)
|
||||
repo.rebuild_views()
|
||||
|
||||
assert repo.execute_one("SELECT max(datetime) FROM kline_minute")[0] == _dt.datetime(2026, 7, 23, 9, 30)
|
||||
|
||||
minute.write_parquet(replacement)
|
||||
os.replace(replacement, part)
|
||||
|
||||
|
||||
def _write_index_enriched(repo, dates_rows):
|
||||
for ds, rows in dates_rows.items():
|
||||
d = repo.store.data_dir / "kline_index_enriched" / f"date={ds}"
|
||||
|
||||
@@ -332,6 +332,7 @@ interface Props {
|
||||
symbol?: string
|
||||
linkedPrice?: number | null
|
||||
onDateClick?: (date: string) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
/** 默认可见蜡烛根数, 默认 60 */
|
||||
visibleBars?: number
|
||||
/** 已激活的子图 key 列表 (含 vol, 按点击顺序) */
|
||||
@@ -551,6 +552,24 @@ function buildOption(
|
||||
const series: any[] = []
|
||||
const xAxisIndices: number[] = []
|
||||
|
||||
const priceLineValues = (priceLines ?? [])
|
||||
.map(line => line.value)
|
||||
.filter(value => Number.isFinite(value) && value > 0)
|
||||
const axisMin = priceLineValues.length > 0
|
||||
? ({ min, max }: { min: number; max: number }) => {
|
||||
const nextMin = Math.min(min, ...priceLineValues)
|
||||
const nextMax = Math.max(max, ...priceLineValues)
|
||||
return nextMin - Math.max((nextMax - nextMin) * 0.03, nextMax * 0.001)
|
||||
}
|
||||
: undefined
|
||||
const axisMax = priceLineValues.length > 0
|
||||
? ({ min, max }: { min: number; max: number }) => {
|
||||
const nextMin = Math.min(min, ...priceLineValues)
|
||||
const nextMax = Math.max(max, ...priceLineValues)
|
||||
return nextMax + Math.max((nextMax - nextMin) * 0.03, nextMax * 0.001)
|
||||
}
|
||||
: undefined
|
||||
|
||||
// ===== grid 0: K线主图 =====
|
||||
grids.push({ left, right, top: topPad, height: candleAvail })
|
||||
xAxes.push({
|
||||
@@ -562,6 +581,8 @@ function buildOption(
|
||||
})
|
||||
yAxes.push({
|
||||
scale: true,
|
||||
min: axisMin,
|
||||
max: axisMax,
|
||||
// 上下各留 3% 边距: 防止最高/最低点的蜡烛贴边, 涨停/炸板标签被遮挡
|
||||
boundaryGap: [0.03, 0.03],
|
||||
splitArea: { show: false },
|
||||
@@ -791,6 +812,7 @@ export function EChartsCandlestick({
|
||||
symbol: _symbol,
|
||||
linkedPrice,
|
||||
onDateClick,
|
||||
onPriceDoubleClick,
|
||||
visibleBars = 60,
|
||||
activeIndicators = [],
|
||||
volumeCompare = { enabled: true, days: 1 },
|
||||
@@ -801,6 +823,8 @@ export function EChartsCandlestick({
|
||||
dataRef.current = data
|
||||
const onDateClickRef = useRef(onDateClick)
|
||||
onDateClickRef.current = onDateClick
|
||||
const onPriceDoubleClickRef = useRef(onPriceDoubleClick)
|
||||
onPriceDoubleClickRef.current = onPriceDoubleClick
|
||||
// 主题: buildOption/信息栏内部通过 CT() 动态取调色板, 这里只负责切换时触发重建
|
||||
const theme = useTheme()
|
||||
|
||||
@@ -982,6 +1006,18 @@ export function EChartsCandlestick({
|
||||
}
|
||||
})
|
||||
|
||||
const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => {
|
||||
const pixel: [number, number] = [event.offsetX, event.offsetY]
|
||||
if (!chart.containPixel({ gridIndex: 0 }, pixel)) return
|
||||
const coordinate = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel)
|
||||
const price = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN
|
||||
const currentPrice = dataRef.current[dataRef.current.length - 1]?.close
|
||||
if (Number.isFinite(price) && price > 0 && Number.isFinite(currentPrice) && currentPrice > 0) {
|
||||
onPriceDoubleClickRef.current?.(price, currentPrice)
|
||||
}
|
||||
}
|
||||
chart.getZr().on('dblclick', handlePriceDoubleClick)
|
||||
|
||||
// dataZoom → 只更新 ref,不触发 React re-render
|
||||
// compact 变化时需要增量更新 markPoint
|
||||
chart.on('dataZoom', () => {
|
||||
@@ -1007,6 +1043,7 @@ export function EChartsCandlestick({
|
||||
chart.off('updateAxisPointer')
|
||||
chart.off('click')
|
||||
chart.off('dataZoom')
|
||||
chart.getZr().off('dblclick', handlePriceDoubleClick)
|
||||
ro.disconnect()
|
||||
chart.dispose()
|
||||
chartRef.current = null
|
||||
|
||||
@@ -23,6 +23,9 @@ interface Props {
|
||||
date?: string
|
||||
priceLimit?: PriceLimitInfo
|
||||
onPriceHover?: (price: number | null) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
currentPrice?: number
|
||||
priceLines?: { value: number; label?: string; color?: string }[]
|
||||
showLimitLines?: boolean
|
||||
showAvgLine?: boolean
|
||||
}
|
||||
@@ -59,7 +62,7 @@ function getLimitPrices(prevClose: number, priceLimit?: PriceLimitInfo): {
|
||||
return { limitUp, limitDown, upPct, downPct }
|
||||
}
|
||||
|
||||
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, priceLimit?: PriceLimitInfo, showLimitLines = true, showAvgLine = true): EChartsOption {
|
||||
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, priceLimit?: PriceLimitInfo, showLimitLines = true, showAvgLine = true, priceLines: Props['priceLines'] = []): EChartsOption {
|
||||
// 将数据映射到全天时间轴上的正确位置
|
||||
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
|
||||
const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
|
||||
@@ -106,6 +109,25 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
|
||||
symbol: 'none',
|
||||
})
|
||||
}
|
||||
for (const line of priceLines) {
|
||||
if (!Number.isFinite(line.value) || line.value <= 0) continue
|
||||
markLineData.push({
|
||||
yAxis: line.value,
|
||||
lineStyle: { color: line.color ?? ct.text, type: 'dashed', width: 1, opacity: 0.92 },
|
||||
label: {
|
||||
show: !!line.label,
|
||||
formatter: line.label ?? '',
|
||||
position: 'insideEndTop',
|
||||
color: line.color ?? ct.text,
|
||||
backgroundColor: ct.tooltipBg,
|
||||
borderRadius: 4,
|
||||
padding: [2, 6],
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
symbol: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
let yMin: number | undefined
|
||||
let yMax: number | undefined
|
||||
@@ -120,13 +142,19 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
|
||||
}
|
||||
}
|
||||
|
||||
const monitoredDiff = priceLines.reduce((largest, line) => (
|
||||
Number.isFinite(line.value) && line.value > 0
|
||||
? Math.max(largest, Math.abs(line.value - prevClose))
|
||||
: largest
|
||||
), 0) * 1.05
|
||||
|
||||
if (showLimitLines && yMode === 'limit') {
|
||||
const { limitUp, limitDown } = getLimitPrices(prevClose, priceLimit)
|
||||
const limitDiffUp = limitUp - prevClose
|
||||
const limitDiffDown = prevClose - limitDown
|
||||
const limitDiff = Math.max(limitDiffUp, limitDiffDown)
|
||||
// 涨跌停模式: Y 轴按实际涨跌停价
|
||||
maxDiff = limitDiff
|
||||
maxDiff = Math.max(limitDiff, monitoredDiff)
|
||||
yMin = prevClose - maxDiff
|
||||
yMax = prevClose + maxDiff
|
||||
// 加 markLine 标注涨停价和跌停价 (仅虚线, 不显示文字)
|
||||
@@ -157,6 +185,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
|
||||
// 至少保证一个可视范围 (防止数据平时 maxDiff=0)。指数不使用涨跌停范围,最小范围要更紧,否则低波动指数会被压成横线。
|
||||
const minDiff = showLimitLines ? prevClose * 0.01 : prevClose * 0.001
|
||||
if (maxDiff < minDiff) maxDiff = minDiff
|
||||
maxDiff = Math.max(maxDiff, monitoredDiff)
|
||||
yMin = prevClose - maxDiff
|
||||
yMax = prevClose + maxDiff
|
||||
}
|
||||
@@ -204,8 +233,8 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
|
||||
link: [{ xAxisIndex: 'all' }],
|
||||
},
|
||||
grid: [
|
||||
{ left: 60, right: 55, top: 24, bottom: '28%' },
|
||||
{ left: 60, right: 55, top: '74%', bottom: 20 },
|
||||
{ left: 60, right: 55, top: 24, bottom: '34%' },
|
||||
{ left: 60, right: 55, top: '69%', bottom: 20 },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
@@ -357,15 +386,32 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP
|
||||
}
|
||||
}
|
||||
|
||||
export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimit, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) {
|
||||
export function EChartsIntraday({
|
||||
data,
|
||||
height = 320,
|
||||
prevClose,
|
||||
date,
|
||||
priceLimit,
|
||||
onPriceHover,
|
||||
onPriceDoubleClick,
|
||||
currentPrice,
|
||||
priceLines,
|
||||
showLimitLines = true,
|
||||
showAvgLine = true,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<ECharts | null>(null)
|
||||
const roRef = useRef<ResizeObserver | null>(null)
|
||||
const moRef = useRef<MutationObserver | null>(null)
|
||||
const priceDoubleClickHandlerRef = useRef<((event: { offsetX: number; offsetY: number }) => void) | null>(null)
|
||||
const dataRef = useRef(data)
|
||||
dataRef.current = data
|
||||
const currentPriceRef = useRef(currentPrice)
|
||||
currentPriceRef.current = currentPrice
|
||||
const onPriceHoverRef = useRef(onPriceHover)
|
||||
onPriceHoverRef.current = onPriceHover
|
||||
const onPriceDoubleClickRef = useRef(onPriceDoubleClick)
|
||||
onPriceDoubleClickRef.current = onPriceDoubleClick
|
||||
// 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart)
|
||||
const fullDayToDataIdx = useRef<Map<number, number>>(new Map())
|
||||
|
||||
@@ -431,6 +477,19 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi
|
||||
chart.on('globalout', () => {
|
||||
onPriceHoverRef.current?.(null)
|
||||
})
|
||||
|
||||
const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => {
|
||||
const pixel: [number, number] = [event.offsetX, event.offsetY]
|
||||
if (!chart!.containPixel({ gridIndex: 0 }, pixel)) return
|
||||
const coordinate = chart!.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel)
|
||||
const clickedPrice = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN
|
||||
const latestPrice = currentPriceRef.current ?? dataRef.current[dataRef.current.length - 1]?.close
|
||||
if (Number.isFinite(clickedPrice) && clickedPrice > 0 && Number.isFinite(latestPrice) && latestPrice > 0) {
|
||||
onPriceDoubleClickRef.current?.(clickedPrice, latestPrice)
|
||||
}
|
||||
}
|
||||
priceDoubleClickHandlerRef.current = handlePriceDoubleClick
|
||||
chart.getZr().on('dblclick', handlePriceDoubleClick)
|
||||
}
|
||||
|
||||
if (data.length > 0) {
|
||||
@@ -446,16 +505,19 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi
|
||||
}
|
||||
fullDayToDataIdx.current = mapping
|
||||
|
||||
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine), true)
|
||||
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines), true)
|
||||
} else {
|
||||
chart.clear()
|
||||
}
|
||||
}, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine])
|
||||
}, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chartRef.current?.off('updateAxisPointer')
|
||||
chartRef.current?.off('globalout')
|
||||
if (priceDoubleClickHandlerRef.current) {
|
||||
chartRef.current?.getZr().off('dblclick', priceDoubleClickHandlerRef.current)
|
||||
}
|
||||
moRef.current?.disconnect()
|
||||
roRef.current?.disconnect()
|
||||
chartRef.current?.dispose()
|
||||
|
||||
@@ -18,6 +18,8 @@ const COLORS = {
|
||||
interface Props {
|
||||
sessions: MinuteKlineSession[]
|
||||
height?: number
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
priceLines?: { value: number; label?: string; color?: string }[]
|
||||
}
|
||||
|
||||
interface InfoPoint {
|
||||
@@ -132,10 +134,18 @@ function buildModel(sessions: MinuteKlineSession[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) {
|
||||
export function EChartsMultiDayIntraday({
|
||||
sessions,
|
||||
height = 420,
|
||||
onPriceDoubleClick,
|
||||
priceLines = [],
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<ECharts | null>(null)
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null)
|
||||
const priceDoubleClickHandlerRef = useRef<((event: { offsetX: number; offsetY: number }) => void) | null>(null)
|
||||
const onPriceDoubleClickRef = useRef(onPriceDoubleClick)
|
||||
onPriceDoubleClickRef.current = onPriceDoubleClick
|
||||
const model = useMemo(() => buildModel(sessions), [sessions])
|
||||
const modelRef = useRef(model)
|
||||
modelRef.current = model
|
||||
@@ -169,10 +179,33 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) {
|
||||
if (point) setInfo(point)
|
||||
})
|
||||
chart.on('globalout', () => setInfo(modelRef.current.latest))
|
||||
|
||||
const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => {
|
||||
const pixel: [number, number] = [event.offsetX, event.offsetY]
|
||||
if (!chart!.containPixel({ gridIndex: 0 }, pixel)) return
|
||||
const coordinate = chart!.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel)
|
||||
const price = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN
|
||||
const currentPrice = modelRef.current.latest?.row.close
|
||||
if (
|
||||
Number.isFinite(price)
|
||||
&& price > 0
|
||||
&& typeof currentPrice === 'number'
|
||||
&& Number.isFinite(currentPrice)
|
||||
&& currentPrice > 0
|
||||
) {
|
||||
onPriceDoubleClickRef.current?.(price, currentPrice)
|
||||
}
|
||||
}
|
||||
priceDoubleClickHandlerRef.current = handlePriceDoubleClick
|
||||
chart.getZr().on('dblclick', handlePriceDoubleClick)
|
||||
}
|
||||
|
||||
const minPrice = model.priceValues.length > 0 ? Math.min(...model.priceValues) : 0
|
||||
const maxPrice = model.priceValues.length > 0 ? Math.max(...model.priceValues) : 1
|
||||
const monitoredPrices = priceLines
|
||||
.map(line => line.value)
|
||||
.filter(value => Number.isFinite(value) && value > 0)
|
||||
const allPriceValues = [...model.priceValues, ...monitoredPrices]
|
||||
const minPrice = allPriceValues.length > 0 ? Math.min(...allPriceValues) : 0
|
||||
const maxPrice = allPriceValues.length > 0 ? Math.max(...allPriceValues) : 1
|
||||
const padding = Math.max((maxPrice - minPrice) * 0.08, maxPrice * 0.002)
|
||||
const totalLength = model.categories.length
|
||||
const priceSeries: any[] = model.dayRanges.map(({ start, session, values }) => {
|
||||
@@ -198,11 +231,31 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) {
|
||||
lineStyle: { color: theme.grid, width: 1 },
|
||||
label: { show: false },
|
||||
}))
|
||||
if (priceSeries.length > 0 && boundaryData.length > 0) {
|
||||
const monitorLineData = priceLines.flatMap(line => {
|
||||
if (!Number.isFinite(line.value) || line.value <= 0) return []
|
||||
return [{
|
||||
yAxis: line.value,
|
||||
lineStyle: { color: line.color ?? theme.text, type: 'dashed', width: 1, opacity: 0.92 },
|
||||
label: {
|
||||
show: !!line.label,
|
||||
formatter: line.label ?? '',
|
||||
position: 'insideEndTop',
|
||||
color: line.color ?? theme.text,
|
||||
backgroundColor: theme.tooltipBg,
|
||||
borderRadius: 4,
|
||||
padding: [2, 6],
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
}]
|
||||
})
|
||||
const markLineData = [...boundaryData, ...monitorLineData]
|
||||
if (priceSeries.length > 0 && markLineData.length > 0) {
|
||||
priceSeries[0].markLine = {
|
||||
symbol: 'none',
|
||||
silent: true,
|
||||
data: boundaryData,
|
||||
animation: false,
|
||||
data: markLineData,
|
||||
}
|
||||
}
|
||||
const averageSeries: any[] = model.dayRanges.map(({ start, session, averages }) => {
|
||||
@@ -243,8 +296,8 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) {
|
||||
},
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||
grid: [
|
||||
{ left: 58, right: 18, top: 16, bottom: '28%' },
|
||||
{ left: 58, right: 18, top: '76%', bottom: 22 },
|
||||
{ left: 58, right: 18, top: 16, bottom: '34%' },
|
||||
{ left: 58, right: 18, top: '69%', bottom: 22 },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
@@ -329,11 +382,14 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) {
|
||||
],
|
||||
}
|
||||
chart.setOption(option, true)
|
||||
}, [height, model, theme])
|
||||
}, [height, model, priceLines, theme])
|
||||
|
||||
useEffect(() => () => {
|
||||
chartRef.current?.off('updateAxisPointer')
|
||||
chartRef.current?.off('globalout')
|
||||
if (priceDoubleClickHandlerRef.current) {
|
||||
chartRef.current?.getZr().off('dblclick', priceDoubleClickHandlerRef.current)
|
||||
}
|
||||
resizeObserverRef.current?.disconnect()
|
||||
chartRef.current?.dispose()
|
||||
chartRef.current = null
|
||||
|
||||
@@ -50,6 +50,7 @@ interface Props {
|
||||
visibleBars?: number
|
||||
linkedPrice?: number | null
|
||||
onDateClick?: (date: string) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
onDataChange?: (result: StockDailyKChartResult) => void
|
||||
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
|
||||
extColumns?: string
|
||||
@@ -132,6 +133,7 @@ export function StockDailyKChart({
|
||||
visibleBars = 60,
|
||||
linkedPrice,
|
||||
onDateClick,
|
||||
onPriceDoubleClick,
|
||||
onDataChange,
|
||||
extColumns,
|
||||
}: Props) {
|
||||
@@ -279,6 +281,7 @@ export function StockDailyKChart({
|
||||
symbol={symbol}
|
||||
linkedPrice={linkedPrice}
|
||||
onDateClick={onDateClick}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
visibleBars={visibleBars}
|
||||
activeIndicators={activeIndicators}
|
||||
volumeCompare={volumeCompare}
|
||||
|
||||
@@ -12,6 +12,9 @@ interface Props {
|
||||
prevClose?: number
|
||||
className?: string
|
||||
onPriceHover?: (price: number | null) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
currentPrice?: number
|
||||
priceLines?: { value: number; label?: string; color?: string }[]
|
||||
/** 自动刷新间隔(ms)。undefined/0 = 不轮询(默认)。个股对话框盘中实时刷新时传入。 */
|
||||
refetchIntervalMs?: number
|
||||
}
|
||||
@@ -23,6 +26,9 @@ export function StockIntradayChart({
|
||||
prevClose,
|
||||
className,
|
||||
onPriceHover,
|
||||
onPriceDoubleClick,
|
||||
currentPrice,
|
||||
priceLines,
|
||||
refetchIntervalMs,
|
||||
}: Props) {
|
||||
const qc = useQueryClient()
|
||||
@@ -121,6 +127,9 @@ export function StockIntradayChart({
|
||||
date={date}
|
||||
priceLimit={minute.data?.price_limit ?? undefined}
|
||||
onPriceHover={onPriceHover}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
currentPrice={currentPrice}
|
||||
priceLines={priceLines}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Download, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { api, type MinuteKlineSession } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday'
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +11,8 @@ interface Props {
|
||||
days: number
|
||||
height?: number
|
||||
refetchIntervalMs?: number
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
priceLines?: { value: number; label?: string; color?: string }[]
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
@@ -21,12 +24,16 @@ export function StockMultiDayIntradayChart({
|
||||
days,
|
||||
height = 420,
|
||||
refetchIntervalMs,
|
||||
onPriceDoubleClick,
|
||||
priceLines,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const history = useQuery({
|
||||
queryKey: QK.klineMinuteRange(symbol, days),
|
||||
queryFn: () => api.klineMinuteRange(symbol, days),
|
||||
enabled: !!symbol,
|
||||
placeholderData: (previous, previousQuery) =>
|
||||
previousQuery?.queryKey[1] === symbol ? previous : undefined,
|
||||
})
|
||||
const latest = useQuery({
|
||||
queryKey: QK.klineMinute(symbol, ''),
|
||||
@@ -63,14 +70,37 @@ export function StockMultiDayIntradayChart({
|
||||
queryClient.invalidateQueries({ queryKey: ['kline-minute', symbol] }),
|
||||
])
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
const msg = e.message || ''
|
||||
if (msg.includes('403') || msg.includes('Pro')) {
|
||||
toast('分钟K数据需要 Pro+ 权限', 'error')
|
||||
} else {
|
||||
toast(`补齐数据失败: ${msg}`, 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const loading = sessions.length === 0 && (history.isLoading || latest.isLoading)
|
||||
const queryError = sessions.length === 0 ? history.error ?? latest.error : null
|
||||
const isIndex = history.data?.asset_type === 'index' || latest.data?.asset_type === 'index'
|
||||
const missingDays = Math.max(0, days - sessions.length)
|
||||
const showCoverage = sessions.length > 0 && missingDays > 0 && !isIndex
|
||||
const chartHeight = Math.max(260, height - (showCoverage ? 32 : 0))
|
||||
const showCoverage = !history.isPlaceholderData && sessions.length > 0 && missingDays > 0 && !isIndex
|
||||
|
||||
// 自动补齐: 数据不足且非指数时, 自动触发同步
|
||||
// 用 ref 记录已触发的 symbol:days, 避免重复
|
||||
const autoSyncRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
// 后端没运行时 history 会 error, 此时 missingDays 计算无意义, 跳过
|
||||
if (history.error || history.isPlaceholderData || loading || isIndex || sessions.length >= days) return
|
||||
if (syncMinute.isPending) return
|
||||
|
||||
const key = `${symbol}:${days}`
|
||||
if (autoSyncRef.current === key) return // 本组合已触发过
|
||||
autoSyncRef.current = key
|
||||
syncMinute.mutate()
|
||||
}, [symbol, days, sessions.length, loading, isIndex, history.error, history.isPlaceholderData, syncMinute.isPending])
|
||||
|
||||
const chartHeight = Math.max(260, height - (showCoverage || syncMinute.isPending ? 32 : 0))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -127,23 +157,39 @@ export function StockMultiDayIntradayChart({
|
||||
|
||||
return (
|
||||
<div style={{ height }}>
|
||||
{showCoverage && (
|
||||
{(showCoverage || (syncMinute.isPending && !isIndex)) && (
|
||||
<div className="flex h-8 items-center justify-between gap-3 border-b border-border/60 bg-elevated/40 px-3 text-[11px]">
|
||||
<span className="truncate text-muted">当前有 {sessions.length} 个交易日数据,目标 {days} 日</span>
|
||||
{syncMinute.isPending ? (
|
||||
<span className="truncate text-accent flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
正在补齐最近 {days} 日分时数据…
|
||||
</span>
|
||||
) : syncMinute.isError ? (
|
||||
<span className="truncate text-muted">当前 {sessions.length} 日,目标 {days} 日 — 补齐失败</span>
|
||||
) : (
|
||||
<span className="truncate text-muted">当前 {sessions.length} 个交易日数据,目标 {days} 日</span>
|
||||
)}
|
||||
{!syncMinute.isPending && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={syncMinute.isPending}
|
||||
onClick={() => syncMinute.mutate()}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-accent hover:text-accent/80 disabled:opacity-60"
|
||||
onClick={() => {
|
||||
autoSyncRef.current = `${symbol}:${days}`
|
||||
syncMinute.mutate()
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-accent hover:text-accent/80"
|
||||
>
|
||||
{syncMinute.isPending
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <Download className="h-3 w-3" />}
|
||||
补齐数据
|
||||
<Download className="h-3 w-3" />
|
||||
重试补齐
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<EChartsMultiDayIntraday sessions={sessions} height={chartHeight} />
|
||||
<EChartsMultiDayIntraday
|
||||
sessions={sessions}
|
||||
height={chartHeight}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
priceLines={priceLines}
|
||||
/>
|
||||
{syncMinute.isError && (
|
||||
<div className="px-3 pt-1 text-center text-[11px] text-danger">{errorMessage(syncMinute.error)}</div>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface Props {
|
||||
showMarkerToggle?: boolean
|
||||
/** 加监控回调 (传入后信息条显示 RadioTower 图标) */
|
||||
onMonitor?: () => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
/** 自选操作(传入后信息条显示 Star 图标) */
|
||||
inWatchlist?: boolean
|
||||
onAddToWatchlist?: (groupId: string | null) => void
|
||||
@@ -37,6 +38,8 @@ interface Props {
|
||||
watchlistPending?: boolean
|
||||
/** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */
|
||||
refetchIntervalMs?: number
|
||||
/** 只渲染信息条, 隐藏图表 (用于分时 tab 共享信息条) */
|
||||
infoBarOnly?: boolean
|
||||
}
|
||||
|
||||
export { getDefaultRange }
|
||||
@@ -54,11 +57,13 @@ export function StockPanel({
|
||||
showLimitMarkers = true,
|
||||
showMarkerToggle = true,
|
||||
onMonitor,
|
||||
onPriceDoubleClick,
|
||||
inWatchlist,
|
||||
onAddToWatchlist,
|
||||
onRemoveFromWatchlist,
|
||||
watchlistPending,
|
||||
refetchIntervalMs,
|
||||
infoBarOnly = false,
|
||||
}: Props) {
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
@@ -144,6 +149,7 @@ export function StockPanel({
|
||||
watchlistPending={watchlistPending}
|
||||
/>
|
||||
|
||||
{infoBarOnly ? null : (
|
||||
<div className="flex gap-3 items-start">
|
||||
<StockDailyKChart
|
||||
symbol={symbol}
|
||||
@@ -157,6 +163,7 @@ export function StockPanel({
|
||||
showMarkerToggle={showMarkerToggle}
|
||||
linkedPrice={linkedPrice}
|
||||
onDateClick={handleDateClick}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
onDataChange={setDailyResult}
|
||||
visibleBars={showIntraday ? 40 : 60}
|
||||
extColumns={extColumns}
|
||||
@@ -178,11 +185,15 @@ export function StockPanel({
|
||||
height={height}
|
||||
prevClose={prevClose}
|
||||
onPriceHover={setLinkedPrice}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
currentPrice={rows[rows.length - 1]?.close}
|
||||
priceLines={priceLines}
|
||||
refetchIntervalMs={refetchIntervalMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2 } from 'lucide-react'
|
||||
@@ -11,6 +11,8 @@ import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
|
||||
import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog'
|
||||
import { buildMonitorPriceLines } from '@/lib/price-alerts'
|
||||
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
|
||||
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
@@ -39,6 +41,11 @@ const PRESETS: { label: string; months: number }[] = [
|
||||
]
|
||||
|
||||
type PreviewView = 'daily' | 'intraday'
|
||||
interface PriceAlertDraft {
|
||||
id: number
|
||||
targetPrice: number
|
||||
currentPrice: number
|
||||
}
|
||||
const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const
|
||||
|
||||
function loadIntradayDays(): number {
|
||||
@@ -60,6 +67,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
const [intradayDays, setIntradayDays] = useState(loadIntradayDays)
|
||||
const [dateRange, setDateRange] = useState(getDefaultRange)
|
||||
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
||||
const [priceAlertDraft, setPriceAlertDraft] = useState<PriceAlertDraft | null>(null)
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
@@ -69,6 +77,15 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
queryFn: api.watchlistList,
|
||||
enabled: !!symbol,
|
||||
})
|
||||
const monitorRules = useQuery({
|
||||
queryKey: QK.monitorRules,
|
||||
queryFn: api.monitorRulesList,
|
||||
enabled: !!symbol,
|
||||
})
|
||||
const monitorPriceLines = useMemo(
|
||||
() => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [],
|
||||
[monitorRules.data?.rules, symbol],
|
||||
)
|
||||
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
|
||||
|
||||
const toggleWatchlist = useMutation({
|
||||
@@ -91,14 +108,15 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
useEffect(() => {
|
||||
if (!symbol) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Escape' && !priceAlertDraft) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [symbol, onClose])
|
||||
}, [symbol, onClose, priceAlertDraft])
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol) setView('daily')
|
||||
setPriceAlertDraft(null)
|
||||
}, [symbol])
|
||||
|
||||
// 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K,
|
||||
@@ -135,6 +153,10 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
storage.stockPreviewIntradayDays.set(days)
|
||||
}
|
||||
|
||||
const openPriceAlert = (targetPrice: number, currentPrice: number) => {
|
||||
setPriceAlertDraft({ id: Date.now(), targetPrice, currentPrice })
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{symbol && (
|
||||
@@ -379,14 +401,25 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
height={420}
|
||||
showIntraday
|
||||
dateRange={dateRange}
|
||||
priceLines={monitorPriceLines}
|
||||
onPriceDoubleClick={openPriceAlert}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<StockPanel
|
||||
symbol={symbol}
|
||||
dateRange={dateRange}
|
||||
infoBarOnly
|
||||
/>
|
||||
<StockMultiDayIntradayChart
|
||||
symbol={symbol}
|
||||
days={intradayDays}
|
||||
height={480}
|
||||
refetchIntervalMs={intradayRefetchMs}
|
||||
priceLines={monitorPriceLines}
|
||||
onPriceDoubleClick={openPriceAlert}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -420,6 +453,16 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{symbol && priceAlertDraft && (
|
||||
<PriceAlertDialog
|
||||
key={`${symbol}-${priceAlertDraft.id}`}
|
||||
symbol={symbol}
|
||||
name={name ?? ''}
|
||||
initialTarget={priceAlertDraft.targetPrice}
|
||||
initialCurrentPrice={priceAlertDraft.currentPrice}
|
||||
onClose={() => setPriceAlertDraft(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import { ArrowDown, ArrowUp, Bell, Check, ExternalLink, Loader2, Trash2, X } fro
|
||||
import { toast } from '@/components/Toast'
|
||||
import { LEVEL_GROUPS } from './AnalysisKChart'
|
||||
import { api, genRuleId, type MonitorRule, type PriceLevel } from '@/lib/api'
|
||||
import {
|
||||
buildPriceAlertMessage,
|
||||
inferPriceAlertDirection,
|
||||
parsePointPriceAlert,
|
||||
type PriceAlertDirection,
|
||||
} from '@/lib/price-alerts'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
@@ -13,10 +19,10 @@ interface Props {
|
||||
symbol: string
|
||||
name: string
|
||||
onClose: () => void
|
||||
initialTarget?: number
|
||||
initialCurrentPrice?: number | null
|
||||
}
|
||||
|
||||
type AlertDirection = 'up' | 'down'
|
||||
|
||||
const COOLDOWNS = [
|
||||
{ value: 600, label: '10 分钟' },
|
||||
{ value: 1800, label: '30 分钟' },
|
||||
@@ -24,19 +30,17 @@ const COOLDOWNS = [
|
||||
{ value: 86400, label: '当日一次' },
|
||||
]
|
||||
|
||||
function pointCondition(rule: MonitorRule) {
|
||||
if (rule.type !== 'price' || rule.conditions.length !== 1) return null
|
||||
const condition = rule.conditions[0]
|
||||
if (condition.field !== 'close' || !['>=', '<='].includes(condition.op)) return null
|
||||
if (typeof condition.value !== 'number') return null
|
||||
return condition
|
||||
}
|
||||
|
||||
function levelGroupLabel(level: PriceLevel) {
|
||||
return LEVEL_GROUPS.find(group => group.key === level.type)?.label ?? level.type
|
||||
}
|
||||
|
||||
export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
export function PriceAlertDialog({
|
||||
symbol,
|
||||
name,
|
||||
onClose,
|
||||
initialTarget,
|
||||
initialCurrentPrice,
|
||||
}: Props) {
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const { data: prefs } = usePreferences()
|
||||
@@ -46,17 +50,28 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const rulesQuery = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList })
|
||||
const initialTargetValue = initialTarget != null && Number.isFinite(initialTarget) && initialTarget > 0
|
||||
? Math.round(initialTarget * 100) / 100
|
||||
: null
|
||||
const initialDirection = initialTargetValue == null
|
||||
? 'up'
|
||||
: inferPriceAlertDirection(initialTargetValue, initialCurrentPrice)
|
||||
const [tab, setTab] = useState<'create' | 'existing'>('create')
|
||||
const [direction, setDirection] = useState<AlertDirection>('up')
|
||||
const [target, setTarget] = useState('')
|
||||
const [direction, setDirection] = useState<PriceAlertDirection>(initialDirection)
|
||||
const [target, setTarget] = useState(initialTargetValue?.toFixed(2) ?? '')
|
||||
const [selectedLabel, setSelectedLabel] = useState('')
|
||||
const [cooldown, setCooldown] = useState(3600)
|
||||
const [message, setMessage] = useState('')
|
||||
const [message, setMessage] = useState(initialTargetValue == null
|
||||
? ''
|
||||
: buildPriceAlertMessage(name, symbol, initialDirection, initialTargetValue))
|
||||
const [messageEdited, setMessageEdited] = useState(false)
|
||||
const [channels, setChannels] = useState<string[]>([])
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const channelsInitialized = useRef(false)
|
||||
|
||||
const currentPrice = levelsQuery.data?.close ?? null
|
||||
const currentPrice = initialCurrentPrice != null && Number.isFinite(initialCurrentPrice) && initialCurrentPrice > 0
|
||||
? initialCurrentPrice
|
||||
: levelsQuery.data?.close ?? null
|
||||
const recommended = useMemo(() => {
|
||||
if (currentPrice == null) return { above: [] as PriceLevel[], below: [] as PriceLevel[] }
|
||||
const seen = new Set<string>()
|
||||
@@ -103,24 +118,23 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
}, [onClose])
|
||||
|
||||
const pointRules = useMemo(
|
||||
() => (rulesQuery.data?.rules ?? []).filter(rule =>
|
||||
rule.scope === 'symbols'
|
||||
&& rule.symbols.length === 1
|
||||
&& rule.symbols[0] === symbol
|
||||
&& pointCondition(rule),
|
||||
),
|
||||
() => (rulesQuery.data?.rules ?? []).filter(rule => parsePointPriceAlert(rule, symbol)),
|
||||
[rulesQuery.data?.rules, symbol],
|
||||
)
|
||||
|
||||
const targetValue = Number(target)
|
||||
const targetValid = Number.isFinite(targetValue) && targetValue > 0
|
||||
useEffect(() => {
|
||||
if (initialTargetValue == null || messageEdited || !targetValid) return
|
||||
setMessage(buildPriceAlertMessage(name, symbol, direction, targetValue))
|
||||
}, [direction, initialTargetValue, messageEdited, name, symbol, targetValid, targetValue])
|
||||
|
||||
const alreadyReached = targetValid && currentPrice != null && (
|
||||
direction === 'up' ? currentPrice >= targetValue : currentPrice <= targetValue
|
||||
)
|
||||
const duplicate = targetValid && pointRules.some(rule => {
|
||||
const condition = pointCondition(rule)!
|
||||
return condition.op === (direction === 'up' ? '>=' : '<=')
|
||||
&& Math.abs((condition.value ?? 0) - targetValue) < 0.005
|
||||
const alert = parsePointPriceAlert(rule, symbol)!
|
||||
return alert.direction === direction && Math.abs(alert.target - targetValue) < 0.005
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
@@ -171,7 +185,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
|
||||
const selectLevel = (level: PriceLevel) => {
|
||||
setTarget(level.value.toFixed(2))
|
||||
setDirection(currentPrice != null && level.value < currentPrice ? 'down' : 'up')
|
||||
setDirection(inferPriceAlertDirection(level.value, currentPrice))
|
||||
setSelectedLabel(level.label)
|
||||
}
|
||||
|
||||
@@ -179,11 +193,16 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
setTarget(value)
|
||||
setSelectedLabel('')
|
||||
const parsed = Number(value)
|
||||
if (currentPrice != null && Number.isFinite(parsed)) {
|
||||
setDirection(parsed < currentPrice ? 'down' : 'up')
|
||||
if (Number.isFinite(parsed)) {
|
||||
setDirection(inferPriceAlertDirection(parsed, currentPrice))
|
||||
}
|
||||
}
|
||||
|
||||
const updateMessage = (value: string) => {
|
||||
setMessage(value)
|
||||
setMessageEdited(true)
|
||||
}
|
||||
|
||||
const toggleChannel = (channel: string) => {
|
||||
setChannels(current => current.includes(channel)
|
||||
? current.filter(item => item !== channel)
|
||||
@@ -298,7 +317,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">自定义提示</span>
|
||||
<input value={message} onChange={event => setMessage(event.target.value)} placeholder="留空使用默认内容" className="h-9 w-full rounded-md border border-border bg-base px-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none" />
|
||||
<input value={message} onChange={event => updateMessage(event.target.value)} placeholder="留空使用默认内容" className="h-9 w-full rounded-md border border-border bg-base px-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -340,8 +359,8 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
) : (
|
||||
<div className="divide-y divide-border/50 border-y border-border/50">
|
||||
{pointRules.map(rule => {
|
||||
const condition = pointCondition(rule)!
|
||||
const isUp = condition.op === '>='
|
||||
const alert = parsePointPriceAlert(rule, symbol)!
|
||||
const isUp = alert.direction === 'up'
|
||||
return (
|
||||
<div key={rule.id} className={`flex items-center gap-3 px-2 py-3 ${rule.enabled ? '' : 'opacity-55'}`}>
|
||||
<span className={`grid h-7 w-7 shrink-0 place-items-center rounded-md ${isUp ? 'bg-bull/10 text-bull' : 'bg-bear/10 text-bear'}`}>
|
||||
@@ -349,7 +368,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs text-foreground">{rule.name}</span>
|
||||
<span className="mt-0.5 block font-mono text-[10px] text-muted">{isUp ? '涨至' : '跌至'} {condition.value!.toFixed(2)} · {COOLDOWNS.find(item => item.value === rule.cooldown_seconds)?.label ?? `${rule.cooldown_seconds} 秒`}</span>
|
||||
<span className="mt-0.5 block font-mono text-[10px] text-muted">{isUp ? '涨至' : '跌至'} {alert.target.toFixed(2)} · {COOLDOWNS.find(item => item.value === rule.cooldown_seconds)?.label ?? `${rule.cooldown_seconds} 秒`}</span>
|
||||
</span>
|
||||
<button role="switch" aria-checked={rule.enabled} onClick={() => toggle.mutate(rule)} disabled={toggle.isPending} className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${rule.enabled ? 'bg-sky-500' : 'bg-elevated'}`} title={rule.enabled ? '停用' : '启用'}>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${rule.enabled ? 'translate-x-[18px]' : 'translate-x-0.5'}`} />
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MonitorRule } from '@/lib/api'
|
||||
|
||||
export type PriceAlertDirection = 'up' | 'down'
|
||||
|
||||
export interface PointPriceAlert {
|
||||
rule: MonitorRule
|
||||
direction: PriceAlertDirection
|
||||
target: number
|
||||
}
|
||||
|
||||
export interface MonitorPriceLine {
|
||||
value: number
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export function parsePointPriceAlert(rule: MonitorRule, symbol?: string): PointPriceAlert | null {
|
||||
if (
|
||||
rule.type !== 'price'
|
||||
|| rule.scope !== 'symbols'
|
||||
|| rule.symbols.length !== 1
|
||||
|| (symbol != null && rule.symbols[0] !== symbol)
|
||||
|| rule.conditions.length !== 1
|
||||
) return null
|
||||
|
||||
const condition = rule.conditions[0]
|
||||
if (condition.field !== 'close' || !['>=', '<='].includes(condition.op)) return null
|
||||
if (typeof condition.value !== 'number' || !Number.isFinite(condition.value) || condition.value <= 0) return null
|
||||
|
||||
return {
|
||||
rule,
|
||||
direction: condition.op === '>=' ? 'up' : 'down',
|
||||
target: condition.value,
|
||||
}
|
||||
}
|
||||
|
||||
export function inferPriceAlertDirection(
|
||||
target: number,
|
||||
currentPrice: number | null | undefined,
|
||||
): PriceAlertDirection {
|
||||
return currentPrice != null && Number.isFinite(currentPrice) && target < currentPrice ? 'down' : 'up'
|
||||
}
|
||||
|
||||
export function buildPriceAlertMessage(
|
||||
name: string,
|
||||
symbol: string,
|
||||
direction: PriceAlertDirection,
|
||||
target: number,
|
||||
): string {
|
||||
return `${name || symbol}股价${direction === 'up' ? '上穿' : '下穿'} ${target.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function buildMonitorPriceLines(rules: MonitorRule[], symbol: string): MonitorPriceLine[] {
|
||||
return rules.flatMap(rule => {
|
||||
if (!rule.enabled) return []
|
||||
const alert = parsePointPriceAlert(rule, symbol)
|
||||
if (!alert) return []
|
||||
const isUp = alert.direction === 'up'
|
||||
return [{
|
||||
value: alert.target,
|
||||
label: `${isUp ? '上穿' : '下穿'} ${alert.target.toFixed(2)}`,
|
||||
color: isUp ? '#C74040' : '#2D9B65',
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -6,11 +6,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { BarChart3, Database, Radio, SlidersHorizontal, Sparkles, Settings2, Zap, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||
import { BarChart3, Database, Radio, SlidersHorizontal, Sparkles, Settings2, Zap, PanelLeftClose, PanelLeftOpen, Clock3 } from 'lucide-react'
|
||||
import { SettingsAIPanel } from './settings/AI'
|
||||
import { SettingsMonitoringPanel } from './settings/Monitoring'
|
||||
import { SettingsExtPagesPanel } from './settings/ExtPages'
|
||||
import { SettingsMenuSettingsPanel } from './settings/MenuSettings'
|
||||
import { SettingsTimeoutPanel } from './settings/Timeout'
|
||||
import { SettingsSystemPanel } from './settings/System'
|
||||
import { SettingsCustomSignalsPanel } from './settings/CustomSignals'
|
||||
import { SettingsDataSourcesPanel } from './settings/DataSources'
|
||||
@@ -35,6 +36,7 @@ const TABS: readonly TabDef[] = [
|
||||
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
|
||||
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
|
||||
{ key: 'signals', label: '信号库', icon: Zap, panel: SettingsCustomSignalsPanel },
|
||||
{ key: 'timeout', label: '超时设置', icon: Clock3, panel: SettingsTimeoutPanel },
|
||||
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
|
||||
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
||||
]
|
||||
@@ -70,7 +72,7 @@ export function Settings() {
|
||||
<div className="flex gap-6 items-stretch">
|
||||
{/* ===== 竖向 Tab 侧栏 ===== */}
|
||||
<nav className={cn('shrink-0 transition-all duration-200 ease-smooth', collapsed ? 'w-10' : 'w-36')}>
|
||||
<div className="flex flex-col gap-0.5 min-h-[60vh] sticky top-6">
|
||||
<div className="flex flex-col gap-0.5 justify-center min-h-[60vh] sticky top-6">
|
||||
{/* 收起/展开 按钮 */}
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Check, Clock3, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react'
|
||||
import { api, type DataSourceItem, type PluginDataSourceItem, type Preferences } from '@/lib/api'
|
||||
import { Check, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react'
|
||||
import { api, type DataSourceItem, type PluginDataSourceItem } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { toast } from '@/components/Toast'
|
||||
@@ -16,65 +16,12 @@ const DATASET_LABEL: Record<string, string> = {
|
||||
minute: '分钟',
|
||||
}
|
||||
|
||||
type TimeoutUnit = 'second' | 'minute' | 'hour'
|
||||
|
||||
const TIMEOUT_UNIT_SECONDS: Record<TimeoutUnit, number> = {
|
||||
second: 1,
|
||||
minute: 60,
|
||||
hour: 3600,
|
||||
}
|
||||
|
||||
function preferredTimeoutUnit(seconds: number): TimeoutUnit {
|
||||
if (seconds >= 3600 && seconds % 1800 === 0) return 'hour'
|
||||
if (seconds % 60 === 0) return 'minute'
|
||||
return 'second'
|
||||
}
|
||||
|
||||
function formatTimeoutValue(seconds: number, unit: TimeoutUnit): string {
|
||||
if (!Number.isFinite(seconds)) return ''
|
||||
const value = seconds / TIMEOUT_UNIT_SECONDS[unit]
|
||||
return String(Number(value.toFixed(4)))
|
||||
}
|
||||
|
||||
export function SettingsDataSourcesPanel() {
|
||||
const qc = useQueryClient()
|
||||
const prefs = usePreferences()
|
||||
const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources })
|
||||
const [selected, setSelected] = useState<string>('tickflow') // 当前在右侧编辑的源 name
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const [timeoutDraft, setTimeoutDraft] = useState<{ regular: string; long: string } | null>(null)
|
||||
const [regularUnitOverride, setRegularUnitOverride] = useState<TimeoutUnit | null>(null)
|
||||
const [longUnitOverride, setLongUnitOverride] = useState<TimeoutUnit | null>(null)
|
||||
|
||||
const currentRegularTimeout = prefs.data?.data_source_job_timeout_s ?? 1200
|
||||
const currentLongTimeout = prefs.data?.data_source_long_job_timeout_s ?? 1800
|
||||
const regularTimeoutUnit = regularUnitOverride ?? preferredTimeoutUnit(currentRegularTimeout)
|
||||
const longTimeoutUnit = longUnitOverride ?? preferredTimeoutUnit(currentLongTimeout)
|
||||
const regularTimeoutInput = timeoutDraft?.regular
|
||||
?? formatTimeoutValue(currentRegularTimeout, regularTimeoutUnit)
|
||||
const longTimeoutInput = timeoutDraft?.long
|
||||
?? formatTimeoutValue(currentLongTimeout, longTimeoutUnit)
|
||||
const regularInputNumber = Number(regularTimeoutInput)
|
||||
const longInputNumber = Number(longTimeoutInput)
|
||||
const regularTimeout = Math.round(regularInputNumber * TIMEOUT_UNIT_SECONDS[regularTimeoutUnit])
|
||||
const longTimeout = Math.round(longInputNumber * TIMEOUT_UNIT_SECONDS[longTimeoutUnit])
|
||||
const timeoutValuesValid = Number.isFinite(regularInputNumber) && regularInputNumber > 0
|
||||
&& Number.isFinite(longInputNumber) && longInputNumber > 0
|
||||
&& regularTimeout >= 60 && longTimeout >= 60
|
||||
const timeoutValuesChanged = regularTimeout !== currentRegularTimeout
|
||||
|| longTimeout !== currentLongTimeout
|
||||
|
||||
const saveJobTimeouts = useMutation({
|
||||
mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout),
|
||||
onSuccess: (saved) => {
|
||||
qc.setQueryData<Preferences>(QK.preferences, current => (
|
||||
current ? { ...current, ...saved } : current
|
||||
))
|
||||
setTimeoutDraft(null)
|
||||
toast('任务超时配置已保存', 'success')
|
||||
},
|
||||
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
|
||||
})
|
||||
|
||||
const reload = useMutation({
|
||||
mutationFn: api.reloadDataSources,
|
||||
@@ -366,93 +313,6 @@ export function SettingsDataSourcesPanel() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-border bg-surface p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock3 className="h-4 w-4 text-secondary mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">数据任务超时</h2>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => saveJobTimeouts.mutate()}
|
||||
disabled={!timeoutValuesValid || !timeoutValuesChanged || saveJobTimeouts.isPending}
|
||||
className="shrink-0 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{saveJobTimeouts.isPending ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
||||
<span className="block text-xs font-medium text-foreground mb-1">普通任务超时</span>
|
||||
<span className="block text-[10px] text-muted mb-2">日 K 管道、扩展、修正与重算任务</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
||||
step={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 0.5}
|
||||
value={regularTimeoutInput}
|
||||
onChange={e => setTimeoutDraft({ regular: e.target.value, long: longTimeoutInput })}
|
||||
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={regularTimeoutUnit}
|
||||
onChange={e => {
|
||||
const nextUnit = e.target.value as TimeoutUnit
|
||||
setTimeoutDraft({
|
||||
regular: formatTimeoutValue(regularTimeout, nextUnit),
|
||||
long: longTimeoutInput,
|
||||
})
|
||||
setRegularUnitOverride(nextUnit)
|
||||
}}
|
||||
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
||||
>
|
||||
<option value="second">秒</option>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="block text-[10px] text-muted/60 mt-1.5">默认 20 分钟,最小 1 分钟</span>
|
||||
</label>
|
||||
|
||||
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
||||
<span className="block text-xs font-medium text-foreground mb-1">长任务超时</span>
|
||||
<span className="block text-[10px] text-muted mb-2">分钟 K 全市场同步任务</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
||||
step={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 0.5}
|
||||
value={longTimeoutInput}
|
||||
onChange={e => setTimeoutDraft({ regular: regularTimeoutInput, long: e.target.value })}
|
||||
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={longTimeoutUnit}
|
||||
onChange={e => {
|
||||
const nextUnit = e.target.value as TimeoutUnit
|
||||
setTimeoutDraft({
|
||||
regular: regularTimeoutInput,
|
||||
long: formatTimeoutValue(longTimeout, nextUnit),
|
||||
})
|
||||
setLongUnitOverride(nextUnit)
|
||||
}}
|
||||
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
||||
>
|
||||
<option value="second">秒</option>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="block text-[10px] text-muted/60 mt-1.5">默认 30 分钟,最小 1 分钟</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== 下方: 编辑区 ===== */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 数据任务超时配置卡片 — 从 DataSources 抽出, 放在系统设置页。
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Clock3 } from 'lucide-react'
|
||||
import { api, type Preferences } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
type TimeoutUnit = 'second' | 'minute' | 'hour'
|
||||
|
||||
const TIMEOUT_UNIT_SECONDS: Record<TimeoutUnit, number> = {
|
||||
second: 1,
|
||||
minute: 60,
|
||||
hour: 3600,
|
||||
}
|
||||
|
||||
function preferredTimeoutUnit(seconds: number): TimeoutUnit {
|
||||
if (seconds >= 3600 && seconds % 1800 === 0) return 'hour'
|
||||
if (seconds % 60 === 0) return 'minute'
|
||||
return 'second'
|
||||
}
|
||||
|
||||
function formatTimeoutValue(seconds: number, unit: TimeoutUnit): string {
|
||||
if (!Number.isFinite(seconds)) return ''
|
||||
const value = seconds / TIMEOUT_UNIT_SECONDS[unit]
|
||||
return String(Number(value.toFixed(4)))
|
||||
}
|
||||
|
||||
export function JobTimeoutCard() {
|
||||
const qc = useQueryClient()
|
||||
const prefs = usePreferences()
|
||||
const [timeoutDraft, setTimeoutDraft] = useState<{ regular: string; long: string } | null>(null)
|
||||
const [regularUnitOverride, setRegularUnitOverride] = useState<TimeoutUnit | null>(null)
|
||||
const [longUnitOverride, setLongUnitOverride] = useState<TimeoutUnit | null>(null)
|
||||
|
||||
const currentRegularTimeout = prefs.data?.data_source_job_timeout_s ?? 1200
|
||||
const currentLongTimeout = prefs.data?.data_source_long_job_timeout_s ?? 1800
|
||||
const regularTimeoutUnit = regularUnitOverride ?? preferredTimeoutUnit(currentRegularTimeout)
|
||||
const longTimeoutUnit = longUnitOverride ?? preferredTimeoutUnit(currentLongTimeout)
|
||||
const regularTimeoutInput = timeoutDraft?.regular
|
||||
?? formatTimeoutValue(currentRegularTimeout, regularTimeoutUnit)
|
||||
const longTimeoutInput = timeoutDraft?.long
|
||||
?? formatTimeoutValue(currentLongTimeout, longTimeoutUnit)
|
||||
const regularInputNumber = Number(regularTimeoutInput)
|
||||
const longInputNumber = Number(longTimeoutInput)
|
||||
const regularTimeout = Math.round(regularInputNumber * TIMEOUT_UNIT_SECONDS[regularTimeoutUnit])
|
||||
const longTimeout = Math.round(longInputNumber * TIMEOUT_UNIT_SECONDS[longTimeoutUnit])
|
||||
const timeoutValuesValid = Number.isFinite(regularInputNumber) && regularInputNumber > 0
|
||||
&& Number.isFinite(longInputNumber) && longInputNumber > 0
|
||||
&& regularTimeout >= 60 && longTimeout >= 60
|
||||
const timeoutValuesChanged = regularTimeout !== currentRegularTimeout
|
||||
|| longTimeout !== currentLongTimeout
|
||||
|
||||
const saveJobTimeouts = useMutation({
|
||||
mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout),
|
||||
onSuccess: (saved) => {
|
||||
qc.setQueryData<Preferences>(QK.preferences, current => (
|
||||
current ? { ...current, ...saved } : current
|
||||
))
|
||||
setTimeoutDraft(null)
|
||||
toast('任务超时配置已保存', 'success')
|
||||
},
|
||||
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-border bg-surface p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock3 className="h-4 w-4 text-secondary mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">超时设置</h2>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => saveJobTimeouts.mutate()}
|
||||
disabled={!timeoutValuesValid || !timeoutValuesChanged || saveJobTimeouts.isPending}
|
||||
className="shrink-0 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{saveJobTimeouts.isPending ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
||||
<span className="block text-xs font-medium text-foreground mb-1">普通任务超时</span>
|
||||
<span className="block text-[10px] text-muted mb-2">日 K 管道、扩展、修正与重算任务</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
||||
step={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 0.5}
|
||||
value={regularTimeoutInput}
|
||||
onChange={e => setTimeoutDraft({ regular: e.target.value, long: longTimeoutInput })}
|
||||
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={regularTimeoutUnit}
|
||||
onChange={e => {
|
||||
const nextUnit = e.target.value as TimeoutUnit
|
||||
setTimeoutDraft({
|
||||
regular: formatTimeoutValue(regularTimeout, nextUnit),
|
||||
long: longTimeoutInput,
|
||||
})
|
||||
setRegularUnitOverride(nextUnit)
|
||||
}}
|
||||
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
||||
>
|
||||
<option value="second">秒</option>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="block text-[10px] text-muted/60 mt-1.5">默认 20 分钟,最小 1 分钟</span>
|
||||
</label>
|
||||
|
||||
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
||||
<span className="block text-xs font-medium text-foreground mb-1">长任务超时</span>
|
||||
<span className="block text-[10px] text-muted mb-2">分钟 K 全市场同步任务</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
||||
step={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 0.5}
|
||||
value={longTimeoutInput}
|
||||
onChange={e => setTimeoutDraft({ regular: regularTimeoutInput, long: e.target.value })}
|
||||
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={longTimeoutUnit}
|
||||
onChange={e => {
|
||||
const nextUnit = e.target.value as TimeoutUnit
|
||||
setTimeoutDraft({
|
||||
regular: regularTimeoutInput,
|
||||
long: formatTimeoutValue(longTimeout, nextUnit),
|
||||
})
|
||||
setLongUnitOverride(nextUnit)
|
||||
}}
|
||||
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
||||
>
|
||||
<option value="second">秒</option>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="block text-[10px] text-muted/60 mt-1.5">默认 30 分钟,最小 1 分钟</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 超时设置面板 — 数据任务超时配置。
|
||||
*/
|
||||
import { JobTimeoutCard } from './JobTimeoutCard'
|
||||
|
||||
export function SettingsTimeoutPanel() {
|
||||
return <JobTimeoutCard />
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
Reference in New Issue
Block a user