Files
stock/frontend/src/components/StockDetailOverlay.vue
2026-08-16 00:05:26 +08:00

848 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
addWatchlist, clearTrades, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
importTrades, removeWatchlist,
} from '@/api/client';
import type {
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField,
TradesImportResponse, UserTrade,
} from '@/api/types';
import { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings';
import DetailKLine from './DetailKLine.vue';
const props = defineProps<{
items: ScreenerItemOut[];
initial: string; // ts_code
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'watched-change'): void;
/** 浮层内切股(键盘 ↑/↓、侧栏点击)时上报当前 ts_code父组件据此同步路由 */
(e: 'change', code: string): void;
}>();
const settings = useSettingsStore();
// ---------- 状态 ----------
const active = ref(props.initial);
const data = ref<PreviewResponse | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const filter = ref('');
// 复权切换(持久化到设置;切换即重拉)
const ADJUSTS: { key: PriceAdjust; label: string }[] = [
{ key: 'bfq', label: '不复权' },
{ key: 'qfq', label: '前复权' },
{ key: 'hfq', label: '后复权' },
];
const adjust = computed(() => settings.priceAdjust);
function setAdjust(key: PriceAdjust) {
settings.setPriceAdjust(key);
}
// K线周期切换持久化到用户偏好
const PERIODS: { key: Timeframe; label: string }[] = [
{ key: '1d', label: '日K' },
{ key: '1w', label: '周K' },
{ key: '1M', label: '月K' },
{ key: '1y', label: '年K' },
];
const timeframe = ref<Timeframe>((settings.chartLayout.timeframe as Timeframe) ?? '1d');
function setTimeframe(tf: Timeframe) {
timeframe.value = tf;
settings.setChartLayout({ timeframe: tf });
}
// ---------- 副图 / MA / 高度(全部随用户偏好持久化) ----------
const SUBS = [
{ key: 'vol', label: 'VOL' },
{ key: 'macd', label: 'MACD' },
{ key: 'kdj', label: 'KDJ' },
{ key: 'rsi', label: 'RSI' },
];
const layout = computed<ChartLayoutPrefs>(() => settings.chartLayout);
const subPanes = computed<string[]>(() => layout.value.subPanes);
const maPeriods = computed<number[]>(() => layout.value.maPeriods);
const subHeights = computed(() => layout.value.subHeights);
const tooltipFields = computed<TooltipField[]>(() => layout.value.tooltipFields ?? DEFAULT_TOOLTIP_FIELDS);
const showBoll = ref(false);
function toggleSub(key: string) {
const cur = subPanes.value;
settings.setChartLayout({
subPanes: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
});
}
// 副图高度改为图内分隔条直接拖拽DetailKLine 订阅 onPaneDrag 持久化),此处不再提供按钮
// 副图拖拽排序
let dragKey: string | null = null;
function onDragStart(e: DragEvent, key: string) {
dragKey = key;
e.dataTransfer?.setData('text/plain', key);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDrop(target: string) {
if (!dragKey || dragKey === target) return;
const arr = [...subPanes.value];
const from = arr.indexOf(dragKey);
if (from >= 0) arr.splice(from, 1);
const to = arr.indexOf(target);
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
settings.setChartLayout({ subPanes: arr });
dragKey = null;
}
// ---------- MA 配置(弹层) ----------
const MA_PRESETS = [5, 10, 20, 30, 60, 120, 250];
const showMaConfig = ref(false);
const customMa = ref('');
function toggleMa(p: number) {
const cur = maPeriods.value;
settings.setChartLayout({
maPeriods: cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p].sort((a, b) => a - b),
});
}
function addCustomMa() {
const v = parseInt(customMa.value, 10);
if (v >= 1 && v <= 500 && !maPeriods.value.includes(v)) {
settings.setChartLayout({ maPeriods: [...maPeriods.value, v].sort((a, b) => a - b) });
}
customMa.value = '';
showMaConfig.value = false;
}
// ---------- 浮层(鼠标悬停信息框)指标配置 ----------
const showTipConfig = ref(false);
function toggleTipField(key: TooltipField) {
const cur = tooltipFields.value;
settings.setChartLayout({
tooltipFields: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
});
}
// ---------- 日期跳转(输入 YYYYMMDD把该日K线定位到可视区中央 ----------
const JUMP_END_DAYS = 170; // 锚点 + 170 自然日 ≈ 120 个交易日:锚点恰落在 240 根首吐窗口正中
const jumpInput = ref('');
const jumpErr = ref('');
const jumpTs = ref<number | null>(null); // 当前锚点本地零点时间戳null=最新行情模式
const klineRef = ref<InstanceType<typeof DetailKLine> | null>(null);
/** '20200218' → 本地零点时间戳(与 Candle.ts 的反序列化口径一致精确命中当日K线非法返回 null */
function parseJumpDate(raw: string): number | null {
if (!/^\d{8}$/.test(raw)) return null;
const y = +raw.slice(0, 4), m = +raw.slice(4, 6), d = +raw.slice(6, 8);
const dt = new Date(y, m - 1, d);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
if (y < 1990 || y > 2099) return null;
return dt.getTime();
}
/** 时间戳 → 'YYYY-MM-DD'(后端 end 参数格式) */
function fmtDashDate(ms: number): string {
const dt = new Date(ms);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
}
function onJumpInput() {
jumpInput.value = jumpInput.value.replace(/\D/g, '').slice(0, 8);
jumpErr.value = '';
}
function jumpToDate() {
const ts = parseJumpDate(jumpInput.value.trim());
if (ts == null) { jumpErr.value = '日期格式20200218'; return; }
jumpErr.value = '';
// 非日K周期先切回日K再跳watcher 重拉时 load 会带上刚设好的锚点)
if (timeframe.value !== '1d') { jumpTs.value = ts; setTimeframe('1d'); return; }
// 快路径:目标日已在当前渲染窗口内 → 纯滚动居中,不打网络
if (klineRef.value?.centerOn(ts)) { jumpTs.value = ts; return; }
// 慢路径:目标日不在窗口内 → 以锚点为中点重拉一窗end 不含当日,故右缘=锚点+170 自然日)
jumpTs.value = ts;
void load(active.value);
}
/** 清除锚点回到最新行情(锚定窗口的右缘停在锚点日之后,需要这个出口) */
function clearJump() {
jumpTs.value = null;
jumpErr.value = '';
void load(active.value);
}
/** DetailKLine 上报:锚点在拉回的数据里也找不到(早于上市首日,或晚于最后一根的未来日期)。
* 统一走与「空数据」相同的回退:清锚点、提示、回最新行情,避免 chip/统计停留在假锚定状态。 */
function onCenterMiss() {
if (jumpTs.value == null) return;
jumpTs.value = null;
jumpErr.value = '该日期无K线早于上市或晚于最新数据已回到最新行情';
void load(active.value);
}
// ---------- 自选股(星标) ----------
const watched = ref(false);
const watchBusy = ref(false);
const watchedSet = ref<Set<string>>(new Set());
async function refreshWatched() {
try {
watchedSet.value = new Set(await getWatchlistApi());
} catch { /* 未登录等场景忽略 */ }
watched.value = watchedSet.value.has(active.value);
}
async function toggleWatch() {
if (watchBusy.value) return;
watchBusy.value = true;
try {
const list = watched.value
? await removeWatchlist(active.value)
: await addWatchlist(active.value);
watchedSet.value = new Set(list);
watched.value = watchedSet.value.has(active.value);
emit('watched-change');
} catch { /* 忽略 */ } finally {
watchBusy.value = false;
}
}
// ---------- 实盘交易点(交割单导入) ----------
const trades = ref<UserTrade[]>([]);
const showTrades = ref(true);
const showTradeImport = ref(false);
const importing = ref(false);
const importResult = ref<TradesImportResponse | null>(null);
const importErr = ref<string | null>(null);
/** 拉当前股的实盘成交(失败静默:未登录/网络异常都不影响看图)。
* 与 load() 同款的请求序号:切股瞬间并发拉 K 线与成交,乱序返回的旧股
* 成交绝不能回填——否则旧股买卖点会画到新股K线上。 */
let tradesToken = 0;
async function loadTrades(code: string) {
const token = ++tradesToken;
try {
const list = await getTrades(code);
if (token === tradesToken) trades.value = list;
} catch {
if (token === tradesToken) trades.value = [];
}
}
watch(active, (code) => {
trades.value = []; // 同步先清:新图挂载时(成交未返回)不能带着旧股标记
loadTrades(code);
}, { immediate: true });
const fmtQty = (q: number) =>
q >= 10000 ? `${(q / 10000).toFixed(1).replace(/\.0$/, '')}` : String(Math.round(q));
const fmtPrice = (p: number) => p.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
/** 按日聚合成标记:图上只显示 B/S/T 单个字母B=当日只买 贴 low 下方、S=当日只卖 贴 high 上方、
* T=当日买+卖「做T」贴 high 上方);数量/均价/费用收进 rows悬停字母时才显示。
* 均价是券商原始成交价的股数加权均值(不复权口径,仅作参考——标记位置贴 bar 高低点,
* 已随复权切换自动对齐)。 */
const tradeMarkers = computed(() => {
if (!showTrades.value) return [];
const byDay = new Map<string, { ts: number; list: UserTrade[] }>();
for (const t of trades.value) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t.trade_date);
if (!m) continue;
let d = byDay.get(m[0]);
if (!d) byDay.set(m[0], d = { ts: new Date(+m[1], +m[2] - 1, +m[3]).getTime(), list: [] });
d.list.push(t);
}
const sumBy = (list: UserTrade[], f: (t: UserTrade) => number) => list.reduce((s, t) => s + f(t), 0);
const dirRow = (label: string, list: UserTrade[], tone: 'buy' | 'sell') => {
const qty = sumBy(list, (t) => t.qty);
const wsum = sumBy(list, (t) => (t.price ?? 0) * t.qty);
const wqty = sumBy(list, (t) => (t.price != null ? t.qty : 0));
const avg = wqty > 0 ? wsum / wqty : null;
return { label, text: `${fmtQty(qty)}${avg != null ? ` @ ${fmtPrice(avg)}` : ''}`, tone };
};
return [...byDay.entries()].map(([date, d]) => {
const buys = d.list.filter((t) => t.direction === 'buy');
const sells = d.list.filter((t) => t.direction === 'sell');
const kind: 'B' | 'S' | 'T' = buys.length && sells.length ? 'T' : buys.length ? 'B' : 'S';
const rows: { label: string; text: string; tone: 'buy' | 'sell' | '' }[] = [
...(buys.length ? [dirRow('买入', buys, 'buy')] : []),
...(sells.length ? [dirRow('卖出', sells, 'sell')] : []),
];
const fee = sumBy(d.list, (t) => t.fee ?? 0);
if (fee > 0) rows.push({ label: '费用', text: fmtPrice(fee), tone: '' });
return { key: date, ts: d.ts, kind, rows };
}).sort((a, b) => a.ts - b.ts);
});
async function onTradeFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
importing.value = true;
importResult.value = null;
importErr.value = null;
try {
importResult.value = await importTrades(file);
await loadTrades(active.value);
} catch (err) {
importErr.value = err instanceof Error ? err.message : '导入失败';
} finally {
importing.value = false;
(e.target as HTMLInputElement).value = ''; // 允许重选同一文件
}
}
async function onClearTrades() {
if (!window.confirm('确定清空全部股票的成交记录?此操作不可恢复(需重新导入交割单)。')) return;
try {
await clearTrades();
trades.value = [];
importResult.value = null;
importErr.value = null;
} catch (err) {
importErr.value = err instanceof Error ? err.message : '清空失败';
}
}
/** 打开导入弹窗:清掉上一次的结果/错误,避免误读为本次操作的结果 */
function openTradeImport() {
importResult.value = null;
importErr.value = null;
showMaConfig.value = false;
showTipConfig.value = false;
showTradeImport.value = true;
}
const filteredItems = computed(() => {
const q = filter.value.trim().toLowerCase();
if (!q) return props.items;
return props.items.filter(
(it) => it.ts_code.toLowerCase().includes(q) || it.name.toLowerCase().includes(q),
);
});
const activeItem = computed(
() => props.items.find((it) => it.ts_code === active.value) ?? null,
);
// 头部/右侧展示值:优先预览信息(最新),否则用选股行数据兜底
const header = computed(() => {
const info = data.value?.info;
const item = activeItem.value;
return {
name: info?.name ?? item?.name ?? active.value,
close: info?.close ?? item?.close ?? null,
pct: info?.pct_chg ?? item?.pct_chg ?? null,
};
});
// ---------- 数据加载(首屏 ~500 根秒开;图表内向左滚动时按 end 参数逐页向前翻历史) ----------
let fetchToken = 0;
async function load(code: string) {
const token = ++fetchToken;
const anchor = jumpTs.value; // 跳转锚点:之后的所有重拉(复权/周期/MA都围绕它取窗视口位置不漂
loading.value = true;
error.value = null;
data.value = null;
try {
const res = await getStockPreview(code, {
limit: 500,
// 锚定取数:窗口右缘=锚点+170 自然日(后端 end 不含当日),锚点恰好落在 240 根首吐窗口正中
end: anchor != null ? fmtDashDate(anchor + JUMP_END_DAYS * 86400000) : undefined,
adjust: adjust.value,
timeframe: timeframe.value,
mas: maPeriods.value,
});
if (token !== fetchToken) return;
if (anchor != null && res.candles.length === 0) {
// 跳转日期早于上市等锚定窗口取不到任何K线 → 退回最新行情
jumpTs.value = null;
jumpErr.value = '该日期无数据(可能早于上市),已回到最新行情';
void load(code);
return;
}
data.value = res;
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
} finally {
if (token === fetchToken) loading.value = false;
}
}
/** 向前翻页:取某日期之前的一页历史(复权/周期/MA 口径与首屏一致;切股或切口径后自动失效) */
async function loadOlder(end: string, count: number) {
const token = fetchToken;
try {
const res = await getStockPreview(active.value, {
limit: count,
end,
adjust: adjust.value,
timeframe: timeframe.value,
mas: maPeriods.value,
});
if (token !== fetchToken) return null;
return { candles: res.candles, indicators: res.indicators, hasMore: res.has_more ?? false };
} catch {
return null; // 网络失败:图表停止向前翻页(不中断已渲染内容)
}
}
watch(active, (code) => {
// 切股清空日期锚点:新股票以最新行情打开
jumpTs.value = null;
jumpErr.value = '';
load(code);
emit('change', code); // 父组件把当前股写进路由,刷新后可还原
}, { immediate: true });
watch(adjust, () => load(active.value));
watch(timeframe, () => load(active.value));
// MA 周期变化也要重拉(后端按 mas 计算指标序列)
watch(maPeriods, () => load(active.value));
// 切股时同步自选状态
watch(active, (code) => {
watched.value = watchedSet.value.has(code);
}, { immediate: true });
void refreshWatched();
function moveActive(delta: number) {
const list = filteredItems.value;
const idx = list.findIndex((it) => it.ts_code === active.value);
if (list.length === 0) return;
const next = idx < 0 ? 0 : Math.min(list.length - 1, Math.max(0, idx + delta));
active.value = list[next].ts_code;
}
// ---------- 键盘 / 滚动锁 ----------
function onKeydown(e: KeyboardEvent) {
if (e.isComposing) return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// 弹层打开时接管按键Esc 关弹层,其余(含 ↑/↓)不再切换底层个股
const modalOpen = showTradeImport.value || showMaConfig.value || showTipConfig.value;
if (e.key === 'Escape') {
if (showTradeImport.value) showTradeImport.value = false;
else if (showMaConfig.value || showTipConfig.value) { showMaConfig.value = false; showTipConfig.value = false; }
else emit('close');
}
else if (modalOpen) return;
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
}
onMounted(() => {
window.addEventListener('keydown', onKeydown);
document.body.style.overflow = 'hidden';
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
// ---------- 右侧信息栏增强52周高低 / 年初至今(从日线序列算,无数据留空) ----------
const stats = computed(() => {
// 日期跳转后窗口是历史段52周/年初至今口径失真,直接不显示
const bars = timeframe.value === '1d' && jumpTs.value == null ? data.value?.candles : null;
if (!bars || bars.length === 0) return { high52: null, low52: null, ytd: null };
const last = bars[bars.length - 1];
const lastTs = new Date(last.ts);
const yearStart = new Date(lastTs.getFullYear(), 0, 1).getTime();
let high = -Infinity, low = Infinity;
let ytdBase: number | null = null;
const cutoff = lastTs.getTime() - 365 * 24 * 3600 * 1000;
for (const b of bars) {
const t = new Date(b.ts).getTime();
if (t >= cutoff) { high = Math.max(high, b.high); low = Math.min(low, b.low); }
// 年初至今基准 = 上一年最后一根收盘
if (t < yearStart) ytdBase = b.close;
}
return {
high52: high === -Infinity ? null : high,
low52: low === Infinity ? null : low,
ytd: ytdBase && ytdBase !== 0 ? ((last.close - ytdBase) / ytdBase) * 100 : null,
};
});
// ---------- 格式化 ----------
const fmt = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
const fmtInt = (v: number | null | undefined) =>
v == null ? '—' : Math.round(v).toLocaleString();
const pctClass = (v: number | null | undefined) =>
v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '';
const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s ?? '—');
</script>
<template>
<div class="fixed inset-0 z-40 flex flex-col bg-black">
<!-- 顶栏 -->
<header class="flex h-12 shrink-0 items-center gap-3 border-b border-[#26272E] bg-[#101014] px-4">
<!-- 自选星标 -->
<button
type="button"
class="shrink-0 rounded p-1 transition-colors hover:bg-[#26272E] hover:text-white disabled:opacity-50"
:class="watched ? 'text-amber-500' : 'text-[#C3C9D2]'"
:title="watched ? '移出自选' : '加入自选'"
:disabled="watchBusy"
@click="toggleWatch"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" :fill="watched ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
<div class="flex items-baseline gap-2">
<span class="text-base font-semibold text-[#E8EAED]">{{ header.name }}</span>
<span class="text-[13px] text-[#9BA3AE]">{{ active }}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-lg font-semibold" :class="pctClass(header.pct)">{{ fmt(header.close) }}</span>
<span v-if="header.pct != null" class="text-sm" :class="pctClass(header.pct)">
{{ header.pct > 0 ? '+' : '' }}{{ fmt(header.pct) }}%
</span>
</div>
<!-- 周期切换 -->
<div class="flex rounded-md border border-[#26272E] p-0.5 text-xs">
<button
v-for="p in PERIODS"
:key="p.key"
type="button"
class="rounded px-2 py-0.5 transition-colors"
:class="timeframe === p.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:text-white'"
@click="setTimeframe(p.key)"
>{{ p.label }}</button>
</div>
<!-- 复权切换 -->
<div class="flex rounded-md border border-[#26272E] p-0.5 text-xs">
<button
v-for="a in ADJUSTS"
:key="a.key"
type="button"
class="rounded px-2 py-0.5 transition-colors"
:class="adjust === a.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:text-white'"
@click="setAdjust(a.key)"
>{{ a.label }}</button>
</div>
<button type="button" class="btn-ghost !px-2.5 !py-1 ml-auto cursor-pointer" title="关闭 (Esc)" @click="emit('close')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</header>
<!-- 三栏主体 -->
<div class="flex min-h-0 flex-1">
<!-- 命中列表 -->
<aside class="flex w-56 shrink-0 flex-col border-r border-[#26272E] bg-[#101014]">
<div class="border-b border-[#1E2026] p-2">
<input v-model="filter" type="text" class="ipt w-full !py-1 text-[13px]" placeholder="搜索代码 / 名称" />
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<button
v-for="it in filteredItems"
:key="it.ts_code"
type="button"
class="flex w-full items-center gap-2 border-b border-[#1E2026] px-3 py-2 text-left transition-colors"
:class="it.ts_code === active ? 'bg-blue-500/15' : 'hover:bg-[#26272E]'"
@click="active = it.ts_code"
>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-[#E8EAED]">{{ it.name }}</span>
<span class="block text-xs text-[#9BA3AE]">{{ it.ts_code }}</span>
</span>
<span class="text-right">
<span class="block text-sm font-medium" :class="pctClass(it.pct_chg)">{{ fmt(it.close) }}</span>
<span class="block text-xs" :class="pctClass(it.pct_chg)">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
</span>
</span>
</button>
<div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-[13px] text-[#9BA3AE]">无匹配</div>
</div>
<div class="border-t border-[#1E2026] px-3 py-2 text-xs text-[#9BA3AE]"> {{ filteredItems.length }} </div>
</aside>
<!-- K线 + 指标面板 -->
<section class="flex min-w-0 flex-1 flex-col">
<!-- 指标开关 / 排序 / MA 配置 -->
<div class="flex shrink-0 flex-wrap items-center gap-1.5 bg-[#101014] px-3 py-2">
<span class="text-xs text-[#9BA3AE]">副图</span>
<div
v-for="s in SUBS"
:key="s.key"
class="flex items-center overflow-hidden rounded-md border"
:class="subPanes.includes(s.key) ? 'border-blue-500' : 'border-[#26272E]'"
>
<button
type="button"
draggable="true"
class="px-2.5 py-1 text-[13px] transition-colors"
:class="subPanes.includes(s.key)
? 'bg-blue-600 text-white'
: 'bg-[#101014] text-[#9BA3AE] line-through'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 图内分隔线拖拽调高度' : '点击显示'"
@click="toggleSub(s.key)"
@dragstart="onDragStart($event, s.key)"
@dragover.prevent
@drop="onDrop(s.key)"
>
{{ s.label }}
</button>
</div>
<button
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showBoll ? 'border-purple-500 bg-purple-500 text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
title="主图叠加布林带"
@click="showBoll = !showBoll"
>BOLL</button>
<!-- 实盘交易点交割单导入的买卖标记 -->
<button
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showTrades && trades.length ? 'border-amber-500 bg-amber-500 text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
:title="trades.length ? `本股实盘成交 ${trades.length} 笔(交割单导入)` : '本股暂无实盘成交记录,点击导入交割单'"
@click="trades.length ? (showTrades = !showTrades) : openTradeImport()"
>交易点{{ trades.length ? ` ${trades.length}` : '' }}</button>
<button
type="button"
class="rounded-md border border-[#26272E] bg-[#101014] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
title="上传券商交割单,导入实盘买卖点"
@click="openTradeImport()"
>导入交割单</button>
<!-- MA 配置 -->
<div class="relative">
<button
type="button"
class="rounded-md border border-[#26272E] bg-[#101014] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
@click="showMaConfig = !showMaConfig; showTipConfig = false"
>MA 设置</button>
<div
v-if="showMaConfig"
class="absolute left-0 top-8 z-20 w-52 rounded-lg border border-[#33353D] bg-[#16181D] p-2.5 shadow-lg shadow-black/60"
>
<div class="mb-2 text-xs text-[#9BA3AE]">勾选主图显示的均线</div>
<div class="grid grid-cols-4 gap-1">
<label
v-for="p in MA_PRESETS"
:key="p"
class="flex cursor-pointer items-center justify-center rounded border px-1 py-1 text-[13px]"
:class="maPeriods.includes(p) ? 'border-blue-500 bg-blue-500/15 text-blue-300' : 'border-[#33353D] text-[#A8AFB8] hover:border-[#3A3D46] hover:text-[#E8EAED]'"
>
<input type="checkbox" class="hidden" :checked="maPeriods.includes(p)" @change="toggleMa(p)" />
MA{{ p }}
</label>
</div>
<div class="mt-2 flex items-center gap-1">
<input
v-model="customMa"
type="number" min="1" max="500"
class="ipt w-full !py-1 text-[13px]"
placeholder="自定义周期"
@keyup.enter="addCustomMa"
/>
<button type="button" class="btn-primary !px-2 !py-1 text-[13px]" @click="addCustomMa"></button>
</div>
<div class="mt-1.5 text-xs text-[#9BA3AE]">当前{{ maPeriods.map((p: number) => 'MA' + p).join(' / ') || '无' }}</div>
</div>
</div>
<!-- 浮层指标配置鼠标悬停信息框每行显示哪些指标 -->
<div class="relative">
<button
type="button"
class="rounded-md border border-[#26272E] bg-[#101014] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
@click="showTipConfig = !showTipConfig; showMaConfig = false"
>浮层设置</button>
<div
v-if="showTipConfig"
class="absolute left-0 top-8 z-20 w-56 rounded-lg border border-[#33353D] bg-[#16181D] p-2.5 shadow-lg shadow-black/60"
>
<div class="mb-2 text-xs text-[#9BA3AE]">勾选鼠标浮层里逐行显示的指标</div>
<div class="grid grid-cols-2 gap-1">
<label
v-for="f in TOOLTIP_FIELDS"
:key="f.key"
class="flex cursor-pointer items-center justify-center rounded border px-1 py-1 text-[13px]"
:class="tooltipFields.includes(f.key) ? 'border-blue-500 bg-blue-500/15 text-blue-300' : 'border-[#33353D] text-[#A8AFB8] hover:border-[#3A3D46] hover:text-[#E8EAED]'"
>
<input type="checkbox" class="hidden" :checked="tooltipFields.includes(f.key)" @change="toggleTipField(f.key)" />
{{ f.label }}
</label>
</div>
</div>
</div>
<!-- 日期跳转输入 YYYYMMDD该日K线居中显示 -->
<div class="ml-auto flex items-center gap-1.5">
<span class="text-xs text-[#9BA3AE]">定位</span>
<div class="flex items-stretch">
<input
v-model="jumpInput"
type="text"
class="w-24 rounded-l-md border border-[#26272E] bg-[#16181D] px-2 py-1 font-mono text-[13px] text-[#E8EAED] outline-none transition-colors placeholder:text-[#7A818C]"
:class="jumpErr ? 'border-red-500' : 'focus:border-blue-500'"
placeholder="20200218"
maxlength="8"
inputmode="numeric"
title="输入日期YYYYMMDD跳转后该日K线居中显示"
@input="onJumpInput"
@keyup.enter="jumpToDate"
/>
<button
type="button"
class="rounded-r-md border border-l-0 border-[#26272E] bg-[#101014] px-2 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
title="跳转到该日期的日K回车亦可"
@click="jumpToDate"
>跳转</button>
</div>
<button
v-if="jumpTs != null"
type="button"
class="rounded-md border border-blue-500/60 bg-blue-500/10 px-2 py-1 font-mono text-xs text-blue-300 transition-colors hover:bg-blue-500/20"
title="清除日期锚点,回到最新行情"
@click="clearJump"
>{{ fmtDashDate(jumpTs) }} </button>
<span v-if="jumpErr" class="text-xs text-red-400">{{ jumpErr }}</span>
</div>
</div>
<!-- 图表 -->
<div class="relative min-h-0 flex-1 bg-black p-1">
<div v-if="loading" class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/85 text-sm text-[#9BA3AE]">
<svg class="mb-2 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ active }} 加载日线中
</div>
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-400">{{ error }}</div>
<DetailKLine
v-else-if="data && data.candles.length"
ref="klineRef"
:ticker="data.ts_code"
:candles="data.candles"
:indicators="data.indicators"
:has-more="data.has_more ?? false"
:load-older="loadOlder"
:sub-panes="subPanes"
:ma-periods="maPeriods"
:sub-heights="subHeights"
:show-boll="showBoll"
:timeframe="timeframe"
:tooltip-fields="tooltipFields"
:center-ts="jumpTs"
:trade-markers="tradeMarkers"
@center-miss="onCenterMiss"
/>
<div v-else class="flex h-full items-center justify-center text-sm text-[#9BA3AE]">无数据</div>
</div>
</section>
<!-- 个股信息通达信式 -->
<aside v-if="data" class="w-72 shrink-0 overflow-y-auto border-l border-[#26272E] bg-[#101014] p-4">
<div class="border-b border-[#1E2026] pb-3">
<div class="text-[15px] font-semibold text-[#E8EAED]">{{ data.info.name }}</div>
<div class="mt-0.5 text-[13px] text-[#9BA3AE]">
{{ data.info.ts_code }}
<span v-if="data.info.market" class="ml-1 rounded bg-[#1E2026] px-1.5 py-0.5">{{ data.info.market }}</span>
</div>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-2xl font-semibold" :class="pctClass(data.info.pct_chg)">{{ fmt(data.info.close) }}</span>
<span v-if="data.info.pct_chg != null" class="text-sm" :class="pctClass(data.info.pct_chg)">
{{ data.info.pct_chg > 0 ? '+' : '' }}{{ fmt(data.info.pct_chg) }}%
</span>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-y-2 text-sm">
<template v-for="(row, i) in [
['今开', fmt(data.info.open)],
['昨收', fmt(data.info.pre_close)],
['最高', fmt(data.info.high)],
['最低', fmt(data.info.low)],
['成交量', fmtInt(data.info.volume_hand) + ' 手'],
['成交额', fmt(data.info.amount_yi) + ' 亿'],
['换手率', fmt(data.info.turnover_rate) + '%'],
['市盈率TTM', fmt(data.info.pe_ttm)],
['市净率', fmt(data.info.pb)],
['总市值', fmt(data.info.total_mv) + ' 亿'],
['流通市值', fmt(data.info.circ_mv) + ' 亿'],
['52周最高', fmt(stats.high52)],
['52周最低', fmt(stats.low52)],
['年初至今', stats.ytd == null ? '—' : (stats.ytd > 0 ? '+' : '') + stats.ytd.toFixed(2) + '%'],
['上市日期', fmtListDate(data.info.list_date)],
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
]" :key="i">
<span class="text-[#9BA3AE]">{{ row[0] }}</span>
<span class="text-right text-[#E8EAED]">{{ row[1] }}</span>
</template>
</div>
<!-- 股本/分红/股东(数据未接入前留空占位) -->
<div class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<div class="mb-2 text-[13px] text-[#9BA3AE]">股本 / 分红 / 股东</div>
<div class="grid grid-cols-2 gap-y-2">
<template v-for="(row, i) in [
['股东户数', '—'],
['户均持股', '—'],
['分红率', '—'],
['股息率', '—'],
]" :key="i">
<span class="text-[#9BA3AE]">{{ row[0] }}</span>
<span class="text-right text-[#C3C9D2]" title="数据源待接入">{{ row[1] }}</span>
</template>
</div>
</div>
<div class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<div class="mb-2 text-[13px] text-[#9BA3AE]">归属</div>
<div class="flex flex-wrap gap-1.5">
<span v-if="data.info.industry" class="rounded-full bg-[#1E2026] px-2.5 py-0.5 text-[13px] text-[#A8AFB8]">{{ data.info.industry }}</span>
<span v-if="data.info.area" class="rounded-full bg-[#1E2026] px-2.5 py-0.5 text-[13px] text-[#A8AFB8]">{{ data.info.area }}</span>
</div>
</div>
</aside>
</div>
<!-- 交割单导入弹窗 -->
<div
v-if="showTradeImport"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
@click.self="showTradeImport = false"
>
<div class="w-full max-w-md rounded-lg border border-[#33353D] bg-[#16181D] p-4 shadow-2xl shadow-black/70">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-[#E8EAED]">导入交割单(实盘买卖点)</span>
<button
type="button"
class="rounded p-1 text-[#9BA3AE] transition-colors hover:bg-[#26272E] hover:text-white"
title="关闭 (Esc)"
@click="showTradeImport = false"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
<p class="mt-2 text-xs leading-5 text-[#9BA3AE]">
上传券商导出的交割单(江海证券通达信版:「查询 → 交割单 → 输出」App/同花顺版:「交割单 → 导出/发送邮箱」)。
CSV / Excel / txt / HTML 均可编码与列名自动识别同日成交合并为一个标注B=买入、S=卖出、T=当日买卖都有),鼠标悬停字母可查看数量与均价。
</p>
<label
class="mt-3 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed px-4 py-6 text-center transition-colors"
:class="importing ? 'border-[#26272E] opacity-60' : 'border-[#3A3D46] hover:border-blue-500/60'"
>
<span class="text-[13px] text-[#C3C9D2]">{{ importing ? '解析导入中…' : '点击选择交割单文件' }}</span>
<span class="mt-1 text-xs text-[#7A818C]">支持 .csv / .txt / .xls / .xlsx / .html20MB 以内</span>
<input
type="file"
class="hidden"
accept=".csv,.txt,.xls,.xlsx,.htm,.html"
:disabled="importing"
@change="onTradeFile"
/>
</label>
<!-- 导入结果反馈 -->
<div v-if="importResult" class="mt-3 rounded-md border border-blue-500/30 bg-blue-500/10 p-2.5 text-xs leading-5 text-[#C3C9D2]">
<div>
新增 <span class="font-semibold text-blue-300">{{ importResult.inserted }}</span> 笔成交,覆盖 {{ importResult.stocks }} 只股票;
重复跳过 {{ importResult.skipped_dup }} 笔,其他跳过 {{ importResult.skipped_other }} 笔。
</div>
<div v-if="importResult.bad.length" class="mt-1 text-red-400">
{{ importResult.bad.join('') }}
</div>
</div>
<div v-if="importErr" class="mt-3 rounded-md border border-red-500/40 bg-red-500/10 p-2.5 text-xs text-red-400">{{ importErr }}</div>
<!-- 危险操作:清空(作用于全部股票,不随当前股是否有成交隐藏入口) -->
<div class="mt-3 flex items-center justify-between border-t border-[#1E2026] pt-3">
<span class="text-xs text-[#7A818C]">清空全部股票的成交记录K 线买卖点将全部消失</span>
<button
type="button"
class="rounded-md border border-red-500/50 px-2.5 py-1 text-[13px] text-red-400 transition-colors hover:bg-red-500/15"
@click="onClearTrades"
>清空全部成交记录</button>
</div>
</div>
</div>
</div>
</template>