提交
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user