rebuild(stage-7): deliver ladder and sector rotation
This commit is contained in:
@@ -18,7 +18,7 @@ const locked = computed(
|
||||
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
|
||||
);
|
||||
const implementedMarket = computed(() =>
|
||||
["emotion", "pool", "broken", "limit-down", "yesterday", "performance"].includes(
|
||||
["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation"].includes(
|
||||
workspace.value.key,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import EmotionPage from "./emotion/EmotionPage.vue";
|
||||
import LadderPage from "./structure/LadderPage.vue";
|
||||
import RotationPage from "./structure/RotationPage.vue";
|
||||
import PerformancePage from "./pools/PerformancePage.vue";
|
||||
import PoolPage from "./pools/PoolPage.vue";
|
||||
|
||||
@@ -38,6 +40,8 @@ watch([() => props.workspaceKey, () => market.selectedDate], () => void load(),
|
||||
<EmptyState v-else-if="error" title="页面数据暂不可用" :description="error" class="card" />
|
||||
<EmptyState v-else-if="!data?.trade_date" title="暂无真实行情快照" :description="data?.message || '等待管理员首次同步真实收盘行情'" class="card" />
|
||||
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
|
||||
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
|
||||
<RotationPage v-else-if="workspaceKey === 'rotation'" :data="data" />
|
||||
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
|
||||
<PoolPage v-else :kind="workspaceKey" :data="data" />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
|
||||
import type { MarketWorkspaceData } from "../../../shared/api/market";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const sortMode = ref<"time" | "open">("time");
|
||||
const expanded = reactive(new Set<number>());
|
||||
const sourceGroups = computed(() => props.data.items ?? []);
|
||||
const maxLevel = computed(() => Math.max(0, ...sourceGroups.value.map((group) => Number(group.level ?? 0))));
|
||||
const topLevel = computed(() => Math.max(5, maxLevel.value));
|
||||
const groups = computed(() => {
|
||||
const byLevel = new Map(sourceGroups.value.map((group) => [Number(group.level), group]));
|
||||
const normalized = Array.from({ length: topLevel.value }, (_, index) => {
|
||||
const level = topLevel.value - index;
|
||||
const source = byLevel.get(level) ?? { level, label: level === 1 ? "首板" : `${level}板`, count: 0, stocks: [] };
|
||||
const stocks = [...((source.stocks as Record<string, unknown>[] | undefined) ?? [])].sort((left, right) => {
|
||||
if (sortMode.value === "open") {
|
||||
return Number(left.open_times ?? 0) - Number(right.open_times ?? 0)
|
||||
|| String(left.first_time ?? "99:99").localeCompare(String(right.first_time ?? "99:99"));
|
||||
}
|
||||
return String(left.first_time ?? "99:99").localeCompare(String(right.first_time ?? "99:99"));
|
||||
});
|
||||
const limit = level <= 2 ? 8 : stocks.length;
|
||||
return {
|
||||
level,
|
||||
label: String(source.label ?? (level === 1 ? "首板" : `${level}板`)),
|
||||
count: Number(source.count ?? stocks.length),
|
||||
stocks,
|
||||
visible: expanded.has(level) ? stocks : stocks.slice(0, limit),
|
||||
hidden: Math.max(0, stocks.length - limit),
|
||||
};
|
||||
});
|
||||
return normalized.map((group) => {
|
||||
const lower = normalized.find((item) => item.level === group.level - 1);
|
||||
return {
|
||||
...group,
|
||||
adjacentRate: lower?.count ? group.count / lower.count * 100 : null,
|
||||
};
|
||||
});
|
||||
});
|
||||
const completeness = computed(() => maxLevel.value
|
||||
? sourceGroups.value.filter((group) => Number(group.count ?? 0) > 0).length / maxLevel.value * 100
|
||||
: 0);
|
||||
const priorPerformance = computed(() => props.data.history ?? []);
|
||||
const priorHeight = computed(() => Math.max(0, ...priorPerformance.value.map((row) => Number(row.level ?? 0))));
|
||||
const heightMessage = computed(() => {
|
||||
if (!maxLevel.value) return "当前没有连板梯队。";
|
||||
if (!priorPerformance.value.length) return "暂无昨日梯队样本,先观察当前结构。";
|
||||
if (maxLevel.value > priorHeight.value) return `较昨日${priorHeight.value}板抬升,市场空间打开。`;
|
||||
if (maxLevel.value < priorHeight.value) return `较昨日${priorHeight.value}板回落,市场空间压缩。`;
|
||||
return "市场高度与昨日接近,继续观察梯队承接。";
|
||||
});
|
||||
|
||||
function toggle(level: number): void {
|
||||
if (expanded.has(level)) expanded.delete(level);
|
||||
else expanded.add(level);
|
||||
}
|
||||
|
||||
function exportCsv(): void {
|
||||
const lines = [["高度", "代码", "股票", "板块", "首封", "开板次数", "成交额(亿)"]];
|
||||
for (const group of groups.value) {
|
||||
for (const stock of group.stocks) {
|
||||
lines.push([
|
||||
`${group.level}板`, String(stock.code ?? ""), String(stock.name ?? ""),
|
||||
String(stock.sector ?? ""), String(stock.first_time ?? ""), String(stock.open_times ?? ""),
|
||||
(Number(stock.amount ?? 0) / 100_000_000).toFixed(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
const content = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(",")).join("\r\n");
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" }));
|
||||
link.download = `市场天梯-${props.data.trade_date}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>市场天梯</h1><p class="page-subtitle">按连板高度观察空间与梯队完整度 · 数据日期 {{ data.trade_date }}</p></div>
|
||||
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: sortMode === 'time' }" @click="sortMode = 'time'">按封板时间</button><button type="button" :class="{ active: sortMode === 'open' }" @click="sortMode = 'open'">按开板次数</button></div><button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button></div>
|
||||
</header>
|
||||
<section class="ladder-layout">
|
||||
<div class="card ladder-board">
|
||||
<section v-for="group in groups" :key="group.level" class="ladder-level" :class="{ 'is-gap': !Number(group.count) }">
|
||||
<header><div><strong>{{ group.level === 1 ? "首板" : `${group.level}板` }}</strong><span>{{ group.count }}只</span></div><small v-if="group.level > 1 && Number(group.count)">{{ group.adjacentRate === null ? "相邻梯队断层" : `相邻梯队 ${group.adjacentRate.toFixed(1)}%` }}</small></header>
|
||||
<div class="ladder-stocks">
|
||||
<article v-for="stock in group.visible" :key="String(stock.identifier)" class="ladder-stock">
|
||||
<div><strong>{{ stock.name }}</strong><span>{{ stock.code }}</span></div>
|
||||
<p><b>{{ stock.sector || "其他" }}</b><span>{{ stock.first_time || "时间待校正" }}</span></p>
|
||||
<small>开板 {{ stock.open_times }}次 · 成交 {{ (Number(stock.amount ?? 0) / 100_000_000).toFixed(2) }}亿</small>
|
||||
</article>
|
||||
<p v-if="!group.visible.length" class="ladder-gap-note">该层暂时空缺</p>
|
||||
<button v-if="group.stocks.length > 8 && group.level <= 2" class="ladder-expand" type="button" @click="toggle(group.level)">{{ expanded.has(group.level) ? "收起" : `展开更多(${group.hidden})` }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="ladder-insights">
|
||||
<article class="card"><header class="card-header"><h2>市场高度</h2></header><div class="ladder-insight-body"><strong>{{ maxLevel ? `${maxLevel}板` : "--" }}</strong><p>{{ heightMessage }}</p></div></article>
|
||||
<article class="card"><header class="card-header"><h2>梯队完整度</h2></header><div class="ladder-insight-body"><strong>{{ completeness.toFixed(1) }}%</strong><div class="score-track"><span :style="{ width: `${completeness}%` }"></span></div><p>从首板到最高板之间存在的真实高度比例。</p></div></article>
|
||||
<article class="card"><header class="card-header"><h2>昨日梯队 → 今日</h2></header><div class="ladder-rate-list"><div v-for="row in priorPerformance" :key="String(row.level)"><span>昨日{{ row.level }}板</span><div class="score-track"><span :style="{ width: `${row.advance_rate}%` }"></span></div><strong>{{ row.advance_rate }}%</strong></div></div></article>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { marketApi, type MarketWorkspaceData, type RotationMembersData } from "../../../shared/api/market";
|
||||
import DataTable from "../../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const order = ref<"oldest" | "latest">("oldest");
|
||||
const selectedSector = ref("");
|
||||
const selectedDate = ref("");
|
||||
const members = ref<RotationMembersData | null>(null);
|
||||
const memberLoading = ref(false);
|
||||
const memberError = ref("");
|
||||
const history = computed(() => order.value === "oldest" ? props.data.history ?? [] : [...(props.data.history ?? [])].reverse());
|
||||
const appearances = computed(() => (props.data.history ?? []).map((day) => ({
|
||||
trade_date: day.trade_date,
|
||||
sector: ((day.sectors as Record<string, unknown>[] | undefined) ?? []).find((item) => item.name === selectedSector.value),
|
||||
})).filter((item) => item.sector));
|
||||
const memberColumns = [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "涨跌幅(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "open", label: "开盘(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "close", label: "收盘(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
|
||||
{ key: "quoted", label: "行情状态", format: (value: unknown) => value ? "正常交易" : "当日无行情" },
|
||||
];
|
||||
const sortKey = ref("change");
|
||||
const sortDirection = ref<"asc" | "desc">("desc");
|
||||
const memberRows = computed(() => {
|
||||
const rows = members.value?.items ?? [];
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => {
|
||||
if (Boolean(left.quoted) !== Boolean(right.quoted)) return left.quoted ? -1 : 1;
|
||||
return compare(left[sortKey.value], right[sortKey.value]) * direction;
|
||||
});
|
||||
});
|
||||
|
||||
async function selectSector(sector: Record<string, unknown>, tradeDate: unknown): Promise<void> {
|
||||
const name = String(sector.name ?? "");
|
||||
const date = String(tradeDate ?? "");
|
||||
if (name === selectedSector.value && date === selectedDate.value) {
|
||||
selectedSector.value = ""; selectedDate.value = ""; members.value = null; memberError.value = "";
|
||||
return;
|
||||
}
|
||||
selectedSector.value = name;
|
||||
selectedDate.value = date;
|
||||
memberLoading.value = true;
|
||||
memberError.value = "";
|
||||
try {
|
||||
members.value = await marketApi.rotationMembers(name, date);
|
||||
} catch (error) {
|
||||
members.value = null;
|
||||
memberError.value = error instanceof Error ? error.message : "板块成分股读取失败";
|
||||
} finally {
|
||||
memberLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
|
||||
function exportCsv(): void {
|
||||
const lines = [["日期", "排名", "板块", "涨停家数", "强度", "领涨股"]];
|
||||
for (const day of props.data.history ?? []) {
|
||||
for (const sector of (day.sectors as Record<string, unknown>[] | undefined) ?? []) {
|
||||
lines.push([String(day.trade_date ?? ""), String(sector.rank ?? ""), String(sector.name ?? ""), String(sector.count ?? ""), String(sector.strength ?? ""), String(sector.leader ?? "")]);
|
||||
}
|
||||
}
|
||||
const content = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(",")).join("\r\n");
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" }));
|
||||
link.download = `板块轮动-${props.data.trade_date}.csv`;
|
||||
link.click(); URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function compare(left: unknown, right: unknown): number {
|
||||
const a = Number(left); const b = Number(right);
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) return a - b;
|
||||
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN");
|
||||
}
|
||||
function numeric(value: unknown, digits: number): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number.toFixed(digits) : "";
|
||||
}
|
||||
function percent(value: unknown): string { return numeric(value, 2); }
|
||||
function decimal(value: unknown): string { return numeric(value, 2); }
|
||||
function amount(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? (number / 100_000_000).toFixed(2) : "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>板块轮动</h1><p class="page-subtitle">最近9个交易日每日Top12热点 · 数据截至 {{ data.trade_date }}</p></div>
|
||||
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: order === 'oldest' }" @click="order = 'oldest'">由远到近</button><button type="button" :class="{ active: order === 'latest' }" @click="order = 'latest'">由近到远</button></div><button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button></div>
|
||||
</header>
|
||||
<section class="card rotation-trajectory">
|
||||
<header class="card-header"><h2>热点轨迹</h2><span class="faint">{{ selectedSector ? `已联动高亮 ${selectedSector}` : "点击任意板块追踪其连续性" }}</span><span class="tag rotation-top-tag">每日Top12热点</span></header>
|
||||
<div class="rotation-legend"><span><i class="heat-high"></i>强度高(90+)</span><span><i class="heat-medium"></i>强度中(70–89)</span><span><i class="heat-low"></i>强度低(70以下)</span></div>
|
||||
<div v-if="selectedSector" class="rotation-tracker"><strong>{{ selectedSector }}</strong><span>近9日在榜 {{ appearances.length }}天 · 最高排名 {{ appearances.length ? Math.min(...appearances.map((item) => Number(item.sector?.rank ?? 99))) : "--" }}</span><button class="btn btn-small" type="button" @click="selectedSector = ''; selectedDate = ''; members = null">取消追踪</button></div>
|
||||
<div class="rotation-days">
|
||||
<article v-for="day in history" :key="String(day.trade_date)" class="rotation-day">
|
||||
<header><time>{{ String(day.trade_date).slice(5) }}</time><span>{{ ((day.sectors as unknown[]) ?? []).length }}个热点</span></header>
|
||||
<div>
|
||||
<button v-for="sector in (day.sectors as Record<string, unknown>[]) ?? []" :key="String(sector.name)" type="button" class="rotation-sector" :class="[{ selected: selectedSector === sector.name }, Number(sector.strength) >= 90 ? 'heat-high' : Number(sector.strength) >= 70 ? 'heat-medium' : 'heat-low']" @click="selectSector(sector, day.trade_date)">
|
||||
<span>{{ sector.rank }}</span><strong>{{ sector.name }}</strong><small>{{ sector.count }}家 · {{ Number(sector.strength).toFixed(0) }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card rotation-members">
|
||||
<header class="card-header"><h2>{{ members?.sector_name ? `${members.sector_name}成分股` : "板块成分股" }}</h2><span class="faint">{{ members ? `${members.trade_date} · ${members.quoted_count}/${members.member_count}只` : selectedSector || "点击上方任意板块查看成分股" }}</span></header>
|
||||
<div v-if="memberLoading" class="workspace-state">正在核验板块成分行情</div>
|
||||
<EmptyState v-else-if="memberError" title="成分行情暂不可用" :description="memberError" />
|
||||
<DataTable v-else-if="memberRows.length" :columns="memberColumns" :rows="memberRows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="尚未选择板块" description="点击热点轨迹中的板块后显示目标日期的申万成分股。" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -67,6 +67,16 @@ export type MarketWorkspaceData = {
|
||||
items?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type RotationMembersData = {
|
||||
trade_date: string;
|
||||
sector_name: string;
|
||||
sector_code: string;
|
||||
member_count: number;
|
||||
quoted_count: number;
|
||||
coverage: number;
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export const marketApi = {
|
||||
summary(date?: string): Promise<MarketSummary> {
|
||||
const query = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||
@@ -88,4 +98,9 @@ export const marketApi = {
|
||||
syncSnapshot(date: string): Promise<{ trade_date: string; temperature: number }> {
|
||||
return api.post(`/market/snapshot-sync?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
rotationMembers(sector: string, date: string): Promise<RotationMembersData> {
|
||||
return api.get<RotationMembersData>(
|
||||
`/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,3 +116,300 @@
|
||||
.breadth-up { background: var(--color-up); }
|
||||
.breadth-flat { background: var(--color-text-faint); }
|
||||
.breadth-down { background: var(--color-down); }
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.page-actions .seg-control {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ladder-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--s-320);
|
||||
gap: var(--layout-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ladder-board {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ladder-level {
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-200) minmax(0, 1fr);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.ladder-level:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ladder-level > header {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-12) var(--s-14);
|
||||
border-right: var(--s-1) solid var(--color-divider);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.ladder-level.is-gap > header,
|
||||
.ladder-level.is-gap .ladder-stocks {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.ladder-level > header div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.ladder-level > header strong {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-15);
|
||||
}
|
||||
|
||||
.ladder-level > header span,
|
||||
.ladder-level > header small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.ladder-stocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--s-200), 1fr));
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-10);
|
||||
}
|
||||
|
||||
.ladder-stock {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-9);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--color-surface);
|
||||
transition: border-color var(--duration-fast) var(--ease-standard),
|
||||
background var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ladder-stock:hover {
|
||||
border-color: var(--color-primary-border);
|
||||
background: var(--color-primary-soft);
|
||||
transform: translateY(calc(var(--s-1) * -1));
|
||||
}
|
||||
|
||||
.ladder-stock > div,
|
||||
.ladder-stock > p {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.ladder-stock strong,
|
||||
.ladder-stock b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ladder-stock span,
|
||||
.ladder-stock small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.ladder-gap-note {
|
||||
align-self: center;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.ladder-expand {
|
||||
min-height: var(--s-44);
|
||||
border: var(--s-1) dashed var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.ladder-insights {
|
||||
display: grid;
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.ladder-insight-body {
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
padding: var(--s-14);
|
||||
}
|
||||
|
||||
.ladder-insight-body > strong {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-18);
|
||||
}
|
||||
|
||||
.ladder-insight-body p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-12);
|
||||
line-height: var(--s-18);
|
||||
}
|
||||
|
||||
.ladder-rate-list {
|
||||
display: grid;
|
||||
gap: var(--s-9);
|
||||
padding: var(--s-12) var(--s-14);
|
||||
}
|
||||
|
||||
.ladder-rate-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-64) minmax(0, 1fr) var(--s-44);
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.ladder-rate-list strong {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rotation-trajectory,
|
||||
.rotation-members {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rotation-top-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rotation-legend {
|
||||
min-height: var(--s-34);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-16);
|
||||
padding: var(--s-6) var(--s-14);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.rotation-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.rotation-legend i {
|
||||
width: var(--s-14);
|
||||
height: var(--s-9);
|
||||
border-radius: var(--radius-4);
|
||||
}
|
||||
|
||||
.rotation-tracker {
|
||||
min-height: var(--s-46);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-14);
|
||||
padding: var(--s-7) var(--s-14);
|
||||
border-bottom: var(--s-1) solid var(--color-primary-border);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.rotation-tracker strong {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.rotation-tracker span {
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.rotation-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.rotation-day {
|
||||
min-width: 0;
|
||||
border-right: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.rotation-day:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.rotation-day > header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-8);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.rotation-day > header time {
|
||||
font-weight: var(--weight-700);
|
||||
}
|
||||
|
||||
.rotation-day > header span {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-10-5);
|
||||
}
|
||||
|
||||
.rotation-day > div {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.rotation-sector {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-20) minmax(0, 1fr);
|
||||
gap: var(--s-2) var(--s-6);
|
||||
padding: var(--s-7) var(--s-8);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
text-align: left;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.rotation-sector > span {
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rotation-sector strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.rotation-sector small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-10-5);
|
||||
}
|
||||
|
||||
.rotation-sector.selected {
|
||||
color: var(--color-primary);
|
||||
outline: var(--s-1) solid var(--color-primary);
|
||||
outline-offset: calc(var(--s-1) * -1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.heat-high { background: var(--color-up-soft); }
|
||||
.heat-medium { background: var(--color-warning-soft); }
|
||||
.heat-low { background: var(--color-primary-soft); }
|
||||
|
||||
@@ -197,4 +197,51 @@
|
||||
.performance-conclusion {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.market-page-header .page-actions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.market-page-header .page-actions .seg-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.market-page-header .page-actions .seg-control button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ladder-layout,
|
||||
.ladder-level {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ladder-level > header {
|
||||
border-right: 0;
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.ladder-stocks {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rotation-legend {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rotation-tracker {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rotation-days {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rotation-day {
|
||||
border-right: 0;
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user