This commit is contained in:
2026-09-07 13:34:26 +08:00
parent ad9245abdd
commit 359f9ae2e4
23 changed files with 2260 additions and 513 deletions

View File

@@ -1,15 +1,18 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
addWatchlist, clearTrades, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
addWatchlist, clearTrades, getStockDividends, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
importTrades, removeWatchlist,
} from '@/api/client';
import type {
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField,
TradesImportResponse, UserTrade,
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, StockDividendRecord, StockFinanceRecord,
Timeframe, TooltipField, TradesImportResponse, UserTrade,
} from '@/api/types';
import { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings';
import DetailKLine from './DetailKLine.vue';
import CompanyInfoPanel from './CompanyInfoPanel.vue';
import FinancePanel from './FinancePanel.vue';
import ReferencePanel from './ReferencePanel.vue';
const props = defineProps<{
items: ScreenerItemOut[];
@@ -71,6 +74,9 @@ const showBoll = computed<boolean>(() => layout.value.showBoll === true);
const showZhixing = computed<boolean>(() => layout.value.showZhixing === true);
function toggleBoll() { settings.setChartLayout({ showBoll: !showBoll.value }); }
function toggleZhixing() { settings.setChartLayout({ showZhixing: !showZhixing.value }); }
// K线分红标记默认开与交易点同款持久化开关
const showDividends = computed<boolean>(() => layout.value.showDividends !== false);
function toggleDividends() { settings.setChartLayout({ showDividends: !showDividends.value }); }
// 知行指标图例行的板块文本(通达信 HYBLOCK+' '+DYBLOCK 的近似GNBLOCK 概念板块无数据源)
const zhixingBlocks = computed(() => {
const info = data.value?.info;
@@ -230,9 +236,30 @@ async function loadTrades(code: string) {
if (token === tradesToken) trades.value = [];
}
}
// ---------- 分红tushare dividend 懒加载K线除权标记 + 右栏统计) ----------
const dividends = ref<StockDividendRecord[]>([]);
/** FinancePanel 加载成功后上报的财务记录(近五年,报告期倒序);分红率等跨源指标用 */
const financeRecords = ref<StockFinanceRecord[]>([]);
/** 拉当前股分红(失败静默:未登录/网络异常都不影响看图)。与 loadTrades 同款请求序号防乱序。 */
let dividendsToken = 0;
async function loadDividends(code: string) {
const token = ++dividendsToken;
try {
const res = await getStockDividends(code);
if (token === dividendsToken) dividends.value = res.records;
} catch {
if (token === dividendsToken) dividends.value = [];
}
}
watch(active, (code) => {
trades.value = []; // 同步先清:新图挂载时(成交未返回)不能带着旧股标记
dividends.value = [];
financeRecords.value = [];
loadTrades(code);
loadDividends(code);
}, { immediate: true });
const fmtQty = (q: number) =>
@@ -275,6 +302,79 @@ const tradeMarkers = computed(() => {
}).sort((a, b) => a.ts - b.ts);
});
/** YYYYMMDD → YYYY-MM-DD分红各日期字段的展示格式 */
const fmtYmd8 = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
const fmt4 = (v: number) => String(+v.toFixed(4));
/** 按除权除息日聚成分红标记kind 固定 'D' 贴 high 上方):只取已实施行;同日多笔
* (如年度+中期分红同日除权)合并金额。明细收进 rows悬停字母 D 时显示。 */
const dividendMarkers = computed(() => {
if (!showDividends.value) return [];
const byEx = new Map<string, {
ts: number; cash: number; cashTax: number; bo: number; co: number;
years: Set<string>; record: string | null; pay: string | null;
}>();
for (const d of dividends.value) {
if (d.div_proc !== '实施' || !d.ex_date || d.ex_date.length !== 8) continue;
let v = byEx.get(d.ex_date);
if (!v) byEx.set(d.ex_date, v = {
ts: new Date(+d.ex_date.slice(0, 4), +d.ex_date.slice(4, 6) - 1, +d.ex_date.slice(6, 8)).getTime(),
cash: 0, cashTax: 0, bo: 0, co: 0, years: new Set(), record: null, pay: null,
});
v.cash += d.cash_div ?? 0;
v.cashTax += d.cash_div_tax ?? 0;
v.bo += d.stk_bo_rate ?? 0;
v.co += d.stk_co_rate ?? 0;
if (d.end_date) v.years.add(d.end_date.slice(0, 4));
v.record = v.record ?? (d.record_date ? fmtYmd8(d.record_date) : null);
v.pay = v.pay ?? (d.pay_date ? fmtYmd8(d.pay_date) : null);
}
return [...byEx.entries()].map(([ex, v]) => {
const rows: { label: string; text: string; tone: 'buy' | 'sell' | '' }[] = [];
if (v.cash > 0) rows.push({ label: '每股派息(税后)', text: `${fmt4(v.cash)}`, tone: '' });
if (v.cashTax > 0 && Math.abs(v.cashTax - v.cash) > 1e-9) rows.push({ label: '每股派息(税前)', text: `${fmt4(v.cashTax)}`, tone: '' });
if (v.bo > 0 || v.co > 0) {
rows.push({ label: '每股送转', text: `10送${fmt4(v.bo * 10)}${fmt4(v.co * 10)}`, tone: '' });
}
if (v.years.size) rows.push({ label: '分红年度', text: [...v.years].sort().join(' / '), tone: '' });
if (v.record) rows.push({ label: '股权登记日', text: v.record, tone: '' });
if (v.pay) rows.push({ label: '派息日', text: v.pay, tone: '' });
return { key: fmtYmd8(ex), ts: v.ts, rows };
}).sort((a, b) => a.ts - b.ts);
});
/** 右栏分红统计:股息率(TTM) / 分红率(最近有分红的年报年度) / 近5年次数与累计。
* 分红率 = 该年度每股税前分红合计 ÷ 该年年报基本每股收益finance 数据由 FinancePanel 上报)。 */
const divStats = computed(() => {
const impl = dividends.value.filter((d) => d.div_proc === '实施');
if (impl.length === 0) return null;
const now = Date.now();
const ttmCutoff = now - 365 * 86400000;
const fiveCutoff = now - 5 * 365 * 86400000;
let ttmCash = 0, cnt5y = 0, sum5y = 0;
for (const d of impl) {
if (!d.ex_date || d.ex_date.length !== 8) continue;
const ts = new Date(+d.ex_date.slice(0, 4), +d.ex_date.slice(4, 6) - 1, +d.ex_date.slice(6, 8)).getTime();
if (ts > now) continue; // 已公告未除权的不计
if (ts >= ttmCutoff) ttmCash += d.cash_div ?? 0;
if (ts >= fiveCutoff) { cnt5y += 1; sum5y += d.cash_div ?? 0; }
}
const price = data.value?.info?.close;
const yieldTtm = ttmCash > 0 && price ? (ttmCash / price) * 100 : null;
// 分红率:从最新年报年度往前找第一个有分红实施的年度
let payout: number | null = null;
const annuals = financeRecords.value.filter((r) => r.end_date.endsWith('1231'));
for (const a of annuals) {
const y = a.end_date.slice(0, 4);
const divSum = impl.filter((d) => (d.end_date ?? '').startsWith(y)).reduce((s, d) => s + (d.cash_div_tax ?? 0), 0);
if (divSum > 0) {
if (a.eps && a.eps > 0) payout = (divSum / a.eps) * 100;
break;
}
}
return { yieldTtm, payout, cnt5y, sum5y };
});
async function onTradeFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
@@ -436,27 +536,22 @@ onBeforeUnmount(() => {
document.body.style.overflow = '';
});
// ---------- 右侧信息栏增强52周高低 / 年初至今(从日线序列算,无数据留空) ----------
// ---------- 右侧信息栏增强52周高低从日线序列算无数据留空 ----------
const stats = computed(() => {
// 日期跳转后窗口是历史段52周/年初至今口径失真,直接不显示
// 日期跳转后窗口是历史段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 };
if (!bars || bars.length === 0) return { high52: null, low52: 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,
};
});
@@ -606,6 +701,14 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
:title="trades.length ? `本股实盘成交 ${trades.length} 笔(交割单导入)` : '本股暂无实盘成交记录,点击导入交割单'"
@click="trades.length ? (showTrades = !showTrades) : openTradeImport()"
>交易点{{ trades.length ? ` ${trades.length}` : '' }}</button>
<!-- 分红除权标记tushare dividend -->
<button
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showDividends && dividendMarkers.length ? 'border-[#9C6ADE] bg-[#9C6ADE] text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
:title="dividendMarkers.length ? `分红除权 ${dividendMarkers.length} 次(悬停 D 看方案)` : '本股无已实施分红记录'"
@click="toggleDividends"
>分红{{ dividendMarkers.length ? ` ${dividendMarkers.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]"
@@ -732,14 +835,15 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
:tooltip-fields="tooltipFields"
:center-ts="jumpTs"
:trade-markers="tradeMarkers"
:dividend-markers="dividendMarkers"
@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">
<!-- 个股信息通达信式w-96=384px参考数据/财务的多列表格在 w-72 下换行过碎 -->
<aside v-if="data" class="w-96 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]">
@@ -756,8 +860,6 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<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) + ' 手'],
@@ -769,7 +871,6 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
['流通市值', 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">
@@ -778,22 +879,31 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</template>
</div>
<!-- 股本/分红/股东(数据未接入前留空占位 -->
<div class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<div class="mb-2 text-[13px] text-[#9BA3AE]">股本 / 分红 / 股东</div>
<!-- 分红统计tushare dividend无已实施分红整节隐藏 -->
<div v-if="divStats" 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 [
['股东户数', '—'],
['户均持股', '—'],
['分红', '—'],
['股息率', '—'],
['股息率TTM', divStats.yieldTtm == null ? '—' : divStats.yieldTtm.toFixed(2) + '%'],
['分红率', divStats.payout == null ? '—' : divStats.payout.toFixed(1) + '%'],
['近5年分红', `${divStats.cnt5y} 次`],
['近5年每股累计', divStats.sum5y > 0 ? fmt4(divStats.sum5y) + ' 元' : '—'],
]" :key="i">
<span class="text-[#9BA3AE]">{{ row[0] }}</span>
<span class="text-right text-[#C3C9D2]" title="数据源待接入">{{ row[1] }}</span>
<span class="text-right text-[#E8EAED]">{{ row[1] }}</span>
</template>
</div>
</div>
<!-- 财务指标fina_indicator+三大报表关键值,近五年;:key 切股重挂,加载成功上报记录供分红率计算) -->
<FinancePanel :key="data.ts_code" :ts-code="data.ts_code" @loaded="financeRecords = $event" />
<!-- 参考数据tushare 参考数据版块 11 类;默认折叠,:key 切股重挂自动清缓存) -->
<ReferencePanel :key="data.ts_code" :ts-code="data.ts_code" />
<!-- 公司简介tushare stock_company 懒加载;:key 切股重挂,折叠态/数据自动重置) -->
<CompanyInfoPanel :key="data.ts_code" :ts-code="data.ts_code" />
<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">