848 lines
38 KiB
Vue
848 lines
38 KiB
Vue
<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 / .html,20MB 以内</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>
|