Compare commits

..

4 Commits

Author SHA1 Message Date
yang 9d2ecbef04 新增历史二板页面 2026-05-17 13:20:23 +08:00
yang 87cc632c03 新增首页详情合计页 2026-05-14 11:18:55 +08:00
yang 4e4975ab67 新增二板日历详情数据按涨停时间升序排序 2026-05-13 23:47:06 +08:00
yang 938a5c06da 优化 2026-05-13 17:46:25 +08:00
5 changed files with 1179 additions and 43 deletions
+16
View File
@@ -48,6 +48,22 @@ export function getSecondBoardCalendar(params) {
}) })
} }
export function getSecondBoardProfitSummary(params) {
return request({
url: '/limit-board/second-board-profit-summary',
method: 'get',
params
})
}
export function getSecondBoardProfitDetail(params) {
return request({
url: '/limit-board/second-board-profit-detail',
method: 'get',
params
})
}
export function getLimitBoardBreakNextPerformance(params) { export function getLimitBoardBreakNextPerformance(params) {
return request({ return request({
url: '/limit-board/break-next-performance', url: '/limit-board/break-next-performance',
+118
View File
@@ -0,0 +1,118 @@
<template>
<el-form-item :label="startLabel">
<el-date-picker
v-model="localStartDate"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableDates"
:editable="false"
:placeholder="startPlaceholder"
class="trade-date-input"
/>
</el-form-item>
<el-form-item :label="endLabel">
<el-date-picker
v-model="localEndDate"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableDates"
:editable="false"
:placeholder="endPlaceholder"
class="trade-date-input"
/>
</el-form-item>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import dayjs from "dayjs";
import { fetchTradeCalendar } from "@/composables/useTradeCalendar";
defineOptions({
name: "TradeDateRangePicker",
});
const props = defineProps({
startDate: {
type: String,
default: "",
},
endDate: {
type: String,
default: "",
},
startLabel: {
type: String,
default: "T日起",
},
endLabel: {
type: String,
default: "T日止",
},
startPlaceholder: {
type: String,
default: "选择起始日",
},
endPlaceholder: {
type: String,
default: "选择结束日",
},
});
const emit = defineEmits([
"update:startDate",
"update:endDate",
"calendar-ready",
]);
const openDatesSet = ref(new Set());
const maxTradeDate = ref("");
const localStartDate = computed({
get: () => props.startDate,
set: (value) => emit("update:startDate", value),
});
const localEndDate = computed({
get: () => props.endDate,
set: (value) => emit("update:endDate", value),
});
onMounted(async () => {
try {
const calendar = await fetchTradeCalendar();
const { open_dates: openDates = [], max_date: maxDate = "" } = calendar || {};
openDatesSet.value = new Set(openDates);
maxTradeDate.value = maxDate;
emit("calendar-ready", {
open_dates: openDates,
max_date: maxDate,
});
} catch (err) {
console.error("加载交易日历失败", err);
emit("calendar-ready", {
open_dates: [],
max_date: "",
});
}
});
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;
};
</script>
<style scoped>
.trade-date-input {
width: 156px;
}
@media (max-width: 820px) {
.trade-date-input {
width: 100%;
}
}
</style>
+6
View File
@@ -11,6 +11,12 @@ const routes = [
component: () => import('@/views/stock/SecondBoardCalendar.vue'), component: () => import('@/views/stock/SecondBoardCalendar.vue'),
meta: { title: '二板日历', icon: 'Calendar' } meta: { title: '二板日历', icon: 'Calendar' }
}, },
{
path: '/stock/second-board-profit-summary',
name: 'StockSecondBoardProfitSummary',
component: () => import('@/views/stock/SecondBoardProfitSummary.vue'),
meta: { title: '二板获利综合', icon: 'TrendCharts' }
},
{ {
path: '/stock/second-board', path: '/stock/second-board',
name: 'StockSecondBoard', name: 'StockSecondBoard',
+194 -41
View File
@@ -77,11 +77,21 @@
</div> </div>
<div class="metric-row break"> <div class="metric-row break">
<span>炸板</span> <span>炸板</span>
<strong>{{ formatRatio(day.stat.break_next_profit_count, day.stat.break_count) }}</strong> <strong>{{
formatRatio(
day.stat.break_next_profit_count,
getProfitDataCount(day.stat, "break"),
)
}}</strong>
</div> </div>
<div class="metric-row limit"> <div class="metric-row limit">
<span>涨停板</span> <span>涨停板</span>
<strong>{{ formatRatio(day.stat.limit_next_profit_count, day.stat.limit_count) }}</strong> <strong>{{
formatRatio(
day.stat.limit_next_profit_count,
getProfitDataCount(day.stat, "limit"),
)
}}</strong>
</div> </div>
</div> </div>
@@ -123,7 +133,7 @@
<strong>{{ <strong>{{
formatRatio( formatRatio(
selectedDayStat?.limit_next_profit_count, selectedDayStat?.limit_next_profit_count,
selectedDayStat?.limit_count, getProfitDataCount(selectedDayStat, "limit"),
) )
}}</strong> }}</strong>
</div> </div>
@@ -132,59 +142,70 @@
<strong>{{ <strong>{{
formatRatio( formatRatio(
selectedDayStat?.break_next_profit_count, selectedDayStat?.break_next_profit_count,
selectedDayStat?.break_count, getProfitDataCount(selectedDayStat, "break"),
) )
}}</strong> }}</strong>
</div> </div>
</div> </div>
<el-tabs v-model="activeDetailTab" class="detail-tabs"> <el-tabs v-model="activeDetailTab" class="detail-tabs">
<el-tab-pane :label="`涨停板(${limitStocks.length})`" name="limit"> <el-tab-pane
<el-table :data="limitStocks" border stripe max-height="520"> v-for="tab in detailTabs"
<el-table-column prop="name" label="股票简称" min-width="120" /> :key="tab.name"
<el-table-column prop="ts_code" label="股票代码" min-width="120" /> :label="`${tab.label}(${tab.stocks.length})`"
<el-table-column prop="industry" label="行业" min-width="120" show-overflow-tooltip /> :name="tab.name"
<el-table-column label="T日最高" width="100" align="right"> >
<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> <template #default="{ row }">{{ formatPrice(row.t_high) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="T日收盘" width="100" align="right"> <el-table-column prop="t_close" label="T日收盘" width="100" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.t_close) }}</template> <template #default="{ row }">{{ formatPrice(row.t_close) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="T+1最高" width="100" align="right"> <el-table-column prop="next_high" label="T+1最高" width="100" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.next_high) }}</template> <template #default="{ row }">{{ formatPrice(row.next_high) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="T+1盈利" width="100" align="center"> <el-table-column prop="next_intraday_profitable" label="T+1盈利" width="100" align="center" sortable>
<template #default="{ row }"> <template #default="{ row }">
{{ formatProfitFlag(row.next_intraday_profitable) }} {{ formatProfitFlag(row.next_intraday_profitable) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="最高收益" width="110" align="right"> <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-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 }"> <template #default="{ row }">
{{ formatSignedRate(row.next_high_profit_pct) }} {{ formatSignedRate(row.next_high_profit_pct) }}
</template> </template>
@@ -220,6 +241,7 @@ const calendarStats = ref([]);
const detailVisible = ref(false); const detailVisible = ref(false);
const selectedDay = ref(null); const selectedDay = ref(null);
const activeDetailTab = ref("limit"); const activeDetailTab = ref("limit");
const defaultDetailSort = { prop: "first_limit_time", order: "ascending" };
const monthStart = computed(() => const monthStart = computed(() =>
selectedMonth.value selectedMonth.value
@@ -246,12 +268,43 @@ const detailDialogTitle = computed(() => {
const detailStocks = computed(() => const detailStocks = computed(() =>
Array.isArray(selectedDayStat.value?.stocks) ? selectedDayStat.value.stocks : [], 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(() => const limitStocks = computed(() =>
sortStocksByLimitTime(
detailStocks.value.filter((stock) => stock?.status === "limit"), detailStocks.value.filter((stock) => stock?.status === "limit"),
),
); );
const breakStocks = computed(() => const breakStocks = computed(() =>
sortStocksByLimitTime(
detailStocks.value.filter((stock) => stock?.status === "break"), 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 statMap = computed(() => {
const map = new Map(); const map = new Map();
@@ -332,7 +385,11 @@ const openDayDetail = (day) => {
if (!day?.stat) return; if (!day?.stat) return;
selectedDay.value = day; selectedDay.value = day;
activeDetailTab.value = limitStocks.value.length ? "limit" : "break"; activeDetailTab.value = totalStocks.value.length
? "total"
: limitStocks.value.length
? "limit"
: "break";
detailVisible.value = true; detailVisible.value = true;
}; };
@@ -359,6 +416,40 @@ const fetchMonthData = async (withLoading = true) => {
} }
}; };
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) => { const formatRate = (value) => {
if (value === null || value === undefined || value === "") return "--"; if (value === null || value === undefined || value === "") return "--";
@@ -375,6 +466,14 @@ const formatCount = (value) => {
const formatRatio = (numerator, denominator) => const formatRatio = (numerator, denominator) =>
`${formatCount(numerator)}/${formatCount(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) => { const formatPrice = (value) => {
if (value === null || value === undefined || value === "") return "--"; if (value === null || value === undefined || value === "") return "--";
@@ -395,6 +494,27 @@ const formatProfitFlag = (value) => {
if (value === false) return "否"; if (value === false) return "否";
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> </script>
<style scoped> <style scoped>
@@ -705,6 +825,39 @@ const formatProfitFlag = (value) => {
min-width: 0; 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) { :deep(.second-calendar-detail-dialog .el-dialog__body) {
padding-top: 8px; padding-top: 8px;
} }
@@ -0,0 +1,843 @@
<template>
<div class="profit-summary-page">
<section class="filter-panel">
<div class="panel-head">
<div>
<h2>二板获利综合</h2>
<p>统计 09:31:00-09:59:59 早盘二板后的 T+1 最高获利。</p>
</div>
</div>
<el-form :inline="true" class="filter-form">
<TradeDateRangePicker
v-model:start-date="startDate"
v-model:end-date="endDate"
@calendar-ready="handleCalendarReady"
/>
<el-form-item label="搜索">
<el-input
v-model.trim="keyword"
clearable
placeholder="股票简称 / 代码 / 行业"
class="keyword-input"
/>
</el-form-item>
<el-form-item class="action-form-item">
<el-button
type="primary"
:icon="SearchIcon"
:loading="loading"
:disabled="loading || !canQuery"
@click="handleQuery"
>
查询
</el-button>
<el-button :icon="ResetIcon" :disabled="loading" @click="handleReset">
重置
</el-button>
</el-form-item>
</el-form>
</section>
<section class="summary-strip">
<div class="summary-item">
<span>命中个股</span>
<strong>{{ formatCount(responseMeta.total ?? tableData.length) }}</strong>
</div>
<div class="summary-item">
<span>二板次数</span>
<strong>{{ formatCount(totalSecondBoardCount) }}</strong>
</div>
<div class="summary-item">
<span>可统计次数</span>
<strong>{{ formatCount(totalProfitDataCount) }}</strong>
</div>
<div class="summary-item">
<span>累计获利合计</span>
<strong :class="getPctClass(totalProfitPct)">
{{ formatSignedPct(totalProfitPct) }}
</strong>
</div>
<div class="summary-item">
<span>筛选区间</span>
<strong class="range-text">{{ rangeLabel }}</strong>
</div>
</section>
<section class="table-panel">
<div class="table-shell">
<el-table
v-loading="loading"
:data="pagedTableData"
border
stripe
height="100%"
class="summary-table"
row-key="ts_code"
:default-sort="{ prop: 'total_next_high_profit_pct', order: 'descending' }"
:header-cell-style="{
background: '#f8f9fb',
color: '#444',
textAlign: 'center',
}"
@sort-change="handleSortChange"
@row-dblclick="openDetail"
>
<el-table-column
type="index"
label="排名"
width="72"
fixed="left"
align="center"
:index="getTableIndex"
/>
<el-table-column prop="name" label="股票简称" width="128" fixed="left" align="center" sortable>
<template #default="{ row }">
<button class="stock-link" type="button" @click.stop="openDetail(row)">
{{ row.name || "--" }}
</button>
</template>
</el-table-column>
<el-table-column prop="ts_code" label="股票代码" width="126" fixed="left" align="center" sortable />
<el-table-column prop="market" label="市场" width="92" align="center" sortable />
<el-table-column prop="industry" label="行业" min-width="132" show-overflow-tooltip sortable />
<el-table-column prop="second_board_count" label="二板次数" width="98" align="center" sortable>
<template #default="{ row }">{{ formatCount(row.second_board_count) }}</template>
</el-table-column>
<el-table-column label="封板 / 炸板" width="118" align="center">
<template #default="{ row }">
{{ formatCount(row.limit_count) }} / {{ formatCount(row.break_count) }}
</template>
</el-table-column>
<el-table-column prop="profit_data_count" label="可统计次数" width="108" align="center" sortable>
<template #default="{ row }">{{ formatCount(row.profit_data_count) }}</template>
</el-table-column>
<el-table-column
prop="total_next_high_profit_pct"
label="累计获利"
width="116"
align="right"
sortable
>
<template #default="{ row }">
<span :class="getPctClass(row.total_next_high_profit_pct)">
{{ formatSignedPct(row.total_next_high_profit_pct) }}
</span>
</template>
</el-table-column>
<el-table-column
prop="average_next_high_profit_pct"
label="平均获利"
width="116"
align="right"
sortable
>
<template #default="{ row }">
<span :class="getPctClass(row.average_next_high_profit_pct)">
{{ formatSignedPct(row.average_next_high_profit_pct) }}
</span>
</template>
</el-table-column>
<el-table-column prop="first_trade_date" label="首次命中" width="118" align="center" sortable />
<el-table-column prop="last_trade_date" label="最近命中" width="118" align="center" sortable />
<el-table-column label="操作" width="96" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" :icon="ViewIcon" @click.stop="openDetail(row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-row">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="tableTotal"
:page-sizes="[10, 20, 50, 100, 200]"
:layout="paginationLayout"
:pager-count="paginationPagerCount"
background
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
</section>
<el-dialog
v-model="detailVisible"
class="profit-detail-dialog"
:title="detailTitle"
width="86%"
align-center
destroy-on-close
>
<div class="detail-summary">
<div class="detail-summary-item">
<span>二板次数</span>
<strong>{{ formatCount(detailMeta.second_board_count) }}</strong>
</div>
<div class="detail-summary-item">
<span>可统计次数</span>
<strong>{{ formatCount(detailMeta.profit_data_count) }}</strong>
</div>
<div class="detail-summary-item">
<span>累计获利</span>
<strong :class="getPctClass(detailMeta.total_next_high_profit_pct)">
{{ formatSignedPct(detailMeta.total_next_high_profit_pct) }}
</strong>
</div>
<div class="detail-summary-item">
<span>平均获利</span>
<strong :class="getPctClass(detailMeta.average_next_high_profit_pct)">
{{ formatSignedPct(detailMeta.average_next_high_profit_pct) }}
</strong>
</div>
</div>
<el-table
v-loading="detailLoading"
:data="detailRows"
border
stripe
max-height="560"
class="detail-table"
:default-sort="{ prop: 'trade_date', order: 'descending' }"
:row-class-name="getDetailRowClassName"
>
<el-table-column prop="trade_date" label="T日" width="118" fixed="left" align="center" sortable />
<el-table-column prop="status" label="状态" width="82" 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="first_limit_time" label="首次涨停时间" width="132" align="center" sortable>
<template #default="{ row }">{{ row.first_limit_time || "--" }}</template>
</el-table-column>
<el-table-column prop="previous_two_trade_date" label="T-2" width="118" align="center" sortable />
<el-table-column prop="previous_trade_date" label="T-1" width="118" align="center" sortable />
<el-table-column prop="next_trade_date" label="T+1" width="118" align="center" sortable />
<el-table-column prop="open_times" label="炸板次数" width="96" align="center" sortable>
<template #default="{ row }">{{ formatNullableCount(row.open_times) }}</template>
</el-table-column>
<el-table-column prop="t_high" label="T最高" width="92" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.t_high) }}</template>
</el-table-column>
<el-table-column prop="t_close" label="T收盘" width="92" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.t_close) }}</template>
</el-table-column>
<el-table-column prop="next_high" label="T+1最高" width="98" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.next_high) }}</template>
</el-table-column>
<el-table-column prop="next_close" label="T+1收盘" width="98" align="right" sortable>
<template #default="{ row }">{{ formatPrice(row.next_close) }}</template>
</el-table-column>
<el-table-column prop="next_intraday_profitable" label="T+1盈利" width="96" 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="108" align="right" sortable>
<template #default="{ row }">
<span :class="getPctClass(row.next_high_profit_pct)">
{{ formatSignedPct(row.next_high_profit_pct) }}
</span>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import {
RefreshLeft as ResetIcon,
Search as SearchIcon,
View as ViewIcon,
} from "@element-plus/icons-vue";
import TradeDateRangePicker from "@/components/TradeDateRangePicker.vue";
import {
getSecondBoardProfitDetail,
getSecondBoardProfitSummary,
} from "@/api/stock";
defineOptions({
name: "SecondBoardProfitSummary",
});
const DEFAULT_START_DATE = "2020-01-01";
const startDate = ref(DEFAULT_START_DATE);
const endDate = ref(dayjs().format("YYYY-MM-DD"));
const keyword = ref("");
const openDatesSet = ref(new Set());
const maxTradeDate = ref("");
const loading = ref(true);
const detailLoading = ref(false);
const tableData = ref([]);
const responseMeta = ref({});
const detailVisible = ref(false);
const selectedStock = ref(null);
const detailMeta = ref({});
const detailRows = ref([]);
const page = ref(1);
const pageSize = ref(20);
const viewportWidth = ref(typeof window === "undefined" ? 1200 : window.innerWidth);
const sortState = ref({
prop: "total_next_high_profit_pct",
order: "descending",
});
const canQuery = computed(
() => Boolean(startDate.value && endDate.value) && startDate.value <= endDate.value,
);
const filteredTableData = computed(() => {
const text = keyword.value.trim().toLowerCase();
if (!text) return tableData.value;
return tableData.value.filter((row) =>
[row?.name, row?.symbol, row?.ts_code, row?.market, row?.industry]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(text)),
);
});
const sortedFilteredTableData = computed(() => {
const data = [...filteredTableData.value];
const { prop, order } = sortState.value;
if (!prop || !order) return data;
const direction = order === "ascending" ? 1 : -1;
return data.sort((left, right) => compareValues(left?.[prop], right?.[prop]) * direction);
});
const tableTotal = computed(() => filteredTableData.value.length);
const pagedTableData = computed(() => {
const start = (page.value - 1) * pageSize.value;
return sortedFilteredTableData.value.slice(start, start + pageSize.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));
const totalSecondBoardCount = computed(() =>
tableData.value.reduce((sum, item) => sum + toNumber(item?.second_board_count), 0),
);
const totalProfitDataCount = computed(() =>
tableData.value.reduce((sum, item) => sum + toNumber(item?.profit_data_count), 0),
);
const totalProfitPct = computed(() =>
tableData.value.reduce((sum, item) => sum + toNumber(item?.total_next_high_profit_pct), 0),
);
const rangeLabel = computed(() => {
const start = responseMeta.value.trade_start_date || startDate.value || "--";
const end = responseMeta.value.trade_end_date || endDate.value || "--";
return `${start} 至 ${end}`;
});
const detailTitle = computed(() => {
const stock = selectedStock.value || detailMeta.value;
const name = stock?.name || "个股";
const code = stock?.ts_code || stock?.symbol || "";
return code ? `${name} (${code}) 二板获利详情` : `${name}二板获利详情`;
});
onMounted(() => {
window.addEventListener("resize", syncViewport);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", syncViewport);
});
watch(keyword, () => {
page.value = 1;
});
watch(tableTotal, (total) => {
const maxPage = Math.max(1, Math.ceil(total / pageSize.value));
if (page.value > maxPage) page.value = maxPage;
});
const syncViewport = () => {
viewportWidth.value = window.innerWidth;
};
const handleCalendarReady = async (calendar = {}) => {
loading.value = true;
try {
const { open_dates: openDates = [], max_date: maxDate = "" } = calendar || {};
openDatesSet.value = new Set(openDates);
maxTradeDate.value = maxDate;
resetDates();
await fetchSummary(false);
} catch (err) {
console.error("初始化二板获利综合页失败", err);
} finally {
loading.value = false;
}
};
const resetDates = () => {
endDate.value = getLatestTradeDate();
startDate.value = getDefaultStartDate();
};
const getLatestTradeDate = () => {
const openDates = [...openDatesSet.value].sort();
if (!openDates.length) return maxTradeDate.value || dayjs().format("YYYY-MM-DD");
if (maxTradeDate.value && openDatesSet.value.has(maxTradeDate.value)) return maxTradeDate.value;
if (maxTradeDate.value) {
return openDates.filter((date) => date <= maxTradeDate.value).at(-1) || openDates.at(-1) || "";
}
return openDates.at(-1) || "";
};
const getDefaultStartDate = () => {
const openDates = [...openDatesSet.value].sort();
if (!openDates.length) return DEFAULT_START_DATE;
return (
openDates.find((date) => date >= DEFAULT_START_DATE && (!endDate.value || date <= endDate.value)) ||
openDates[0]
);
};
const handleReset = async () => {
keyword.value = "";
resetDates();
await handleQuery();
};
const handleQuery = async () => {
if (!validateDateRange()) return;
page.value = 1;
await fetchSummary(true);
};
const fetchSummary = async (withLoading = true) => {
if (withLoading) loading.value = true;
try {
const res = await getSecondBoardProfitSummary({
tradeStartDate: startDate.value,
tradeEndDate: endDate.value,
});
tableData.value = Array.isArray(res?.list) ? res.list : [];
responseMeta.value = res || {};
page.value = 1;
} catch (err) {
console.error("加载二板获利综合失败", err);
tableData.value = [];
responseMeta.value = {};
} finally {
if (withLoading) loading.value = false;
}
};
const handlePageChange = (currentPage) => {
page.value = currentPage;
};
const handlePageSizeChange = (currentPageSize) => {
pageSize.value = currentPageSize;
page.value = 1;
};
const handleSortChange = ({ prop, order }) => {
sortState.value = {
prop: prop || "",
order: order || "",
};
page.value = 1;
};
const openDetail = async (stock) => {
if (!stock?.ts_code && !stock?.symbol) return;
selectedStock.value = stock;
detailVisible.value = true;
detailLoading.value = true;
detailRows.value = [];
detailMeta.value = { ...stock };
try {
const res = await getSecondBoardProfitDetail({
tsCode: stock.ts_code,
symbol: stock.symbol,
tradeStartDate: startDate.value,
tradeEndDate: endDate.value,
});
detailMeta.value = res || {};
detailRows.value = Array.isArray(res?.list) ? res.list : [];
} catch (err) {
console.error("加载个股二板获利详情失败", err);
detailRows.value = [];
} finally {
detailLoading.value = false;
}
};
const validateDateRange = () => {
if (!startDate.value || !endDate.value) {
ElMessage.warning("请选择 T 日范围");
return false;
}
if (endDate.value < startDate.value) {
ElMessage.warning("结束日期不能早于起始日期");
return false;
}
return true;
};
const toNumber = (value) => {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
};
const compareValues = (left, right) => {
const leftEmpty = left === null || left === undefined || left === "";
const rightEmpty = right === null || right === undefined || right === "";
if (leftEmpty && rightEmpty) return 0;
if (leftEmpty) return 1;
if (rightEmpty) return -1;
const leftNumber = Number(left);
const rightNumber = Number(right);
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
return leftNumber - rightNumber;
}
return String(left).localeCompare(String(right), "zh-Hans-CN", {
numeric: true,
sensitivity: "base",
});
};
const getTableIndex = (index) => (page.value - 1) * pageSize.value + index + 1;
const formatCount = (value) => {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
};
const formatNullableCount = (value) => {
if (value === null || value === undefined || value === "") return "--";
return formatCount(value);
};
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 formatProfitFlag = (value) => {
if (value === true) return "是";
if (value === false) return "否";
return "--";
};
const formatStatus = (value) => {
if (value === "limit") return "封板";
if (value === "break") return "炸板";
return "--";
};
const getPctClass = (value) => {
const number = Number(value);
if (!Number.isFinite(number) || number === 0) return "";
return number > 0 ? "is-up" : "is-down";
};
const getDetailRowClassName = ({ row }) =>
row?.status === "break" ? "break-detail-row" : "";
</script>
<style scoped>
.profit-summary-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
padding: 20px;
box-sizing: border-box;
overflow: hidden;
background: #f4f7f9;
}
.filter-panel,
.table-panel {
flex: 0 0 auto;
background: #ffffff;
border: 1px solid #e5edf5;
border-radius: 8px;
box-shadow: 0 8px 22px rgba(31, 42, 55, 0.04);
}
.filter-panel {
padding: 18px 20px 8px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.panel-head h2 {
margin: 0;
font-size: 20px;
line-height: 28px;
color: #1f2a37;
}
.panel-head p {
margin: 4px 0 0;
font-size: 13px;
line-height: 20px;
color: #6d7d93;
}
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 8px;
}
.keyword-input {
width: 226px;
}
.summary-strip {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin: 12px 0;
}
.summary-item {
min-width: 0;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #ffffff;
}
.summary-item span {
display: block;
margin-bottom: 4px;
font-size: 12px;
line-height: 18px;
color: #6d7d93;
}
.summary-item strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 20px;
line-height: 28px;
color: #223047;
}
.summary-item .range-text {
font-size: 14px;
line-height: 28px;
}
.table-panel {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
padding: 12px;
}
.table-shell {
flex: 1 1 auto;
height: auto;
min-height: 0;
overflow: hidden;
}
.summary-table {
width: 100%;
}
.pagination-row {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
padding-top: 12px;
}
.stock-link {
border: 0;
padding: 0;
background: transparent;
color: #1f5fa8;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.stock-link:hover {
color: #0f3f78;
text-decoration: underline;
}
.is-up {
color: #1f7a55;
font-weight: 700;
}
.is-down {
color: #c2414b;
font-weight: 700;
}
.detail-summary {
display: grid;
grid-template-columns: repeat(4, 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;
}
.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-table .break-detail-row td.el-table__cell) {
background: #fff1f2 !important;
}
:deep(.detail-table .break-detail-row:hover > td.el-table__cell) {
background: #ffe4e6 !important;
}
:deep(.profit-detail-dialog .el-dialog__body) {
padding-top: 8px;
}
@media (max-width: 1180px) {
.summary-strip {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 820px) {
.profit-summary-page {
padding: 14px;
}
.filter-panel {
padding: 14px 14px 4px;
}
.filter-form {
display: block;
}
.keyword-input {
width: 100%;
}
.summary-strip,
.detail-summary {
grid-template-columns: 1fr;
}
.table-panel {
padding: 10px;
}
.pagination-row {
justify-content: flex-start;
overflow-x: auto;
padding-top: 10px;
padding-bottom: 2px;
}
}
</style>