新增二板炸板情况

This commit is contained in:
2026-05-09 17:00:41 +08:00
parent ea2a9753f7
commit 4315f556c6
4 changed files with 806 additions and 2 deletions
+18
View File
@@ -40,6 +40,24 @@ export function getSecondBoardProfit(params) {
}) })
} }
export function getLimitBoardBreakNextPerformance(params) {
return request({
url: '/limit-board/break-next-performance',
method: 'get',
params
})
}
export function downloadLimitBoardBreakNextPerformanceCsv(params) {
return request({
url: '/download/limit-board/break-next-performance-csv',
method: 'get',
params,
responseType: 'blob',
timeout: 60000
})
}
export function downloadSecondBoardProfitCsv(params) { export function downloadSecondBoardProfitCsv(params) {
return request({ return request({
url: '/download/limit-board/second-board-profit-csv', url: '/download/limit-board/second-board-profit-csv',
+6
View File
@@ -23,6 +23,12 @@ const routes = [
component: () => import('@/views/stock/SecondBoard.vue'), component: () => import('@/views/stock/SecondBoard.vue'),
meta: { title: '二板', icon: 'Download' } meta: { title: '二板', icon: 'Download' }
}, },
{
path: '/stock/break-next-performance',
name: 'StockBreakNextPerformance',
component: () => import('@/views/stock/BreakNextPerformance.vue'),
meta: { title: '炸板次日表现', icon: 'TrendCharts' }
},
{ {
path: '/stock/download', path: '/stock/download',
redirect: '/stock/second-board' redirect: '/stock/second-board'
+780
View File
@@ -0,0 +1,780 @@
<template>
<div class="break-next-page">
<section class="filter-panel">
<div class="panel-head">
<div>
<h2>炸板次日表现</h2>
<p>按 T 日范围查询炸板股票,并展示 T+1 交易日表现。</p>
</div>
</div>
<el-form :inline="true" class="filter-form">
<el-form-item label="T日起">
<el-date-picker
v-model="startDate"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableDates"
:editable="false"
placeholder="选择起始日"
class="date-input"
/>
</el-form-item>
<el-form-item label="T日止">
<el-date-picker
v-model="endDate"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableDates"
:editable="false"
placeholder="选择结束日"
class="date-input"
/>
</el-form-item>
<el-form-item label="筛选">
<div class="checkbox-group">
<el-checkbox v-model="tOpenGainLt101">
T开盘小于1%
</el-checkbox>
<el-checkbox v-model="breakTimeBefore1100">
11点前炸板
</el-checkbox>
</div>
</el-form-item>
<el-form-item class="action-form-item">
<el-button
type="primary"
:icon="SearchIcon"
:loading="loading"
:disabled="!canQuery"
@click="handleQuery(true)"
>
查询
</el-button>
<el-button
:icon="ResetIcon"
:disabled="loading || downloading"
@click="handleReset"
>
重置
</el-button>
<el-button
:icon="DownloadIcon"
:loading="downloading"
:disabled="!canQuery"
@click="handleDownload"
>
下载 CSV
</el-button>
</el-form-item>
</el-form>
</section>
<section class="table-panel">
<div class="table-shell">
<el-table
v-loading="loading"
:data="tableData"
border
stripe
class="performance-table"
style="width: 100%"
height="100%"
:header-cell-style="{
background: '#f8f9fb',
color: '#444',
textAlign: 'center',
}"
>
<el-table-column
type="index"
label="Id"
width="70"
fixed="left"
align="center"
/>
<el-table-column
prop="trade_date"
label="T日"
width="118"
fixed="left"
align="center"
sortable
/>
<el-table-column
prop="ts_code"
label="股票代码"
width="126"
fixed="left"
align="center"
sortable
/>
<el-table-column
prop="name"
label="股票简称"
width="112"
fixed="left"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="t_pct_chg"
label="T涨跌幅"
width="92"
align="right"
sortable
>
<template #default="{ row }">{{ formatSignedPct(row.t_pct_chg) }}</template>
</el-table-column>
<el-table-column
prop="t_open_gain_lt_101"
label="T开盘<1%"
width="104"
align="center"
sortable
>
<template #default="{ row }">
<el-tag
:type="row.t_open_gain_lt_101 ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ formatBoolean(row.t_open_gain_lt_101) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="first_time"
label="炸板时间"
width="136"
align="center"
sortable
>
<template #default="{ row }">{{ row.first_time || "--" }}</template>
</el-table-column>
<el-table-column
prop="break_time_before_1100"
label="11点前炸板"
width="104"
align="center"
sortable
>
<template #default="{ row }">
<el-tag
:type="row.break_time_before_1100 ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ formatBoolean(row.break_time_before_1100) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="next_intraday_profitable"
label="T+1盘中可盈利"
width="120"
align="center"
sortable
>
<template #default="{ row }">
<el-tag
:type="row.next_intraday_profitable ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ formatBoolean(row.next_intraday_profitable) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="next_high_profit_pct"
label="T+1最高获利"
width="104"
align="right"
sortable
>
<template #default="{ row }">{{ formatSignedPct(row.next_high_profit_pct) }}</template>
</el-table-column>
<el-table-column
prop="next_open_pct_chg"
label="T+1开盘涨幅"
width="104"
align="right"
sortable
>
<template #default="{ row }">{{ formatSignedPct(row.next_open_pct_chg) }}</template>
</el-table-column>
<el-table-column
prop="market"
label="市场"
width="92"
align="center"
sortable
/>
<el-table-column
prop="industry"
label="行业"
min-width="120"
show-overflow-tooltip
sortable
/>
<el-table-column
prop="t_open"
label="T开盘"
width="88"
align="right"
sortable
>
<template #default="{ row }">{{ formatPrice(row.t_open) }}</template>
</el-table-column>
<el-table-column
prop="t_open_pct_chg"
label="T开盘涨幅"
width="104"
align="right"
sortable
>
<template #default="{ row }">{{ formatSignedPct(row.t_open_pct_chg) }}</template>
</el-table-column>
<el-table-column
prop="t_high"
label="T最高"
width="88"
align="right"
sortable
>
<template #default="{ row }">{{ formatPrice(row.t_high) }}</template>
</el-table-column>
<el-table-column
prop="t_close"
label="T收盘"
width="88"
align="right"
sortable
>
<template #default="{ row }">{{ formatPrice(row.t_close) }}</template>
</el-table-column>
<el-table-column
prop="has_tushare_break_data"
label="炸板"
width="108"
align="center"
sortable
>
<template #default="{ row }">
<el-tag
:type="row.has_tushare_break_data ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ row.has_tushare_break_data ? "有" : "无" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="next_open"
label="T+1开盘"
width="92"
align="right"
sortable
>
<template #default="{ row }">{{ formatPrice(row.next_open) }}</template>
</el-table-column>
<el-table-column
prop="next_high"
label="T+1最高"
width="92"
align="right"
sortable
>
<template #default="{ row }">{{ formatPrice(row.next_high) }}</template>
</el-table-column>
<el-table-column
prop="next_low"
label="T+1最低"
width="92"
align="right"
sortable
>
<template #default="{ row }">{{ formatPrice(row.next_low) }}</template>
</el-table-column>
<el-table-column
prop="next_close"
label="T+1收盘"
width="92"
align="right"
sortable
>
<template #default="{ row }">{{ formatPrice(row.next_close) }}</template>
</el-table-column>
<el-table-column
prop="next_low_loss_pct"
label="T+1最大亏损"
width="104"
align="right"
sortable
>
<template #default="{ row }">{{ formatSignedPct(row.next_low_loss_pct) }}</template>
</el-table-column>
<el-table-column
prop="next_close_pct_chg"
label="T+1收盘涨跌"
width="112"
align="right"
sortable
>
<template #default="{ row }">{{ formatSignedPct(row.next_close_pct_chg) }}</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-row">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
:layout="paginationLayout"
:pager-count="paginationPagerCount"
background
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
</section>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import {
Download as DownloadIcon,
RefreshLeft as ResetIcon,
Search as SearchIcon,
} from "@element-plus/icons-vue";
import { fetchTradeCalendar } from "@/composables/useTradeCalendar";
import {
downloadLimitBoardBreakNextPerformanceCsv,
getLimitBoardBreakNextPerformance,
} from "@/api/stock";
defineOptions({
name: "BreakNextPerformance",
});
const startDate = ref("");
const endDate = ref("");
const tOpenGainLt101 = ref(false);
const breakTimeBefore1100 = ref(false);
const openDatesSet = ref(new Set());
const maxTradeDate = ref("");
const loading = ref(false);
const downloading = ref(false);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const viewportWidth = ref(window.innerWidth);
const canQuery = computed(
() =>
Boolean(startDate.value && endDate.value) &&
startDate.value <= endDate.value,
);
const paginationLayout = computed(() =>
viewportWidth.value <= 820
? "total, prev, pager, next"
: "total, sizes, prev, pager, next, jumper",
);
const paginationPagerCount = computed(() => (viewportWidth.value <= 820 ? 5 : 7));
onMounted(async () => {
window.addEventListener("resize", syncViewport);
try {
const res = await fetchTradeCalendar();
const { open_dates: openDates = [], max_date: maxDate = "" } = res || {};
openDatesSet.value = new Set(openDates);
maxTradeDate.value = maxDate;
resetFilters();
if (canQuery.value) {
await handleQuery(true);
}
} catch (err) {
console.error("初始化炸板次日表现页失败", err);
}
});
onBeforeUnmount(() => {
window.removeEventListener("resize", syncViewport);
});
const syncViewport = () => {
viewportWidth.value = window.innerWidth;
};
const disableDates = (time) => {
const date = dayjs(time).format("YYYY-MM-DD");
if (maxTradeDate.value && date > maxTradeDate.value) return true;
return openDatesSet.value.size > 0 ? !openDatesSet.value.has(date) : false;
};
const resetFilters = () => {
const latestTradeDate = getLatestTradeDate();
startDate.value = latestTradeDate;
endDate.value = latestTradeDate;
tOpenGainLt101.value = false;
breakTimeBefore1100.value = false;
page.value = 1;
};
const handleReset = async () => {
resetFilters();
if (canQuery.value) {
await handleQuery(true);
}
};
const getLatestTradeDate = () => {
if (!openDatesSet.value.size) return "";
const openDates = [...openDatesSet.value].sort();
if (!maxTradeDate.value) return openDates.at(-1) || "";
if (openDatesSet.value.has(maxTradeDate.value)) return maxTradeDate.value;
return (
openDates.filter((date) => date <= maxTradeDate.value).at(-1) ||
openDates.at(-1) ||
""
);
};
const buildQueryParams = (withPage = true) => {
const params = {
tradeStartDate: startDate.value,
tradeEndDate: endDate.value,
};
if (withPage) {
params.page = page.value;
params.pageSize = pageSize.value;
}
if (tOpenGainLt101.value) {
params.tOpenGainLt101 = true;
}
if (breakTimeBefore1100.value) {
params.breakTimeBefore1100 = true;
}
return params;
};
const handleQuery = async (resetPage = false) => {
if (!validateDateRange()) return;
if (resetPage) page.value = 1;
loading.value = true;
try {
const res = await getLimitBoardBreakNextPerformance(buildQueryParams(true));
tableData.value = Array.isArray(res?.list) ? res.list : [];
total.value = Number(res?.total || 0);
} catch (err) {
console.error("加载炸板次日表现列表失败", err);
} finally {
loading.value = false;
}
};
const handlePageChange = async (currentPage) => {
page.value = currentPage;
await handleQuery(false);
};
const handlePageSizeChange = async (currentPageSize) => {
pageSize.value = currentPageSize;
page.value = 1;
await handleQuery(false);
};
const handleDownload = async () => {
if (!validateDateRange()) return;
downloading.value = true;
try {
const blob = await downloadLimitBoardBreakNextPerformanceCsv(
buildQueryParams(false),
);
saveBlob(blob, getDownloadFilename());
ElMessage.success("CSV 下载已开始");
} catch (err) {
console.error("下载炸板次日表现 CSV 失败", err);
} finally {
downloading.value = false;
}
};
const validateDateRange = () => {
if (!startDate.value || !endDate.value) {
ElMessage.warning("请选择 T 日范围");
return false;
}
if (endDate.value < startDate.value) {
ElMessage.warning("结束日期不能早于起始日期");
return false;
}
if (openDatesSet.value.size > 0) {
if (!openDatesSet.value.has(startDate.value)) {
ElMessage.warning("T日起不是交易日");
return false;
}
if (!openDatesSet.value.has(endDate.value)) {
ElMessage.warning("T日止不是交易日");
return false;
}
}
return true;
};
const saveBlob = (blob, filename) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
const getDownloadFilename = () => {
const start = startDate.value.replaceAll("-", "");
const end = endDate.value.replaceAll("-", "");
return `limit_break_next_performance_${start}_${end}_daily_detail.csv`;
};
const formatPrice = (value) => {
if (value === null || value === undefined || value === "") return "--";
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(2) : "--";
};
const formatSignedPct = (value) => {
if (value === null || value === undefined || value === "") return "--";
const number = Number(value);
if (!Number.isFinite(number)) return "--";
return `${number > 0 ? "+" : ""}${number.toFixed(2)}%`;
};
const formatBoolean = (value) => {
if (value === null || value === undefined) return "--";
return value ? "是" : "否";
};
</script>
<style scoped>
.break-next-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
padding: 20px;
box-sizing: border-box;
overflow: hidden;
background: #f4f7f9;
}
.filter-panel,
.table-panel {
background: #fff;
border: 1px solid #e5edf5;
border-radius: 8px;
box-shadow: 0 8px 22px rgba(31, 42, 55, 0.04);
}
.filter-panel {
flex: 0 0 auto;
padding: 22px 24px 24px;
margin-bottom: 12px;
}
.panel-head {
margin-bottom: 18px;
}
.panel-head h2 {
margin: 0 0 6px;
font-size: 20px;
line-height: 28px;
font-weight: 700;
letter-spacing: 0;
color: #1f2a37;
}
.panel-head p {
margin: 0;
color: #667085;
font-size: 14px;
line-height: 22px;
}
.filter-form {
display: flex;
flex-wrap: wrap;
gap: 10px 10px;
align-items: flex-end;
margin-bottom: 0;
}
.date-input {
width: 152px;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
min-height: 32px;
align-items: center;
}
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 0;
}
:deep(.date-input .el-input__wrapper),
:deep(.date-input .el-input__inner) {
cursor: pointer;
}
:deep(.action-form-item .el-form-item__content) {
display: flex;
gap: 10px;
}
.table-panel {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
padding: 18px;
}
.table-shell {
flex: 1 1 auto;
min-height: 360px;
width: 100%;
overflow: hidden;
}
.pagination-row {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
padding-top: 16px;
}
:deep(.performance-table .el-table__cell) {
white-space: nowrap;
}
:deep(.performance-table th.el-table__cell .cell) {
padding: 0 4px;
line-height: 16px;
white-space: normal;
word-break: break-all;
}
:deep(.performance-table td.el-table__cell .cell) {
padding: 0 6px;
}
@media (max-width: 1180px) {
.break-next-page {
padding: 16px;
}
.filter-panel,
.table-panel {
padding: 18px;
}
}
@media (max-width: 820px) {
.break-next-page {
padding: 12px;
}
.filter-panel,
.table-panel {
padding: 16px;
}
.filter-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
:deep(.filter-form .el-form-item),
:deep(.filter-form .el-form-item__content),
.date-input {
width: 100%;
}
.checkbox-group {
width: 100%;
}
:deep(.action-form-item) {
grid-column: 1 / -1;
}
:deep(.action-form-item .el-form-item__content) {
justify-content: stretch;
}
:deep(.action-form-item .el-button) {
flex: 1 1 0;
min-width: 0;
margin-left: 0;
}
.table-shell {
min-height: 430px;
}
.pagination-row {
justify-content: flex-start;
overflow-x: auto;
padding-top: 12px;
padding-bottom: 2px;
}
}
@media (max-width: 640px) {
.filter-form {
grid-template-columns: 1fr;
}
:deep(.action-form-item .el-form-item__content) {
flex-direction: column;
}
:deep(.action-form-item .el-button) {
width: 100%;
}
}
</style>
+2 -2
View File
@@ -153,7 +153,7 @@
border border
stripe stripe
class="profit-table" class="profit-table"
style="width: 100%; min-width: 1440px" style="width: 100%; min-width: 1880px"
height="100%" height="100%"
:header-cell-style="{ :header-cell-style="{
background: '#f8f9fb', background: '#f8f9fb',
@@ -957,7 +957,7 @@ const formatVolume = (value) => {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 360px; min-height: 360px;
width: 100%; width: 100%;
overflow: hidden; overflow: auto;
} }
.stock-link { .stock-link {