Files
vue/src/views/stock/SecondBoardCalendar.vue
T
2026-05-12 00:35:43 +08:00

774 lines
19 KiB
Vue

<template>
<div
v-loading="loading"
class="second-calendar-page"
element-loading-text="正在加载二板日历..."
element-loading-background="rgba(245, 248, 252, 0.72)"
>
<section class="month-filter">
<el-date-picker
v-model="selectedMonth"
type="month"
value-format="YYYY-MM"
format="YYYY年MM月"
:clearable="false"
:editable="false"
:disabled-date="disableMonths"
placeholder="选择月份"
class="month-picker"
@change="handleMonthChange"
/>
</section>
<section class="calendar-stage">
<el-tooltip content="上一月" placement="right">
<button
type="button"
class="calendar-nav prev"
:disabled="loading"
aria-label="上一月"
@click="handleMonthStep(-1)"
>
<el-icon><ArrowLeftIcon /></el-icon>
</button>
</el-tooltip>
<div class="calendar-panel">
<div class="calendar-head">
<div class="month-title">{{ monthTitle }}</div>
<div class="month-range">{{ monthRangeLabel }}</div>
</div>
<div class="weekday-row">
<div v-for="weekday in weekdays" :key="weekday" class="weekday-cell">
{{ weekday }}
</div>
</div>
<div class="calendar-grid">
<article
v-for="day in monthCells"
:key="day.date"
:class="[
'day-card',
{
muted: !day.isCurrentMonth,
today: day.isToday,
closed: day.isCurrentMonth && !day.isOpen,
empty: day.isCurrentMonth && day.isOpen && !day.stat,
detailable: day.stat,
},
]"
:tabindex="day.stat ? 0 : undefined"
:role="day.stat ? 'button' : undefined"
@click="openDayDetail(day)"
@keydown.enter="openDayDetail(day)"
>
<header class="day-card-head">
<span class="day-number">{{ day.dayNumber }}</span>
<span v-if="day.isCurrentMonth && !day.isOpen" class="day-badge">
休
</span>
</header>
<div v-if="day.stat" class="day-metrics">
<div class="metric-row profit">
<strong>{{ formatRate(day.stat.next_intraday_profit_rate) }}</strong>
</div>
<div class="metric-row break">
<span>炸板</span>
<strong>{{ formatRatio(day.stat.break_next_profit_count, day.stat.break_count) }}</strong>
</div>
<div class="metric-row limit">
<span>涨停板</span>
<strong>{{ formatRatio(day.stat.limit_next_profit_count, day.stat.limit_count) }}</strong>
</div>
</div>
<div v-else-if="day.isCurrentMonth" class="day-placeholder">
{{ day.isOpen ? "暂无数据" : "非交易日" }}
</div>
</article>
</div>
</div>
<el-tooltip content="下一月" placement="left">
<button
type="button"
class="calendar-nav next"
:disabled="loading || !canGoNextMonth"
aria-label="下一月"
@click="handleMonthStep(1)"
>
<el-icon><ArrowRightIcon /></el-icon>
</button>
</el-tooltip>
</section>
<el-dialog
v-model="detailVisible"
class="second-calendar-detail-dialog"
:title="detailDialogTitle"
width="82%"
align-center
destroy-on-close
>
<div class="detail-summary">
<div class="detail-summary-item">
<span>盈利率</span>
<strong>{{ formatRate(selectedDayStat?.next_intraday_profit_rate) }}</strong>
</div>
<div class="detail-summary-item">
<span>涨停板</span>
<strong>{{
formatRatio(
selectedDayStat?.limit_next_profit_count,
selectedDayStat?.limit_count,
)
}}</strong>
</div>
<div class="detail-summary-item">
<span>炸板</span>
<strong>{{
formatRatio(
selectedDayStat?.break_next_profit_count,
selectedDayStat?.break_count,
)
}}</strong>
</div>
</div>
<el-tabs v-model="activeDetailTab" class="detail-tabs">
<el-tab-pane :label="`涨停板(${limitStocks.length})`" name="limit">
<el-table :data="limitStocks" border stripe max-height="520">
<el-table-column prop="name" label="股票简称" min-width="120" />
<el-table-column prop="ts_code" label="股票代码" min-width="120" />
<el-table-column prop="industry" label="行业" min-width="120" show-overflow-tooltip />
<el-table-column label="T日最高" width="100" align="right">
<template #default="{ row }">{{ formatPrice(row.t_high) }}</template>
</el-table-column>
<el-table-column label="T日收盘" width="100" align="right">
<template #default="{ row }">{{ formatPrice(row.t_close) }}</template>
</el-table-column>
<el-table-column label="T+1最高" width="100" align="right">
<template #default="{ row }">{{ formatPrice(row.next_high) }}</template>
</el-table-column>
<el-table-column label="T+1盈利" width="100" align="center">
<template #default="{ row }">
{{ formatProfitFlag(row.next_intraday_profitable) }}
</template>
</el-table-column>
<el-table-column label="最高收益" width="110" align="right">
<template #default="{ row }">
{{ formatSignedRate(row.next_high_profit_pct) }}
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`炸板(${breakStocks.length})`" name="break">
<el-table :data="breakStocks" border stripe max-height="520">
<el-table-column prop="name" label="股票简称" min-width="120" />
<el-table-column prop="ts_code" label="股票代码" min-width="120" />
<el-table-column prop="industry" label="行业" min-width="120" show-overflow-tooltip />
<el-table-column label="T日最高" width="100" align="right">
<template #default="{ row }">{{ formatPrice(row.t_high) }}</template>
</el-table-column>
<el-table-column label="T日收盘" width="100" align="right">
<template #default="{ row }">{{ formatPrice(row.t_close) }}</template>
</el-table-column>
<el-table-column label="T+1最高" width="100" align="right">
<template #default="{ row }">{{ formatPrice(row.next_high) }}</template>
</el-table-column>
<el-table-column label="T+1盈利" width="100" align="center">
<template #default="{ row }">
{{ formatProfitFlag(row.next_intraday_profitable) }}
</template>
</el-table-column>
<el-table-column label="最高收益" width="110" align="right">
<template #default="{ row }">
{{ formatSignedRate(row.next_high_profit_pct) }}
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import dayjs from "dayjs";
import {
ArrowLeft as ArrowLeftIcon,
ArrowRight as ArrowRightIcon,
} from "@element-plus/icons-vue";
import { fetchTradeCalendar } from "@/composables/useTradeCalendar";
import { getSecondBoardCalendar } from "@/api/stock";
defineOptions({
name: "SecondBoardCalendar",
});
const weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const selectedMonth = ref(dayjs().format("YYYY-MM"));
const loading = ref(false);
const openDatesSet = ref(new Set());
const maxTradeDate = ref("");
const calendarStats = ref([]);
const detailVisible = ref(false);
const selectedDay = ref(null);
const activeDetailTab = ref("limit");
const monthStart = computed(() =>
selectedMonth.value
? dayjs(`${selectedMonth.value}-01`)
: dayjs().startOf("month"),
);
const monthEnd = computed(() => monthStart.value.endOf("month"));
const monthTitle = computed(() => monthStart.value.format("YYYY年MM月"));
const monthRangeLabel = computed(
() => `${monthStart.value.format("YYYY-MM-DD")} 至 ${monthEnd.value.format("YYYY-MM-DD")}`,
);
const canGoNextMonth = computed(() => {
if (!maxTradeDate.value) return true;
const nextMonth = monthStart.value.add(1, "month").format("YYYY-MM");
const latestMonth = dayjs(maxTradeDate.value).format("YYYY-MM");
return nextMonth <= latestMonth;
});
const selectedDayStat = computed(() => selectedDay.value?.stat || null);
const detailDialogTitle = computed(() => {
const date = selectedDay.value?.date || selectedDayStat.value?.trade_date || "";
return date ? `${date} 二板详情` : "二板详情";
});
const detailStocks = computed(() =>
Array.isArray(selectedDayStat.value?.stocks) ? selectedDayStat.value.stocks : [],
);
const limitStocks = computed(() =>
detailStocks.value.filter((stock) => stock?.status === "limit"),
);
const breakStocks = computed(() =>
detailStocks.value.filter((stock) => stock?.status === "break"),
);
const statMap = computed(() => {
const map = new Map();
calendarStats.value.forEach((item) => {
if (item?.trade_date) map.set(item.trade_date, item);
});
return map;
});
const monthCells = computed(() => {
const firstDay = monthStart.value.startOf("month");
const offset = (firstDay.day() + 6) % 7;
const gridStart = firstDay.subtract(offset, "day");
const cellCount = Math.ceil((offset + monthEnd.value.date()) / 7) * 7;
const today = dayjs().format("YYYY-MM-DD");
return Array.from({ length: cellCount }, (_, index) => {
const current = gridStart.add(index, "day");
const date = current.format("YYYY-MM-DD");
const isCurrentMonth = current.format("YYYY-MM") === selectedMonth.value;
const isOpen = openDatesSet.value.size
? openDatesSet.value.has(date)
: current.day() !== 0 && current.day() !== 6;
return {
date,
dayNumber: current.date(),
isCurrentMonth,
isToday: date === today,
isOpen,
stat: statMap.value.get(date),
};
});
});
onMounted(async () => {
await initializePage();
});
const initializePage = async () => {
loading.value = true;
try {
const calendar = await fetchTradeCalendar();
const { open_dates: openDates = [], max_date: maxDate = "" } =
calendar || {};
openDatesSet.value = new Set(openDates);
maxTradeDate.value = maxDate;
selectedMonth.value = maxDate ? dayjs(maxDate).format("YYYY-MM") : dayjs().format("YYYY-MM");
} catch (err) {
console.error("初始化二板日历失败", err);
} finally {
await fetchMonthData(false);
loading.value = false;
}
};
const disableMonths = (time) => {
if (!maxTradeDate.value) return false;
const currentMonth = dayjs(time).format("YYYY-MM");
const latestMonth = dayjs(maxTradeDate.value).format("YYYY-MM");
return currentMonth > latestMonth;
};
const handleMonthChange = async () => {
await fetchMonthData();
};
const handleMonthStep = async (step) => {
if (step > 0 && !canGoNextMonth.value) return;
selectedMonth.value = monthStart.value.add(step, "month").format("YYYY-MM");
await fetchMonthData();
};
const openDayDetail = (day) => {
if (!day?.stat) return;
selectedDay.value = day;
activeDetailTab.value = limitStocks.value.length ? "limit" : "break";
detailVisible.value = true;
};
const fetchMonthData = async (withLoading = true) => {
if (!selectedMonth.value) return;
if (withLoading) loading.value = true;
try {
const res = await getSecondBoardCalendar({
tradeStartDate: monthStart.value.format("YYYY-MM-DD"),
tradeEndDate: monthEnd.value.format("YYYY-MM-DD"),
});
calendarStats.value = Array.isArray(res)
? res
: Array.isArray(res?.list)
? res.list
: [];
} catch (err) {
console.error("加载二板日历失败", err);
calendarStats.value = [];
} finally {
if (withLoading) loading.value = false;
}
};
const formatRate = (value) => {
if (value === null || value === undefined || value === "") return "--";
const number = Number(value);
if (!Number.isFinite(number)) return "--";
return `${number.toFixed(2)}%`;
};
const formatCount = (value) => {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
};
const formatRatio = (numerator, denominator) =>
`${formatCount(numerator)}/${formatCount(denominator)}`;
const formatPrice = (value) => {
if (value === null || value === undefined || value === "") return "--";
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(2) : "--";
};
const formatSignedRate = (value) => {
if (value === null || value === undefined || value === "") return "--";
const number = Number(value);
if (!Number.isFinite(number)) return "--";
return `${number > 0 ? "+" : ""}${number.toFixed(2)}%`;
};
const formatProfitFlag = (value) => {
if (value === true) return "是";
if (value === false) return "否";
return "--";
};
</script>
<style scoped>
.second-calendar-page {
min-height: 100vh;
min-height: 100dvh;
padding: 24px;
box-sizing: border-box;
background: #f4f7f9;
}
.month-filter {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 18px;
}
.month-picker {
width: 180px;
}
.calendar-stage {
max-width: 1280px;
margin: 0 auto;
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 72px;
align-items: stretch;
gap: 12px;
}
.calendar-panel {
min-width: 0;
padding: 22px 24px 26px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e5edf5;
border-radius: 8px;
box-shadow: 0 8px 22px rgba(31, 42, 55, 0.04);
}
.calendar-nav {
width: 100%;
min-height: 100%;
border: 1px solid #d8e3f0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
border-color: #d8e3f0;
color: #315f96;
background: #ffffff;
box-shadow: 0 8px 18px rgba(31, 42, 55, 0.06);
cursor: pointer;
transition:
border-color 0.2s ease,
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
}
.calendar-nav .el-icon {
font-size: 24px;
}
.calendar-nav:hover:not(:disabled) {
border-color: #9abbe1;
background: #eef6ff;
color: #1f5fa8;
}
.calendar-nav:disabled {
cursor: not-allowed;
color: #a8b2c1;
background: #f7f9fc;
box-shadow: none;
opacity: 0.72;
}
.calendar-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.month-title {
font-size: 22px;
line-height: 30px;
font-weight: 700;
color: #1f2a37;
}
.month-range {
font-size: 13px;
line-height: 20px;
color: #7c8aa5;
}
.weekday-row,
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.weekday-row {
gap: 8px;
margin-bottom: 8px;
}
.weekday-cell {
text-align: center;
font-size: 13px;
line-height: 20px;
font-weight: 700;
color: #5f6f86;
}
.calendar-grid {
gap: 8px;
}
.day-card {
min-height: 138px;
padding: 12px;
box-sizing: border-box;
border: 1px solid #e4ebf3;
border-radius: 8px;
background: #ffffff;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.day-card:hover {
border-color: #b9cbe2;
box-shadow: 0 8px 18px rgba(31, 42, 55, 0.07);
transform: translateY(-1px);
}
.day-card.detailable {
cursor: pointer;
}
.day-card.detailable:focus-visible {
outline: 2px solid #4f8fd9;
outline-offset: 2px;
}
.day-card.muted {
background: #f7f9fc;
color: #a8b2c1;
}
.day-card.closed {
background: #fafbfc;
}
.day-card.empty {
background: #fbfcfe;
}
.day-card.today {
border-color: #4f8fd9;
box-shadow: inset 0 0 0 1px rgba(79, 143, 217, 0.28);
}
.day-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 24px;
margin-bottom: 10px;
}
.day-number {
font-size: 16px;
line-height: 24px;
font-weight: 700;
color: #27364a;
}
.muted .day-number,
.closed .day-number {
color: #9aa7b8;
}
.day-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
background: #eef2f7;
color: #7c8aa5;
font-size: 12px;
line-height: 1;
font-weight: 700;
}
.day-metrics {
display: flex;
flex-direction: column;
gap: 8px;
}
.metric-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 24px;
font-size: 13px;
line-height: 20px;
color: #5f6f86;
}
.metric-row strong {
font-size: 15px;
line-height: 22px;
font-weight: 800;
color: #26364b;
}
.metric-row.profit strong {
color: #1f7a55;
}
.metric-row.break strong {
color: #c2414b;
}
.metric-row.limit strong {
color: #2f6fc0;
}
.day-placeholder {
min-height: 70px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: #a0acbb;
font-size: 13px;
line-height: 20px;
}
.detail-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.detail-summary-item {
min-width: 0;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #f8fafc;
}
.detail-summary-item span {
display: block;
margin-bottom: 4px;
font-size: 13px;
line-height: 20px;
color: #6d7d93;
}
.detail-summary-item strong {
font-size: 20px;
line-height: 28px;
color: #223047;
}
.detail-tabs {
min-width: 0;
}
:deep(.second-calendar-detail-dialog .el-dialog__body) {
padding-top: 8px;
}
@media (max-width: 1024px) {
.second-calendar-page {
padding: 18px;
}
.calendar-panel {
padding: 18px;
}
.calendar-stage {
grid-template-columns: 58px minmax(0, 1fr) 58px;
gap: 8px;
}
.day-card {
min-height: 128px;
padding: 10px;
}
}
@media (max-width: 820px) {
.second-calendar-page {
padding: 14px;
}
.calendar-head {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.calendar-stage {
grid-template-columns: 42px minmax(0, 1fr) 42px;
gap: 6px;
}
.calendar-panel {
padding: 12px;
}
.calendar-nav {
border-radius: 6px;
}
.calendar-nav .el-icon {
font-size: 20px;
}
.weekday-row,
.calendar-grid {
gap: 6px;
}
.weekday-cell {
font-size: 12px;
}
.day-card {
min-height: 112px;
padding: 8px;
}
.day-number {
font-size: 14px;
}
.metric-row {
align-items: flex-start;
flex-direction: column;
gap: 0;
font-size: 12px;
}
.metric-row strong {
font-size: 13px;
}
.day-placeholder {
min-height: 58px;
font-size: 12px;
}
.detail-summary {
grid-template-columns: 1fr;
}
}
</style>