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

@@ -70,7 +70,7 @@ async function signOut() {
</main>
<footer v-if="!isHome" class="mx-auto max-w-[1400px] px-5 pb-8 pt-2 text-center text-[13px] text-[#9BA3AE]">
数据来源 Tushare · 仅供研究学习不构成投资建议
数据来源 Tushare / 东方财富 · 仅供研究学习不构成投资建议
</footer>
</div>
</template>

View File

@@ -6,6 +6,11 @@ import type {
CurrentUser,
EventBacktestRequest,
EventBacktestResponse,
EtfListResponse,
EtfSyncStatus,
GlobalIndexList,
IndexDetail,
IndexWeights,
LoginRequest,
LoginResponse,
MarketOverview,
@@ -16,7 +21,11 @@ import type {
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
StockCompanyInfo,
StockDividendOut,
StockFacets,
StockFinanceOut,
StockReferenceOut,
StockListResponse,
SyncRequest,
SyncResponse,
@@ -191,6 +200,34 @@ export async function getStockPreview(
return (await res.json()) as PreviewResponse;
}
/** 个股公司简介tushare stock_company 按需懒加载404 = ETF/无此股,调用方据此隐藏面板) */
export async function getStockCompany(tsCode: string): Promise<StockCompanyInfo> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/company`);
if (!res.ok) throw new ApiError(await readError(res, `获取公司简介失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockCompanyInfo;
}
/** 个股财务数据fina_indicator+三大报表关键值近五年404 = ETF/无数据,调用方据此隐藏面板) */
export async function getStockFinance(tsCode: string): Promise<StockFinanceOut> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/finance`);
if (!res.ok) throw new ApiError(await readError(res, `获取财务数据失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockFinanceOut;
}
/** 个股分红送股tushare dividend 全历史;空 records = 确认无分红) */
export async function getStockDividends(tsCode: string): Promise<StockDividendOut> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/dividends`);
if (!res.ok) throw new ApiError(await readError(res, `获取分红数据失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockDividendOut;
}
/** 个股参考数据tushare 参考数据版块按 kind 懒加载;空 records = 确认无数据) */
export async function getStockReference(tsCode: string, kind: string): Promise<StockReferenceOut> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/reference/${encodeURIComponent(kind)}`);
if (!res.ok) throw new ApiError(await readError(res, `获取参考数据失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockReferenceOut;
}
// ---------- 大盘总览(主页) ----------
export async function getMarketOverview(): Promise<MarketOverview> {
const res = await apiFetch('/api/market/overview');
@@ -205,6 +242,37 @@ export async function getIndexCandles(timeframe: Timeframe): Promise<Candle[]> {
return (await res.json()) as Candle[];
}
// ---------- 指数专题(国际指数卡片 + 指数详情) ----------
export async function getGlobalIndexes(): Promise<GlobalIndexList> {
const res = await apiFetch('/api/market/global-indexes');
if (!res.ok) throw new ApiError(await readError(res, '获取国际指数失败'), res.status);
return (await res.json()) as GlobalIndexList;
}
export async function getIndexDetail(code: string): Promise<IndexDetail> {
const res = await apiFetch(`/api/market/indexes/${encodeURIComponent(code)}`);
if (!res.ok) throw new ApiError(await readError(res, '获取指数详情失败'), res.status);
return (await res.json()) as IndexDetail;
}
/** 白名单指数全量 K 线(国内 index_daily / 国际 index_global日线基底聚合 */
export async function getAnyIndexCandles(code: string, timeframe: Timeframe): Promise<Candle[]> {
const res = await apiFetch(
`/api/market/indexes/${encodeURIComponent(code)}/candles?timeframe=${encodeURIComponent(timeframe)}`,
);
if (!res.ok) throw new ApiError(await readError(res, `获取指数K线失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as Candle[];
}
/** 指数成分股权重(仅国内指数;国际指数后端 404 */
export async function getIndexWeights(code: string, limit = 50): Promise<IndexWeights> {
const res = await apiFetch(
`/api/market/indexes/${encodeURIComponent(code)}/weights?limit=${limit}`,
);
if (!res.ok) throw new ApiError(await readError(res, '获取成分权重失败'), res.status);
return (await res.json()) as IndexWeights;
}
export async function getStocks(params: {
search?: string;
market?: string;
@@ -237,6 +305,41 @@ export async function getStockFacets(): Promise<StockFacets> {
return (await res.json()) as StockFacets;
}
// ---------- ETF 列表(全市场浏览) ----------
export async function getEtfs(params: {
search?: string;
exchange?: string;
watched_only?: boolean;
sort?: string;
order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}): Promise<EtfListResponse> {
const q = new URLSearchParams();
if (params.search) q.set('search', params.search);
if (params.exchange) q.set('exchange', params.exchange);
if (params.watched_only) q.set('watched_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
q.set('limit', String(params.limit ?? 100));
q.set('offset', String(params.offset ?? 0));
const res = await apiFetch(`/api/etfs?${q.toString()}`);
if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 列表失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EtfListResponse;
}
export async function startEtfSync(full = false): Promise<EtfSyncStatus> {
const res = await apiFetch('/api/etf/sync', { method: 'POST', body: JSON.stringify({ full }) });
if (!res.ok) throw new ApiError(await readError(res, `启动 ETF 同步失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EtfSyncStatus;
}
export async function getEtfSyncStatus(): Promise<EtfSyncStatus> {
const res = await apiFetch('/api/etf/sync/status');
if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 同步状态失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EtfSyncStatus;
}
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
export async function getPreferences(): Promise<Record<string, unknown>> {
const res = await apiFetch('/api/preferences');

View File

@@ -209,6 +209,105 @@ export interface PreviewResponse {
has_more?: boolean; // 返回窗口之前是否还有更早历史(前端向左滚动翻页用)
}
// ---------- 公司简介(镜像 app/schemas.py StockCompanyOut ----------
export interface StockCompanyInfo {
ts_code: string;
com_name?: string | null; // 公司全称
com_id?: string | null; // 统一社会信用代码
chairman?: string | null; // 法人代表
manager?: string | null; // 总经理
secretary?: string | null; // 董秘
reg_capital?: number | null; // 万元
setup_date?: string | null; // YYYYMMDD
province?: string | null;
city?: string | null;
introduction?: string | null; // 公司介绍
website?: string | null;
email?: string | null;
office?: string | null;
employees?: number | null;
main_business?: string | null; // 主要业务及产品
business_scope?: string | null; // 经营范围
}
// ---------- 财务数据(镜像 app/schemas.py StockFinanceOut近五年按报告期倒序 ----------
export interface StockFinanceRecord {
end_date: string; // 报告期 YYYYMMDD
ann_date?: string | null; // 公告日 YYYYMMDD
eps?: number | null; // 基本每股收益(元)
bps?: number | null; // 每股净资产(元)
ocfps?: number | null; // 每股经营现金流净额(元)
roe?: number | null; // 净资产收益率 %
roe_dt?: number | null; // 扣非净资产收益率 %
grossprofit_margin?: number | null; // 销售毛利率 %
netprofit_margin?: number | null; // 销售净利率 %
debt_to_assets?: number | null; // 资产负债率 %
or_yoy?: number | null; // 营业收入同比 %
netprofit_yoy?: number | null; // 归母净利润同比 %
dt_netprofit_yoy?: number | null; // 扣非净利润同比 %
profit_dedt?: number | null; // 扣非净利润(元)
rd_exp?: number | null; // 研发投入(元)
total_revenue?: number | null; // 营业总收入(元)
operate_profit?: number | null; // 营业利润(元)
n_income_attr_p?: number | null; // 归母净利润(元)
total_assets?: number | null; // 总资产(元)
total_hldr_eqy?: number | null; // 归母股东权益(元)
n_cashflow_act?: number | null; // 经营现金流净额(元)
}
export interface StockFinanceOut {
ts_code: string;
records: StockFinanceRecord[]; // records[0] = 最新报告期
}
// ---------- 分红送股(镜像 app/schemas.py StockDividendOut全历史按分红年度倒序 ----------
export interface StockDividendRecord {
end_date?: string | null; // 分红年度 YYYYMMDD
ann_date?: string | null; // 预案公告日
div_proc?: string | null; // 实施进度(预案/实施)
stk_div?: number | null; // 每股送转
stk_bo_rate?: number | null; // 每股送股比例
stk_co_rate?: number | null; // 每股转增比例
cash_div?: number | null; // 每股分红(税后,元)
cash_div_tax?: number | null; // 每股分红(税前,元)
base_share?: number | null; // 基准股本(万股)
record_date?: string | null; // 股权登记日
ex_date?: string | null; // 除权除息日K线标记锚点
pay_date?: string | null; // 派息日
div_listdate?: string | null; // 红股上市日
imp_ann_date?: string | null; // 实施公告日
}
export interface StockDividendOut {
ts_code: string;
records: StockDividendRecord[]; // 空数组 = 确认无分红
}
// ---------- 参考数据(镜像 app/schemas.py StockReferenceOut字段随 kind 而异) ----------
export type StockReferenceRecord = Record<string, string | number | null>;
export interface StockReferenceOut {
ts_code: string;
kind: string; // 白名单 key见 REFERENCE_KINDS
records: StockReferenceRecord[]; // 空数组 = 确认无数据
}
/** 参考数据分类目录(与后端 reference.REFERENCE_KINDS 白名单一一对应) */
export const REFERENCE_KINDS: { key: string; label: string }[] = [
{ key: 'top10_holders', label: '十大股东' },
{ key: 'top10_floatholders', label: '十大流通' },
{ key: 'pledge_stat', label: '质押统计' },
{ key: 'pledge_detail', label: '质押明细' },
{ key: 'repurchase', label: '回购' },
{ key: 'share_float', label: '解禁' },
{ key: 'block_trade', label: '大宗' },
{ key: 'moneyflow', label: '资金流向' },
{ key: 'holdernumber', label: '股东人数' },
{ key: 'holdertrade', label: '增减持' },
{ key: 'shock', label: '异动' },
{ key: 'high_shock', label: '严重异动' },
];
// ---------- 股票列表(全市场浏览) ----------
export interface StockListItem {
ts_code: string;
@@ -243,6 +342,41 @@ export interface StockFacets {
areas: FacetItem[];
}
// ---------- ETF 列表(全市场浏览) ----------
export interface EtfListItem {
ts_code: string;
symbol: string;
name: string;
exchange: string; // SH/SZ
close?: number | null;
prev_close?: number | null;
pct_chg?: number | null;
amount?: number | null; // 最新成交额(亿元)
last_ts?: string | null;
turnover_rate?: number | null; // 换手率 %(东财快照)
total_mv?: number | null; // 总市值(亿元)
circ_mv?: number | null; // 流通市值(亿元)
list_date?: string | null; // 上市日 YYYYMMDD
watched: boolean;
}
export interface EtfListResponse {
total: number;
items: EtfListItem[];
}
export interface EtfSyncStatus {
running: boolean;
step?: string | null;
total: number;
done: number;
error?: string | null;
ready: boolean;
last_trade_date?: string | null;
last_synced_at?: string | null;
stats: { etfs?: number } & Record<string, number>;
}
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
export type Timeframe = '1d' | '1w' | '1M' | '1y';
export type AdjustMode = 'bfq' | 'qfq' | 'hfq';
@@ -263,6 +397,7 @@ export interface ChartLayoutPrefs {
indexTimeframe?: Timeframe; // 首页上证指数 K 线周期
showBoll?: boolean; // 主图叠加 BOLL
showZhixing?: boolean; // 主图叠加知行指标(趋势线+多空线)
showDividends?: boolean; // K线分红标记默认开
}
export interface ScreenerQueryItem {
@@ -396,3 +531,83 @@ export interface MarketOverview {
amount_history: AmountBar[]; // 近 N 交易日两市成交额(旧 -> 新,末根可能盘中)
errors: string[];
}
// ---------- 指数专题(国际指数卡片 + 指数详情) ----------
export type GlobalRegion = 'americas' | 'europe' | 'asia';
export interface GlobalIndexQuote {
code: string; // DJI / SPX / HSI ...
name: string; // 道琼斯工业指数
region: GlobalRegion;
country: string; // 美国 / 英国 / 日本 ...
close: number | null;
change: number | null;
pct_chg: number | null;
trade_date: string | null; // ISO 日期
spark: number[]; // 近 45 交易日收盘(旧 -> 新)
spark_dates: string[];
}
export interface GlobalIndexList {
updated_at: string;
items: GlobalIndexQuote[];
errors: string[];
}
export interface IndexBasic {
ts_code: string;
name: string;
market?: string | null; // SSE / CSI / SZSE国内
publisher?: string | null; // 中证指数 / 上交所
category?: string | null; // 规模指数 / 综合指数
base_date?: string | null; // 基期
base_point?: number | null; // 基点
list_date?: string | null; // 发布日期
country?: string | null; // 国际指数:国家/地区
region?: string | null; // 国际指数americas/europe/asia
}
export interface IndexValuationPoint {
trade_date: string;
pe?: number | null;
pe_ttm?: number | null;
pb?: number | null;
turnover_rate?: number | null; // 换手率 %
total_mv?: number | null; // 总市值(万元)
float_mv?: number | null; // 流通市值(万元)
}
export interface IndexQuoteBrief {
close: number | null;
change: number | null;
pct_chg: number | null;
open: number | null;
high: number | null;
low: number | null;
pre_close: number | null;
trade_date: string | null;
spark: number[];
spark_dates: string[];
}
export interface IndexDetail {
code: string;
name: string;
region: 'cn' | GlobalRegion;
quote: IndexQuoteBrief;
basic: IndexBasic | null;
valuation: IndexValuationPoint | null; // 接口不覆盖该指数时为 null
valuation_history: IndexValuationPoint[]; // 近 N 日PE 走势小图)
}
export interface IndexWeightItem {
con_code: string; // 000001.SZ
name: string | null; // 平安银行(本地回填,缺则 null
weight: number; // 权重 %
}
export interface IndexWeights {
trade_date: string;
total: number;
items: IndexWeightItem[];
}

View File

@@ -29,15 +29,16 @@ for (const t of [
registerOverlay(t);
}
// ---------- 实盘买卖点标记(交割单导入 ----------
// ---------- 实盘买卖点 / 分红事件标记(同一模板,两组 groupId 独立管理 ----------
// v10 无 v9 的 simpleMarker须注册自定义模板字母种类/明细经 extendData 传入。
// A股惯例通达信/同花顺同款B 买贴 low 下方、S 卖贴 high 上方、T 当日买+卖做T贴 high 上方;
// 图上只显示单个字母徽章(色底白字,用户指定固定配色,不随涨跌设置),成交明细(数量/均价/费用)
// 悬停字母时由组件浮层展示——onMouseEnter/onMouseLeave 是创建项级回调OverlayCreate 未 Omit
// 事件键),闭包进组件状态即可(模板是模块级的,拿不到组件实例)。
// D 分红除权日贴 high 上方(紫)。图上只显示单个字母徽章(色底白字,用户指定固定配色,
// 不随涨跌设置),明细(成交数量/均价/费用 或 分红方案)悬停字母时由组件浮层展示——
// onMouseEnter/onMouseLeave 是创建项级回调OverlayCreate 未 Omit 事件键),
// 闭包进组件状态即可(模板是模块级的,拿不到组件实例)。
interface TradeRow { label: string; text: string; tone: 'buy' | 'sell' | '' }
interface TradeMarkExt { kind: 'B' | 'S' | 'T'; rows: TradeRow[] }
const TRADE_COLORS: Record<'B' | 'S' | 'T', string> = { B: '#FE354B', S: '#3B7BBF', T: '#F9A504' };
interface TradeMarkExt { kind: 'B' | 'S' | 'T' | 'D'; rows: TradeRow[] }
const TRADE_COLORS: Record<'B' | 'S' | 'T' | 'D', string> = { B: '#FE354B', S: '#3B7BBF', T: '#F9A504', D: '#9C6ADE' };
const tradeMarkerTemplate: OverlayTemplate<TradeMarkExt> = {
name: 'tradeMarker',
totalStep: 2,
@@ -106,6 +107,9 @@ const props = defineProps<{
* T=当日买+卖做T贴 high 上方rows 为悬停明细(数量/均价/费用)。
* 只画落在已渲染窗口内的(更早的等左滑翻页后自动补画) */
tradeMarkers?: { key: string; ts: number; kind: 'B' | 'S' | 'T'; rows: TradeRow[] }[];
/** 分红事件标记tushare dividend按除权除息日贴 high 上方kind 固定 'D'
* rows 为悬停明细(每股分红/送转/登记日等)。与买卖点同模板不同 groupId独立开关 */
dividendMarkers?: { key: string; ts: number; rows: TradeRow[] }[];
}>();
const emit = defineEmits<{
@@ -520,8 +524,8 @@ function pickTool(key: string) {
function clearOverlays() {
chart?.removeOverlay();
activeTool.value = '';
// removeOverlay() 无参清的是全部 overlay含交易点)——交易点不是用户画线,重画回来
renderTradeMarkers();
// removeOverlay() 无参清的是全部 overlay含交易点/分红)——事件标记不是用户画线,重画回来
renderMarkers();
}
// ---------- 日期跳转居中 ----------
@@ -559,12 +563,13 @@ function centerOn(ts: number): boolean {
}
defineExpose({ centerOn });
// ---------- 实盘买卖点标记渲染 ----------
// ---------- 事件标记渲染(买卖点 + 分红,同模板不同 groupId ----------
const TRADE_GROUP = 'trades';
const DIVIDEND_GROUP = 'dividends';
/** 交易点允许吸附到「晚于最后一根K时间戳」的窗口按周期放大周/月/年K的 bar 时间戳
* 是周期首日(周一/1日/1月1日当前周期内的成交(如月中)仍应贴到最后一根上。
* 日K严格为 0行情未同步到成交日时宁可先不画(数据同步后重建图表自动补上),
/** 事件允许吸附到「晚于最后一根K时间戳」的窗口按周期放大周/月/年K的 bar 时间戳
* 是周期首日(周一/1日/1月1日当前周期内的事件(如月中成交/除权)仍应贴到最后一根上。
* 日K严格为 0行情未同步到事件日时宁可先不画(数据同步后重建图表自动补上),
* 也不能把周一的成交错标到周五的K线上。 */
const TRADE_AHEAD_MS: Record<string, number> = {
'1d': 0,
@@ -573,27 +578,27 @@ const TRADE_AHEAD_MS: Record<string, number> = {
'1y': 366 * 86400000,
};
/** 按 groupId 整组重建买卖点标记(先删后建,幂等)。交易日期按时间戳吸附到所在 bar
* B 贴 bar.low 下方、S/T 贴 bar.high 上方坐标随复权切换自动重算value 取自当前数据)。
* 早于已渲染窗口的交易先跳过——左滑翻页 serveOlder 吐出新数据后会重跑本函数补画。
* 列表为空(关闭显示/清空成交/切到无成交股票)也必须清组,否则旧标记残留。 */
function renderTradeMarkers() {
/** 按 groupId 整组重建事件标记(先删后建,幂等)。事件日期按时间戳吸附到所在 bar
* B 贴 bar.low 下方、S/T/D 贴 bar.high 上方坐标随复权切换自动重算value 取自当前数据)。
* 早于已渲染窗口的事件先跳过——左滑翻页 serveOlder 吐出新数据后会重跑本函数补画。
* 列表为空(关闭显示/清空成交/切到无分红股票)也必须清组,否则旧标记残留。 */
function renderMarkerGroup(groupId: string, markers: { key: string; ts: number; kind: 'B' | 'S' | 'T' | 'D'; rows: TradeRow[] }[] | undefined) {
if (!chart) return;
tradeTip.value = null; // 组重建期间字母已换位,旧明细浮层不能留在原地
chart.removeOverlay({ groupId: TRADE_GROUP });
if (!props.tradeMarkers?.length) return;
chart.removeOverlay({ groupId });
if (!markers?.length) return;
const list = chart.getDataList();
if (list.length === 0) return;
const lastTs = list[list.length - 1].timestamp;
const aheadMs = TRADE_AHEAD_MS[props.timeframe] ?? 0;
const creates: OverlayCreate<unknown>[] = [];
for (const m of props.tradeMarkers) {
for (const m of markers) {
const i = idxAtOrBefore(list, m.ts);
if (i < 0 || m.ts > lastTs + aheadMs) continue; // 未翻到 / 行情尚未覆盖该周期
const bar = list[i];
creates.push({
id: `trade-${m.key}`,
groupId: TRADE_GROUP,
id: `${groupId}-${m.key}`,
groupId,
name: 'tradeMarker',
points: [{ timestamp: bar.timestamp, value: m.kind === 'B' ? bar.low : bar.high }],
extendData: { kind: m.kind, rows: m.rows },
@@ -615,8 +620,14 @@ function renderTradeMarkers() {
if (creates.length) chart.createOverlay(creates);
}
// ---------- 交易点悬停明细(悬停 B/S/T 字母才显示,离开/滚动即隐) ----------
interface TradeTip { x: number; y: number; kind: 'B' | 'S' | 'T'; date: string; rows: TradeRow[] }
/** 全部事件标记重画(买卖点 + 分红触发点build 尾部 / serveOlder 左扩 / props 变化 / 清画线 */
function renderMarkers() {
renderMarkerGroup(TRADE_GROUP, props.tradeMarkers);
renderMarkerGroup(DIVIDEND_GROUP, props.dividendMarkers?.map((m) => ({ ...m, kind: 'D' as const })));
}
// ---------- 事件标记悬停明细(悬停 B/S/T/D 字母才显示,离开/滚动即隐) ----------
interface TradeTip { x: number; y: number; kind: 'B' | 'S' | 'T' | 'D'; date: string; rows: TradeRow[] }
const tradeTip = ref<TradeTip | null>(null);
/** 贴鼠标定位并在右缘/下缘自动翻转(与十字线浮层 placeHover 同款策略,宽度略大) */
@@ -669,7 +680,7 @@ function build() {
const start = allData.length - served - take;
served += take;
callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
renderTradeMarkers(); // 窗口左扩后补画此前跳过的更早交易点
renderMarkers(); // 窗口左扩后补画此前跳过的更早事件标记(交易/分红)
};
const answerEmpty = () => callback([], { forward: false, backward: false });
if (type === 'init') {
@@ -764,8 +775,8 @@ function build() {
// 居中失败(锚点早于上市首日/晚于最后一根)必须上报:否则锚点 chip 与统计口径
// 仍停留在「已定位」状态,视口却悄悄回到最新行情。
if (props.centerTs != null && !centerOn(props.centerTs)) emit('centerMiss', props.centerTs);
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的交易点
renderTradeMarkers();
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的事件标记
renderMarkers();
}
function teardown() {
@@ -780,8 +791,8 @@ function teardown() {
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
// 买卖点数据变化(导入/清空/开关显示):只重画标记,不重建图表(保留滚动位置与用户画线)
watch(() => props.tradeMarkers, renderTradeMarkers, { deep: true });
// 事件标记数据变化(导入/清空/开关显示/分红数据到达):只重画标记,不重建图表(保留滚动位置与用户画线)
watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers, { deep: true });
// 涨跌配色切换:重建图表以应用新颜色
watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)

View File

@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { getMarketOverview } from '@/api/client';
import type { IndexQuote, MarketOverview as Overview } from '@/api/types';
import type { MarketOverview as Overview } from '@/api/types';
import AmountHistoryChart from '@/components/AmountHistoryChart.vue';
import IndexKLine from '@/components/IndexKLine.vue';
import Sparkline from '@/components/Sparkline.vue';
// 主页大盘总览:A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。
// 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询
// 主页大盘总览:美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。
// 港股与国际指数在 /indexes 国际指数页。数据为 EOD 口径,进页面拉一次 + 手动刷新即可。
const overview = ref<Overview | null>(null);
const loading = ref(false);
@@ -31,7 +32,7 @@ const groups = computed(() => {
const idx = overview.value?.indexes ?? [];
return [
{ label: '沪深主要指数', items: idx.filter((i) => i.region === 'cn') },
{ label: '美市场', items: idx.filter((i) => i.region !== 'cn') },
{ label: '美市场', items: idx.filter((i) => i.region === 'us') },
].filter((g) => g.items.length > 0);
});
@@ -72,50 +73,6 @@ function fmtYi(v: number | null | undefined): string {
if (Math.abs(v) >= 10000) return `${(v / 10000).toFixed(2)} 万亿`;
return `${v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })} 亿`;
}
// ---------- 迷你走势归一化折线SVG 拉伸 + 描边不缩放;端点/悬停点用 HTML 圆点保证正圆) ----------
const SPARK_PAD = 0.08; // 上下留白,避免贴边
function sparkGeom(it: IndexQuote) {
const vals = it.spark;
const n = vals.length;
if (n < 2) return null;
const lo = Math.min(...vals);
const hi = Math.max(...vals);
const span = hi - lo || Math.abs(hi) || 1;
const pts = vals.map((v, i) => ({
x: i / (n - 1),
y: 1 - ((v - lo) / span) * (1 - 2 * SPARK_PAD) - SPARK_PAD,
}));
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(4)},${p.y.toFixed(4)}`).join(' ');
const area = `${line} L1,1 L0,1 Z`;
return { pts, line, area, last: pts[n - 1] };
}
const sparkColor = (it: IndexQuote): string => {
if (it.pct_chg == null || it.pct_chg === 0) return '#A8AFB8';
return it.pct_chg > 0 ? 'var(--color-up)' : 'var(--color-down)';
};
// 悬停per-code 记录索引 + 相对坐标tooltip 跟随
const hover = reactive<Record<string, { i: number; x: number; y: number }>>({});
function onSparkMove(it: IndexQuote, e: MouseEvent) {
const g = sparkGeom(it);
if (!g) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const t = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const i = Math.round(t * (it.spark.length - 1));
hover[it.code] = { i, x: g.pts[i].x, y: g.pts[i].y };
}
function hoverText(it: IndexQuote): string {
const h = hover[it.code];
if (!h) return '';
const d = it.spark_dates[h.i] ?? '';
const iso = d ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '';
return `${iso} ${fmtClose(it.spark[h.i])}`;
}
</script>
<template>
@@ -126,6 +83,13 @@ function hoverText(it: IndexQuote): string {
</h2>
<div class="flex items-center gap-3 text-xs text-[#6B7280]">
<span v-if="updatedAt">更新于 {{ updatedAt }}</span>
<RouterLink
to="/indexes"
class="flex items-center gap-1 rounded-md border border-[#26272E] px-2 py-1 transition-colors hover:border-[#3A3D46] hover:text-[#A8AFB8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>
国际指数
</RouterLink>
<button
type="button"
class="rounded p-1 transition-colors hover:bg-[#26272E] hover:text-[#A8AFB8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
@@ -182,54 +146,12 @@ function hoverText(it: IndexQuote): string {
{{ it.pct_chg != null && it.pct_chg > 0 ? '▲' : it.pct_chg != null && it.pct_chg < 0 ? '▼' : '' }}{{ fmtPct(it.pct_chg) }}
</span>
</div>
<!-- 迷你走势默认高度 36pxhover 出十字点与数值 -->
<div
class="relative mt-2 h-9"
@mousemove="onSparkMove(it, $event)"
@mouseleave="delete hover[it.code]"
>
<svg
v-if="sparkGeom(it)"
class="h-full w-full"
viewBox="0 0 1 1"
preserveAspectRatio="none"
aria-hidden="true"
>
<path :d="sparkGeom(it)!.area" :fill="sparkColor(it)" fill-opacity="0.1" />
<path
:d="sparkGeom(it)!.line"
fill="none"
:stroke="sparkColor(it)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<!-- 端点 2px 表面环与悬停点HTML 圆点避免非等比 viewBox 把圆拉成椭圆 -->
<span
v-if="sparkGeom(it)"
class="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#101014]"
:style="{ left: `${sparkGeom(it)!.last.x * 100}%`, top: `${sparkGeom(it)!.last.y * 100}%`, backgroundColor: sparkColor(it) }"
/>
<template v-if="hover[it.code]">
<span
class="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#101014]"
:style="{ left: `${hover[it.code].x * 100}%`, top: `${hover[it.code].y * 100}%`, backgroundColor: sparkColor(it) }"
/>
<span
class="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded border border-[#3A3D46] bg-[#1A1B21] px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-[#E5E7EB]"
:style="{ left: `${Math.min(82, Math.max(18, hover[it.code].x * 100))}%` }"
>{{ hoverText(it) }}</span>
</template>
</div>
<!-- 迷你走势hover 出十字点与数值 -->
<Sparkline class="mt-2" :values="it.spark" :dates="it.spark_dates" :pct="it.pct_chg" />
</div>
</div>
</div>
<!-- 上证指数 K 线///周期随用户偏好持久化 -->
<IndexKLine class="mt-4" />
<!-- 两市统计daily_info SH_MARKET(主板A+科创+B) + SZ_MARKET 汇总 -->
<div
v-if="overview.stats"

View File

@@ -2,14 +2,20 @@
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useMarketSyncStore } from '@/stores/marketSync';
import { useEtfSyncStore } from '@/stores/etfSync';
const store = useMarketSyncStore();
const etfStore = useEtfSyncStore();
onMounted(async () => {
await store.fetchStatus();
await Promise.all([store.fetchStatus(), etfStore.fetchStatus()]);
store.pollIfRunning();
etfStore.pollIfRunning();
});
onBeforeUnmount(() => {
store.stopPolling();
etfStore.stopPolling();
});
onBeforeUnmount(() => store.stopPolling());
/** "2026-09-01T00:00:00" / "2026-09-01" -> "2026年09月01日";无数据显示 — */
function fmtDate(s?: string | null): string {
@@ -21,20 +27,35 @@ function fmtDate(s?: string | null): string {
}
const latestDate = computed(() => store.syncStatus?.last_trade_date ?? null);
const running = computed(() => !!store.syncStatus?.running);
const etfLatestDate = computed(() => etfStore.syncStatus?.last_trade_date ?? null);
const marketRunning = computed(() => !!store.syncStatus?.running);
const etfRunning = computed(() => !!etfStore.syncStatus?.running);
// 任一在同步即视为「同步中」(隐藏按钮,避免同步期间重复触发)
const running = computed(() => marketRunning.value || etfRunning.value);
const syncing = computed(() => !!store.syncStatus?.total_days);
// 后端任务内错误(如 daily_basic 权限受限)与请求级错误都展示
const errText = computed(() => store.error || store.syncStatus?.error || null);
const etfErrText = computed(() => etfStore.error || etfStore.syncStatus?.error || null);
const progressPct = computed(() => {
const s = store.syncStatus;
if (!s?.total_days) return 0;
return Math.min(100, ((s.done_days ?? 0) / s.total_days) * 100);
});
const etfProgressPct = computed(() => {
const s = etfStore.syncStatus;
if (!s?.total) return 0;
return Math.min(100, ((s.done ?? 0) / s.total) * 100);
});
/** 一个按钮同时同步 A 股与 ETF两端点各自幂等已在这边跑着的不会被重复启动 */
async function startAll() {
await Promise.all([store.startSync(), etfStore.startSync()]);
}
</script>
<template>
<div class="mb-12 flex flex-wrap items-center gap-x-5 gap-y-3 rounded-xl border border-[#26272E] bg-[#101014] px-5 py-4">
<!-- 最新更新日期 -->
<!-- 最新更新日期A股 + ETF -->
<div class="flex items-center gap-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/15 text-blue-300">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -48,29 +69,53 @@ const progressPct = computed(() => {
</div>
</div>
<span class="hidden flex-1 sm:block"></span>
<!-- 同步中进度条 -->
<div v-if="running" class="flex min-w-[220px] flex-1 items-center gap-2">
<svg class="h-4 w-4 shrink-0 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>
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
{{ store.syncStatus?.step || '同步中…' }}<template v-if="syncing">{{ store.syncStatus?.done_days }}/{{ store.syncStatus?.total_days }}</template>
</span>
<span v-if="syncing" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: progressPct + '%' }" />
<div class="flex items-center gap-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-500/15 text-violet-300">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
<path d="M22 12A10 10 0 0 0 12 2v10h10z" />
</svg>
</span>
<div>
<div class="text-xs text-[#6B7280]">ETF日线数据更新至</div>
<div class="text-base font-semibold tabular-nums text-[#E5E7EB]">{{ fmtDate(etfLatestDate) }}</div>
</div>
</div>
<!-- 空闲手动同步按钮 -->
<button v-else type="button" class="btn-primary shrink-0" @click="store.startSync()">
<span class="hidden flex-1 sm:block"></span>
<!-- 同步中A股 + ETF 各自进度 -->
<div v-if="running" class="flex min-w-[220px] flex-1 flex-col gap-1.5">
<div v-if="marketRunning" class="flex items-center gap-2">
<svg class="h-4 w-4 shrink-0 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>
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
{{ store.syncStatus?.step || '同步中…' }}<template v-if="syncing">{{ store.syncStatus?.done_days }}/{{ store.syncStatus?.total_days }}</template>
</span>
<span v-if="syncing" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: progressPct + '%' }" />
</span>
</div>
<div v-if="etfRunning" class="flex items-center gap-2">
<svg class="h-3.5 w-3.5 shrink-0 animate-spin text-violet-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>
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
ETF{{ etfStore.syncStatus?.step || '同步中…' }}<template v-if="etfStore.syncStatus?.total">{{ etfStore.syncStatus?.done }}/{{ etfStore.syncStatus?.total }}</template>
</span>
<span v-if="etfStore.syncStatus?.total" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
<span class="block h-full rounded-full bg-violet-500 transition-all" :style="{ width: etfProgressPct + '%' }" />
</span>
</div>
</div>
<!-- 空闲手动同步按钮一次同步 A + ETF -->
<button v-else type="button" class="btn-primary shrink-0" @click="startAll()">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
同步A股当日数据
同步A股/ETF当日数据
</button>
<!-- 错误提示 -->
<div v-if="errText" class="w-full text-sm text-amber-400">
<div v-if="errText || etfErrText" class="w-full text-sm text-amber-400">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ errText }}
<template v-if="errText">A股{{ errText }}<template v-if="etfErrText"></template></template><template v-if="etfErrText">ETF{{ etfErrText }}</template>
</div>
</div>
</template>
</template>

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">

View File

@@ -9,6 +9,9 @@ const router = createRouter({
{ path: '/', name: 'home', component: HomeView },
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
{ path: '/stocks', name: 'stocks', component: () => import('@/views/StocksView.vue') },
{ path: '/etfs', name: 'etfs', component: () => import('@/views/EtfsView.vue') },
{ path: '/indexes', name: 'indexes', component: () => import('@/views/IndexesView.vue') },
{ path: '/indexes/:code', name: 'index-detail', component: () => import('@/views/IndexDetailView.vue') },
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],

View File

@@ -71,6 +71,7 @@ function loadLayout(): ChartLayoutPrefs {
tooltipFields: normTipFields(v.tooltipFields) ?? DEFAULT_TOOLTIP_FIELDS,
showBoll: v.showBoll === true,
showZhixing: v.showZhixing === true,
showDividends: v.showDividends !== false,
indexTimeframe: normTimeframe(v.indexTimeframe),
};
}
@@ -180,6 +181,7 @@ export const useSettingsStore = defineStore('settings', () => {
tooltipFields: tip,
showBoll: v.showBoll === true,
showZhixing: v.showZhixing === true,
showDividends: v.showDividends !== false,
indexTimeframe: normTimeframe(v.indexTimeframe),
};
saveLocal(LAYOUT_KEY, JSON.stringify(chartLayout.value));

View File

@@ -4,7 +4,7 @@ import { RouterLink } from 'vue-router';
import MarketOverview from '@/components/MarketOverview.vue';
import MarketSyncBar from '@/components/MarketSyncBar.vue';
// 首页:大盘行情总览 + 三大功能入口(看股 / 选股 / 回测)
// 首页:大盘行情总览 + 功能入口(看股 / ETF / 选股 / 回测)
const features = [
{
to: '/stocks',
@@ -13,6 +13,13 @@ const features = [
title: '看股',
desc: '浏览全市场 5,400+ 只股票的信息与历史 K 线数据。',
},
{
to: '/etfs',
icon: 'M21.21 15.89A10 10 0 1 1 8 2.83M22 12A10 10 0 0 0 12 2v10h10z',
accent: 'bg-violet-500/15 text-violet-300',
title: 'ETF',
desc: '浏览全市场 1,500+ 只场内 ETF 的行情、规模与 K 线。',
},
{
to: '/screener',
icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z',
@@ -31,12 +38,12 @@ const features = [
</script>
<template>
<div class="w-full max-w-5xl">
<div class="w-full max-w-6xl">
<MarketOverview />
<MarketSyncBar />
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<RouterLink
v-for="f in features"
:key="f.to"