Files
vue/src/views/stock/SecondBoardCalendar.vue
T
2026-05-14 11:18:55 +08:00

1001 lines
24 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,
getProfitDataCount(day.stat, "break"),
)
}}</strong>
</div>
<div class="metric-row limit">
<span>涨停板</span>
<strong>{{
formatRatio(
day.stat.limit_next_profit_count,
getProfitDataCount(day.stat, "limit"),
)
}}</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,
getProfitDataCount(selectedDayStat, "limit"),
)
}}</strong>
</div>
<div class="detail-summary-item">
<span>炸板</span>
<strong>{{
formatRatio(
selectedDayStat?.break_next_profit_count,
getProfitDataCount(selectedDayStat, "break"),
)
}}</strong>
</div>
</div>
<el-tabs v-model="activeDetailTab" class="detail-tabs">
<el-tab-pane
v-for="tab in detailTabs"
:key="tab.name"
:label="`${tab.label}(${tab.stocks.length})`"
:name="tab.name"
>
<el-table
:data="tab.stocks"
border
stripe
max-height="520"
class="detail-stock-table"
:default-sort="defaultDetailSort"
:row-class-name="getStockRowClassName"
>
<el-table-column v-if="tab.showStatus" prop="status" label="状态" width="86" align="center" sortable>
<template #default="{ row }">
<span :class="['stock-status', row.status === 'break' ? 'break' : 'limit']">
{{ formatStatus(row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="股票简称" min-width="120" sortable />
<el-table-column prop="ts_code" label="股票代码" min-width="120" sortable />
<el-table-column prop="industry" label="行业" min-width="120" sortable show-overflow-tooltip />
<el-table-column
prop="first_limit_time"
label="首次涨停时间"
width="130"
align="center"
sortable
:sort-method="sortLimitTimeColumn"
>
<template #default="{ row }">
{{ formatLimitTime(row.first_limit_time) }}
</template>
</el-table-column>
<el-table-column prop="open_times" label="炸板次数" width="100" align="center" sortable>
<template #default="{ row }">
{{ formatOpenTimes(row.open_times) }}
</template>
</el-table-column>
<el-table-column prop="t_high" label="T日最高" width="100" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.t_high) }}</template>
</el-table-column>
<el-table-column prop="t_close" label="T日收盘" width="100" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.t_close) }}</template>
</el-table-column>
<el-table-column prop="next_high" label="T+1最高" width="100" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.next_high) }}</template>
</el-table-column>
<el-table-column prop="next_intraday_profitable" label="T+1盈利" width="100" align="center" sortable>
<template #default="{ row }">
{{ formatProfitFlag(row.next_intraday_profitable) }}
</template>
</el-table-column>
<el-table-column prop="next_high_profit_pct" label="最高收益" width="110" align="right" sortable>
<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 defaultDetailSort = { prop: "first_limit_time", order: "ascending" };
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 totalStocks = computed(() => {
const stocks = Array.isArray(selectedDayStat.value?.total_stocks)
? selectedDayStat.value.total_stocks
: detailStocks.value;
return sortStocksByLimitTime(stocks);
});
const limitStocks = computed(() =>
sortStocksByLimitTime(
detailStocks.value.filter((stock) => stock?.status === "limit"),
),
);
const breakStocks = computed(() =>
sortStocksByLimitTime(
detailStocks.value.filter((stock) => stock?.status === "break"),
),
);
const detailTabs = computed(() => [
{
name: "total",
label: "合计",
stocks: totalStocks.value,
showStatus: true,
},
{
name: "limit",
label: "涨停板",
stocks: limitStocks.value,
showStatus: false,
},
{
name: "break",
label: "炸板",
stocks: breakStocks.value,
showStatus: false,
},
]);
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 = totalStocks.value.length
? "total"
: 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 sortStocksByLimitTime = (stocks) =>
[...stocks].sort((left, right) => {
const timeDiff =
getLimitTimeSortValue(left?.first_limit_time) -
getLimitTimeSortValue(right?.first_limit_time);
if (timeDiff !== 0) return timeDiff;
return String(left?.ts_code || "").localeCompare(String(right?.ts_code || ""));
});
const sortLimitTimeColumn = (left, right) =>
getLimitTimeSortValue(left?.first_limit_time) -
getLimitTimeSortValue(right?.first_limit_time);
const getLimitTimeSortValue = (value) => {
if (value === null || value === undefined || value === "") {
return Number.MAX_SAFE_INTEGER;
}
const text = String(value);
const timeMatch = text.match(/(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
if (timeMatch) {
const [, hour, minute, second = "0"] = timeMatch;
return Number(hour) * 3600 + Number(minute) * 60 + Number(second);
}
const digits = text.replace(/\D/g, "");
if (!digits) return Number.MAX_SAFE_INTEGER;
const timeDigits = digits.length > 6 ? digits.slice(-6) : digits;
const number = Number(timeDigits);
return Number.isFinite(number) ? number : Number.MAX_SAFE_INTEGER;
};
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 getProfitDataCount = (stat, type) => {
if (!stat) return 0;
const nextDataKey = type === "break" ? "break_next_data_count" : "limit_next_data_count";
const fallbackKey = type === "break" ? "break_count" : "limit_count";
return stat[nextDataKey] ?? stat[fallbackKey];
};
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 "--";
};
const formatStatus = (value) => {
if (value === "limit") return "封板";
if (value === "break") return "炸板";
return "--";
};
const formatLimitTime = (value) => {
if (value === null || value === undefined || value === "") return "--";
return String(value);
};
const formatOpenTimes = (value) => {
if (value === null || value === undefined || value === "") return "--";
const number = Number(value);
return Number.isFinite(number) ? number : "--";
};
const getStockRowClassName = ({ row }) =>
row?.status === "break" ? "break-stock-row" : "";
</script>
<style scoped>
.second-calendar-page {
height: 100vh;
height: 100dvh;
min-height: 0;
padding: 16px 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f4f7f9;
}
.month-filter {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
margin-bottom: 10px;
}
.month-picker {
width: 180px;
}
.calendar-stage {
width: 100%;
max-width: 1280px;
margin: 0 auto;
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 56px;
align-items: stretch;
gap: 10px;
}
.calendar-panel {
min-width: 0;
min-height: 0;
height: 100%;
padding: 16px 18px 18px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
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 {
flex: 0 0 auto;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.month-title {
font-size: 20px;
line-height: 26px;
font-weight: 700;
color: #1f2a37;
}
.month-range {
font-size: 12px;
line-height: 18px;
color: #7c8aa5;
}
.weekday-row,
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.weekday-row {
flex: 0 0 auto;
gap: 6px;
margin-bottom: 6px;
}
.weekday-cell {
text-align: center;
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #5f6f86;
}
.calendar-grid {
flex: 1 1 auto;
min-height: 0;
gap: 6px;
grid-auto-rows: minmax(0, 1fr);
}
.day-card {
min-height: 0;
padding: 8px 10px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
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 {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 20px;
margin-bottom: 5px;
}
.day-number {
font-size: 15px;
line-height: 20px;
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: 18px;
height: 18px;
border-radius: 999px;
background: #eef2f7;
color: #7c8aa5;
font-size: 12px;
line-height: 1;
font-weight: 700;
}
.day-metrics {
min-height: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.metric-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
font-size: 12px;
line-height: 18px;
color: #5f6f86;
}
.metric-row strong {
font-size: 14px;
line-height: 18px;
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 {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: #a0acbb;
font-size: 12px;
line-height: 18px;
}
.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;
}
.stock-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 24px;
padding: 0 8px;
box-sizing: border-box;
border-radius: 999px;
font-size: 12px;
line-height: 1;
font-weight: 700;
}
.stock-status.limit {
background: #eaf2ff;
color: #2563a8;
}
.stock-status.break {
background: #fee2e2;
color: #b4232c;
}
:deep(.detail-stock-table .break-stock-row td.el-table__cell) {
background: #fff1f2 !important;
color: #b4232c;
}
:deep(.detail-stock-table .break-stock-row:hover > td.el-table__cell) {
background: #ffe4e6 !important;
}
:deep(.second-calendar-detail-dialog .el-dialog__body) {
padding-top: 8px;
}
@media (max-width: 1024px) {
.second-calendar-page {
padding: 14px;
}
.calendar-panel {
padding: 14px;
}
.calendar-stage {
grid-template-columns: 58px minmax(0, 1fr) 58px;
gap: 8px;
}
.day-card {
padding: 8px;
}
}
@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 {
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 {
font-size: 12px;
}
.detail-summary {
grid-template-columns: 1fr;
}
}
@media (max-height: 900px) {
.second-calendar-page {
padding: 12px 16px;
}
.month-filter {
margin-bottom: 8px;
}
.calendar-stage {
grid-template-columns: 48px minmax(0, 1fr) 48px;
gap: 8px;
}
.calendar-panel {
padding: 12px 14px;
}
.calendar-head {
margin-bottom: 8px;
}
.month-title {
font-size: 18px;
line-height: 24px;
}
.weekday-row {
margin-bottom: 5px;
}
.weekday-cell {
line-height: 17px;
}
.calendar-grid {
gap: 5px;
}
.day-card {
padding: 7px 8px;
}
.day-card-head {
margin-bottom: 4px;
}
.day-metrics {
gap: 2px;
}
}
</style>