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:
shy3130
2026-08-10 23:32:44 +08:00
parent aaa888347c
commit 8c00f7a3fd
26 changed files with 666 additions and 216 deletions
+1 -1
View File
@@ -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)
+12 -2
View File
@@ -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,7 +1070,10 @@ def sync_and_persist_minute(
written_box = [0] # list 闭包, 绕过 Python 闭包外层赋值
def _persist(seg_df: pl.DataFrame) -> None:
written_box[0] += _write_minute_partition(seg_df, minute_dir)
# 单股自动补齐可能与另一个补齐请求同时写同一日期分区。Windows 不允许
# 替换仍被另一写入占用的临时文件,因此读-改-写必须复用仓库写锁。
with repo._write_lock:
written_box[0] += _write_minute_partition(seg_df, minute_dir)
segment_days = preferences.get_minute_sync_segment_days()
sync_minute_batch(
+10 -2
View File
@@ -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 缓存管理
+1 -1
View File
@@ -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")
+33
View File
@@ -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):
+21
View File
@@ -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
+69 -7
View File
@@ -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>
<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"
>
{syncMinute.isPending
? <Loader2 className="h-3 w-3 animate-spin" />
: <Download className="h-3 w-3" />}
</button>
{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"
onClick={() => {
autoSyncRef.current = `${symbol}:${days}`
syncMinute.mutate()
}}
className="inline-flex shrink-0 items-center gap-1 text-accent hover:text-accent/80"
>
<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>
)}
+11
View File
@@ -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>
)
}
+46 -3
View File
@@ -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'}`} />
+65
View File
@@ -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',
}]
})
}
+4 -2
View File
@@ -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}
+2 -142
View File
@@ -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>
)
}
+8
View File
@@ -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