Compare commits
7 Commits
7b720f7918
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d2ecbef04 | |||
| 87cc632c03 | |||
| 4e4975ab67 | |||
| 938a5c06da | |||
| f5df65fb75 | |||
| debeb72516 | |||
| 8904cc8868 |
@@ -1,3 +1,15 @@
|
|||||||
# Vue 3 + Vite
|
# Vue 3 + Vite
|
||||||
|
|
||||||
# 运行项目使用:npm run dev
|
# 运行项目使用
|
||||||
|
|
||||||
|
先启动后端服务,默认接口地址为 `http://127.0.0.1:18080`。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
如果后端端口或地址不同,可以启动前端时覆盖代理目标:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:18080 npm run dev
|
||||||
|
```
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -33,11 +33,9 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="筛选">
|
<el-form-item label="筛选">
|
||||||
<div class="checkbox-group">
|
<div class="checkbox-group">
|
||||||
<el-checkbox v-model="tOpenGainLt101">
|
<el-checkbox v-model="tOpenGainLt101"> T开盘小于1% </el-checkbox>
|
||||||
T开盘小于1%
|
|
||||||
</el-checkbox>
|
|
||||||
<el-checkbox v-model="breakTimeBefore1100">
|
<el-checkbox v-model="breakTimeBefore1100">
|
||||||
11点前炸板
|
10点前炸板
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -124,7 +122,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatSignedPct(row.t_pct_chg) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatSignedPct(row.t_pct_chg)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="t_open_gain_lt_101"
|
prop="t_open_gain_lt_101"
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="break_time_before_1100"
|
prop="break_time_before_1100"
|
||||||
label="11点前炸板"
|
label="10点前炸板"
|
||||||
width="104"
|
width="104"
|
||||||
align="center"
|
align="center"
|
||||||
sortable
|
sortable
|
||||||
@@ -193,7 +193,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatSignedPct(row.next_high_profit_pct) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatSignedPct(row.next_high_profit_pct)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="next_open_pct_chg"
|
prop="next_open_pct_chg"
|
||||||
@@ -202,7 +204,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatSignedPct(row.next_open_pct_chg) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatSignedPct(row.next_open_pct_chg)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="market"
|
prop="market"
|
||||||
@@ -225,7 +229,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatPrice(row.t_open) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatPrice(row.t_open)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="t_open_pct_chg"
|
prop="t_open_pct_chg"
|
||||||
@@ -234,7 +240,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatSignedPct(row.t_open_pct_chg) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatSignedPct(row.t_open_pct_chg)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="t_high"
|
prop="t_high"
|
||||||
@@ -243,7 +251,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
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
|
<el-table-column
|
||||||
prop="t_close"
|
prop="t_close"
|
||||||
@@ -252,7 +262,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
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
|
<el-table-column
|
||||||
prop="has_tushare_break_data"
|
prop="has_tushare_break_data"
|
||||||
@@ -278,7 +290,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatPrice(row.next_open) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatPrice(row.next_open)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="next_high"
|
prop="next_high"
|
||||||
@@ -287,7 +301,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
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
|
<el-table-column
|
||||||
prop="next_low"
|
prop="next_low"
|
||||||
@@ -296,7 +312,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatPrice(row.next_low) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatPrice(row.next_low)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="next_close"
|
prop="next_close"
|
||||||
@@ -305,7 +323,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatPrice(row.next_close) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatPrice(row.next_close)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="next_low_loss_pct"
|
prop="next_low_loss_pct"
|
||||||
@@ -314,7 +334,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatSignedPct(row.next_low_loss_pct) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatSignedPct(row.next_low_loss_pct)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
prop="next_close_pct_chg"
|
prop="next_close_pct_chg"
|
||||||
@@ -323,7 +345,9 @@
|
|||||||
align="right"
|
align="right"
|
||||||
sortable
|
sortable
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatSignedPct(row.next_close_pct_chg) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
formatSignedPct(row.next_close_pct_chg)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,7 +414,9 @@ const paginationLayout = computed(() =>
|
|||||||
: "total, sizes, prev, pager, next, jumper",
|
: "total, sizes, prev, pager, next, jumper",
|
||||||
);
|
);
|
||||||
|
|
||||||
const paginationPagerCount = computed(() => (viewportWidth.value <= 820 ? 5 : 7));
|
const paginationPagerCount = computed(() =>
|
||||||
|
viewportWidth.value <= 820 ? 5 : 7,
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener("resize", syncViewport);
|
window.addEventListener("resize", syncViewport);
|
||||||
|
|||||||
@@ -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,22 +494,49 @@ 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>
|
||||||
.second-calendar-page {
|
.second-calendar-page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
min-height: 100dvh;
|
height: 100dvh;
|
||||||
padding: 24px;
|
min-height: 0;
|
||||||
|
padding: 16px 20px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
background: #f4f7f9;
|
background: #f4f7f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-filter {
|
.month-filter {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 18px;
|
min-height: 32px;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-picker {
|
.month-picker {
|
||||||
@@ -418,18 +544,26 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-stage {
|
.calendar-stage {
|
||||||
|
width: 100%;
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 72px minmax(0, 1fr) 72px;
|
grid-template-columns: 56px minmax(0, 1fr) 56px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-panel {
|
.calendar-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 22px 24px 26px;
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #e5edf5;
|
border: 1px solid #e5edf5;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -475,23 +609,24 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-head {
|
.calendar-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-title {
|
.month-title {
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
line-height: 30px;
|
line-height: 26px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2a37;
|
color: #1f2a37;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-range {
|
.month-range {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 18px;
|
||||||
color: #7c8aa5;
|
color: #7c8aa5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,26 +637,33 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weekday-row {
|
.weekday-row {
|
||||||
gap: 8px;
|
flex: 0 0 auto;
|
||||||
margin-bottom: 8px;
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekday-cell {
|
.weekday-cell {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 20px;
|
line-height: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #5f6f86;
|
color: #5f6f86;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
gap: 8px;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 6px;
|
||||||
|
grid-auto-rows: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-card {
|
.day-card {
|
||||||
min-height: 138px;
|
min-height: 0;
|
||||||
padding: 12px;
|
padding: 8px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid #e4ebf3;
|
border: 1px solid #e4ebf3;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -565,17 +707,18 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.day-card-head {
|
.day-card-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 24px;
|
min-height: 20px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-number {
|
.day-number {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
line-height: 24px;
|
line-height: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #27364a;
|
color: #27364a;
|
||||||
}
|
}
|
||||||
@@ -589,8 +732,8 @@ const formatProfitFlag = (value) => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 18px;
|
||||||
height: 22px;
|
height: 18px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #eef2f7;
|
background: #eef2f7;
|
||||||
color: #7c8aa5;
|
color: #7c8aa5;
|
||||||
@@ -600,9 +743,10 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.day-metrics {
|
.day-metrics {
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row {
|
.metric-row {
|
||||||
@@ -610,15 +754,15 @@ const formatProfitFlag = (value) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 24px;
|
min-height: 18px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 18px;
|
||||||
color: #5f6f86;
|
color: #5f6f86;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row strong {
|
.metric-row strong {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
line-height: 22px;
|
line-height: 18px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #26364b;
|
color: #26364b;
|
||||||
}
|
}
|
||||||
@@ -636,14 +780,15 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.day-placeholder {
|
.day-placeholder {
|
||||||
min-height: 70px;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #a0acbb;
|
color: #a0acbb;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-summary {
|
.detail-summary {
|
||||||
@@ -680,17 +825,50 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.second-calendar-page {
|
.second-calendar-page {
|
||||||
padding: 18px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-panel {
|
.calendar-panel {
|
||||||
padding: 18px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-stage {
|
.calendar-stage {
|
||||||
@@ -699,8 +877,7 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.day-card {
|
.day-card {
|
||||||
min-height: 128px;
|
padding: 8px;
|
||||||
padding: 10px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,7 +919,6 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.day-card {
|
.day-card {
|
||||||
min-height: 112px;
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +938,6 @@ const formatProfitFlag = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.day-placeholder {
|
.day-placeholder {
|
||||||
min-height: 58px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,4 +945,56 @@ const formatProfitFlag = (value) => {
|
|||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
+3
-1
@@ -2,6 +2,8 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
|
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:18080'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -12,7 +14,7 @@ export default defineConfig({
|
|||||||
port: 5173, // 你的端口号
|
port: 5173, // 你的端口号
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/v1': {
|
'/api/v1': {
|
||||||
target: 'http://localhost:18080',
|
target: apiProxyTarget,
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user