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